前言
作為一名無所事事的公司蛀蟲,總是想在平靜的日子里搞出點(diǎn)事情躯保。于是我發(fā)現(xiàn)旋膳,公司的網(wǎng)絡(luò)層作為基礎(chǔ)庫竟然沒有單元測試覆蓋,是不是有失軟件工程水準(zhǔn)呢途事?于是就有了接下來的故事...
Why验懊?
當(dāng)我們做某件事情的時(shí)候,我們常常抱有強(qiáng)烈的目的性尸变,那么單元測試的目的是什么呢义图?為什么要有單元測試呢?
遺憾的是召烂,作為一個(gè)‘人’碱工,我們無法控制我們想控制的事物按照預(yù)想的情況運(yùn)作下去。即便是那些很厲害很厲害的開發(fā)人員奏夫,在介紹他的時(shí)候也只能說“幾乎沒有BUG”怕篷,而那些肉眼我們無法察覺的BUG就需要我們通過測試來發(fā)現(xiàn)并且修正它了。
What桶蛔?
那么說了那么多匙头,到底什么是單元測試呢?我們可以來看一下維基百科上的定義仔雷。
In computer programming, unit testing is a software testing method by which individual units of source code, sets of one or more computer program modules together with associated control data, usage procedures, and operating procedures, are tested to determine whether they are fit for use.
在計(jì)算機(jī)編程中蹂析,單元測試是一種軟件測試方法,通過該方法測試各個(gè)單位的源代碼碟婆,一個(gè)或多個(gè)計(jì)算機(jī)程序模塊的組合以及關(guān)聯(lián)的控制數(shù)據(jù)电抚,使用和操作程序,以確定它們是否正常運(yùn)行竖共。
簡單的來說蝙叛,單元測試是使用程序控制的以類或者函數(shù)為單元的期望判斷。比如公给,我們需要測試一個(gè)計(jì)算器中的加法(來自于Apple官方文檔):
- (void)testAddition
{
// obtain the app variables for test access
app = [NSApplication sharedApplication];
calcViewController = (CalcViewController*)[[NSApplication sharedApplication] delegate];
calcView = calcViewController.view;
// perform two addition tests
[calcViewController press:[calcView viewWithTag: 6]]; // 6
[calcViewController press:[calcView viewWithTag:13]]; // +
[calcViewController press:[calcView viewWithTag: 2]]; // 2
[calcViewController press:[calcView viewWithTag:12]]; // =
XCTAssertEqualObjects([calcViewController.displayField stringValue], @"8", @"Part 1 failed.");
}
在這個(gè)中借帘,我們的測試目的只有一個(gè)蜘渣,那就是在加法的情況之下,進(jìn)行6+2
的運(yùn)行肺然,并且期望結(jié)果為8蔫缸,如果期望不滿足,那么Xcode就會在該斷言上失敗际起。這幾乎是最簡單的一個(gè)單元測試了拾碌,但是在真實(shí)的世界中,我們所碰到的情況比這復(fù)雜的多街望。比如校翔,我們需要測試的方法是異步的,我們所測試的方法互相依賴灾前,我們需要測試一個(gè)方法的性能等等防症,那么如何在真實(shí)的復(fù)雜情況之下編寫出令人滿意的良好測試呢?
命名
按照Apple官方文檔豫柬,相信你能很快的新建一個(gè)項(xiàng)目的測試Target告希,當(dāng)你新建一個(gè).swift
文件之后你的心中可能會突然一下顫抖,然后發(fā)出宇宙終極的三問:我在哪烧给?我是誰?我在干什么喝噪?
是的础嫡,你在公司,你是一個(gè)死宅碼農(nóng)酝惧,你在寫單元測試榴鼎!
可是,你卻遲遲不能動手寫下一行代碼晚唇,不是因?yàn)槟悴恢老胍獪y試什么功能巫财,你知道你想測試網(wǎng)絡(luò)層的Get請求是否正常運(yùn)作,但是你不知道該怎么樣給這個(gè)測試取一個(gè)名字哩陕,就好像一個(gè)爸爸看到剛出生的baby一樣手足無措平项。
要不就叫它func testGet()
吧!
然而當(dāng)你敲下方法名的定義之后悍及,你敏銳的工程師思維開始發(fā)揮了作用闽瓢,如果我的Get
方法帶參數(shù)怎么辦?如果不帶參正常運(yùn)行心赶,帶參失敗了怎么辦扣讼?也就是說我不止需要一個(gè)Get的測試方法,那么我的命名應(yīng)該如何呢缨叫?
確實(shí)椭符,這樣的測試不僅沒有能夠測試到應(yīng)該覆蓋的測試case荔燎,同時(shí)也不便于維護(hù),他人很難通過方法命名一眼就看出你測試的意圖销钝。
那么良好的測試命名應(yīng)該是怎么樣的呢湖雹?
總的來說,良好的測試命名應(yīng)該有如下的特點(diǎn):
- 全局測試內(nèi)的命名統(tǒng)一曙搬。
- 命名可以清晰的闡明測試意圖摔吏。
- 命名可以清晰的闡明測試期望以及副作用(如果有的話)。
1.Plan A
在A方案中纵装,我們單元測試的名稱將分為三部分:方法名稱(method name)+ 執(zhí)行測試用例的狀態(tài)(state under test)+ 預(yù)期名為(expected behavior)示例如下:
/// 這是一個(gè)除法的測試征讲,在分母為0的情況之下,我們期望拋出異常
func divide_ZeroAs2ndParam_ExceptionThrown()
可以看到橡娄,在這樣的命名規(guī)范之下诗箍,他人也可以通過方法名清晰明了的知道該方法在怎樣的期望輸入或者狀態(tài)之下會產(chǎn)出什么樣的輸出或者狀態(tài)。
更加詳細(xì)的關(guān)于該測試方法名的論述挽唉,大家可以看一下Roy Osherove](http://osherove.com/blog/2005/4/3/naming-standards-for-unit-tests.html)的Blog滤祖。
Tips:當(dāng)我們修改了所測試的方法名字之后,原測試方法就已經(jīng)偏離了命名規(guī)范瓶籽,所以需要我們手動的修改測試方法匠童。但是這樣的工作明顯是最無效和重復(fù)的。因此也可以這樣做:我們將原來的方法名稱(method name)更改成了抽象的方法名稱塑顺,而不是將原來的方法名稱一字不落的當(dāng)做測試方法的前綴汤求。
2. Plan B
在B方案中,我們將采用Given-When-Then
的方式進(jìn)行命名組織严拒,該組織方式來源于BDD(Behavior-Driven Development)扬绪。具體的命名例子如下:
/// Given: 當(dāng)前測試所給予的輸入或者初始狀態(tài)
/// Action: 當(dāng)前測試所要進(jìn)行的操作
/// Then: 當(dāng)前測試所期望的輸出狀態(tài)或者輸出
func Given_StateUnderTest_When_ActionUnderTest_Then_ExpectedOutcomes()
我們可以看到,在Given-When-Then
的命名方式之下裤唠,我們滿足了所有良好測試命名的特點(diǎn)挤牛,與此同時(shí)似乎還看起來有一些過于“啰嗦”,但是這也并不是什么大問題种蘸,畢竟清晰的意圖的優(yōu)先級總比簡短的命名優(yōu)先級更高墓赴。
總的來說,測試的命名并沒有刻板的規(guī)定劈彪,只要滿足自身的測試需要竣蹦,滿足公認(rèn)的測試名稱規(guī)范就可以。當(dāng)然還有一些其他的命名方式沧奴,但是基本上也都是與上述的兩種方法類似或者是變種痘括。最重要的是,我們知道了命名的準(zhǔn)則,那么我們也可以制作出屬于自己的規(guī)范纲菌。
關(guān)于斷言數(shù)的爭論
在我跟同事關(guān)于單元測試的討論中挠日,同事提出單元測試最好只有一個(gè)assert
,不然當(dāng)測試不通過的時(shí)候無法知道具體fail在哪里翰舌。但是嚣潜,具體在iOS的XCTest中,我們知道當(dāng)某一個(gè)斷言無法滿足條件的時(shí)候椅贱,Xcode會直接卡在那個(gè)斷言之上懂算,并且告訴你不通過的原因,如下圖所示:
但是我也知道庇麦,一個(gè)單元測試的用例最好只包含一個(gè)assert
這樣的觀點(diǎn)也由來已久计技,那么到底在編寫單元測試的用例的時(shí)候該不該使用多個(gè)斷言呢?
我們先來看看贊成單元測試用例只寫一個(gè)斷言的其他理由:
如果你在一個(gè)測試中包含了不只一個(gè)斷言山橄,則你的測試目的就不只一個(gè)垮媒。在這種情況下,測試名稱變得奇怪不清晰航棱,測試變得太長睡雇,反饋也變得不清晰;你永遠(yuǎn)無法知道哪個(gè)斷言通過了饮醇,哪個(gè)斷言失敗了它抱。假如你依次有三個(gè)斷言。如果第一個(gè)斷言失敗了驳阎,則后面兩個(gè)永遠(yuǎn)都不會檢查抗愁。如果你修改了一些生產(chǎn)代碼,那么當(dāng)代碼變化時(shí)呵晚,后面兩個(gè)斷言就無法發(fā)揮作用了。在這種情況下沫屡,你就會錯(cuò)誤地認(rèn)為自己的代碼有安全保障和回歸測試饵隙。 ---編寫良好的單元測試
其實(shí),我確實(shí)同意上述的某些觀點(diǎn)的沮脖,比如測試的目的應(yīng)當(dāng)只有一個(gè)
金矛,
但是當(dāng)你只有一個(gè)測試目的的時(shí)候就代表我們只能有一個(gè)斷言么?我想這個(gè)推論應(yīng)當(dāng)是錯(cuò)誤的勺届。
我們可以在StackOverFlow里看到相關(guān)的討論驶俊,其中第二個(gè)回答我深以為然,比如我們要測試所得到數(shù)值是否在一個(gè)數(shù)值區(qū)間內(nèi)免姿,我們的單元測試代碼可能是這樣的:
public void ValueIsInRange()
{
int value = GetValueToTest();
Assert.That(value, Is.GreaterThan(10), "value is too small");
Assert.That(value, Is.LessThan(100), "value is too large");
}
在這里我們所要測試的確確實(shí)實(shí)是一個(gè)單獨(dú)的目的饼酿,即“該數(shù)值是否在某個(gè)區(qū)間內(nèi)”,但是很顯然我們需要兩個(gè)斷言來分別判斷數(shù)值的上界和下界。當(dāng)然我們也可以通過isInRange
之類的方便來將兩個(gè)斷言合并成一個(gè)故俐,但是這樣真的是一個(gè)好的測試用例么想鹰?當(dāng)用例的失敗的時(shí)候,我們只能知道該數(shù)值不在指定的范圍內(nèi)药版,但是我們甚至都不知道它是超過了上界還是下界辑舷。
綜上所述,“一個(gè)單元測試最好只有一個(gè)斷言”并不十分準(zhǔn)確槽片,或許我們應(yīng)當(dāng)信奉的應(yīng)該是“一個(gè)單元測試應(yīng)當(dāng)只有一個(gè)邏輯單元何缓,只有一個(gè)測試目的”,本著這樣的宗旨还栓,寫出只有一個(gè)斷言的測試應(yīng)該是自然而然的事情碌廓,在需要的時(shí)候可以使用多個(gè)斷言。
函數(shù)式編程和單元測試
在傳統(tǒng)的面向?qū)ο缶幊踢^程中蝙云,我們總是能會和各種各樣的狀態(tài)機(jī)進(jìn)行交互氓皱,因?yàn)槊嫦驅(qū)ο缶幊痰暮诵氖欠庋b,那么我們就免不了將各種狀態(tài)封裝在對象的內(nèi)部勃刨。然而隨著軟件規(guī)模的不斷龐大波材,各種復(fù)雜的狀態(tài)機(jī)也導(dǎo)致了難以維護(hù)、難以迭代和難以測試的問題身隐。
那么具體在單元測試當(dāng)中廷区,狀態(tài)機(jī)又是怎樣拖累我們的測試的呢?又為什么說純函數(shù)的方法便于單元測試呢贾铝?
首先我們需要搞懂什么是副作用:
In computer science, a function or expression is said to have a side effect if it modifies some state outside its scope or has an observable interaction with its calling functions or the outside world besides returning a value.
在計(jì)算機(jī)科學(xué)中隙轻,如果一個(gè)函數(shù)或表達(dá)式修改某個(gè)超出其范圍的狀態(tài),或者除了返回一個(gè)值之外還有一個(gè)與其調(diào)用函數(shù)或外部世界的可觀察的交互垢揩,這個(gè)函數(shù)或表達(dá)式會產(chǎn)生副作用玖绿。 -------------- from wikipedia
反過來說,無副作用的函數(shù)是指不會對外部作用域產(chǎn)生影響并且函數(shù)的作用是恒定不變的叁巨。
對于單元測試而言斑匪,很明顯無副作用的函數(shù)更加容易測試,函數(shù)式編程的每個(gè)單元函數(shù)更加符合“單一職責(zé)”锋勺,而“單一職責(zé)”的函數(shù)則契合了單元測試?yán)?測試的目的應(yīng)當(dāng)只有一個(gè)"的準(zhǔn)則蚀瘸。
舉個(gè)例子,如下有一個(gè)非純函數(shù)的場景(impure function):
class Person {
var friends: [String] = []
func addFriend(_ name: String) {
self.friends.append(name)
}
}
class PersonTest: XCTestCase {
let me = Person()
func testAddFriend() {
me.addFriend("jason")
XCTAssert(me.friends == ["jason"])
}
}
我們可以看到庶橱,上述代碼段的寫法是經(jīng)典的面向?qū)ο笏枷胂碌膶懛ㄖN覀冊跍y試的過程中創(chuàng)建了一個(gè)Person
的實(shí)例對象me
,然后在testAddFriend
方法中測試添加朋友的這一個(gè)操作是否正確執(zhí)行苏章。然而這樣簡單的操作卻存在著很大的“副作用”寂嘉,首先,在執(zhí)行操作的時(shí)候我們并不知道之前是否已經(jīng)存在friends
,如果存在了之前已經(jīng)存在過friends
垫释,那么這里的斷言將會失敗丝格,其次在addFriends
所產(chǎn)生的副作用也會影響之后的單元測試,可能會導(dǎo)致之前好好的單元測試用例發(fā)生不可預(yù)計(jì)的錯(cuò)誤棵譬。
那么显蝌,經(jīng)過無副作用
的函數(shù)應(yīng)該是怎么樣的呢?在這里推薦一下onevcat關(guān)于單向數(shù)據(jù)流控制器的文章订咸,在那里會有更加清晰易懂的純函數(shù)式的例子曼尊。在本篇文章中,主要為了更加簡單的展示“純函數(shù)”對測試的作用脏嚷,因此也是一些比較簡單的改造骆撇,大概如下所示:
class Person {
var state: State = State(friends: [])
struct State {
let friends: [String]
/// other state stuff ...
}
enum Action {
case addFriend(String)
/// other action stuff ...
}
lazy var reducer: (State, Action) -> State = { (state: State, action: Action) in
var internalState = state
switch action {
case .addFriend(let name):
internalState = State(friends: state.friends + [name])
}
return internalState
}
func dispatch(_ action: Action) {
let previousState = state
let nextState = reducer(state, action)
state = nextState
}
}
class PersonTest: XCTestCase {
let me = Person()
func testAddFriend() {
let initState = Person.State(friends: [])
let newState = me.reducer(initState, .addFriend("jason"))
/// 在這里的測試沒有對外部變量產(chǎn)生任何副作用
XCTAssert(initState.friends == ["jason"])
}
func testOtherMethod() {
/// 其余的測試可以安全的進(jìn)行,me不會受到不安全的變動
}
}
我們可以看到父叙,經(jīng)過簡單的函數(shù)式改造之后神郊,測試函數(shù)就可以異常的純粹,測試用例也將清晰明了趾唱。所以涌乳,當(dāng)你發(fā)先自己的單元測試無法進(jìn)行下去,各種corner case
越來越多甜癞,各種狀態(tài)紛繁雜亂的時(shí)候夕晓,或許是時(shí)候考慮一下減少副作用,使用函數(shù)式的方法來改造我們的生產(chǎn)代碼悠咱,將自己解放出來蒸辆。
雖然純函數(shù)式的編程有這樣那樣的好處,但是遺憾的是析既,在實(shí)際的編程開發(fā)中躬贡,我們總是不可避免的產(chǎn)生副作用。諸如:修改全局變量眼坏,修改靜態(tài)變量逗宜,修改inout
入?yún)ⅲ瑨伋霎惓?丈В琁/O操作,調(diào)用其他的具有副作用的函數(shù)等等擂仍。那么我們需要做的是囤屹,將不可避免的副作用限制在可控的范圍之內(nèi),如果在程序中逢渔,所有的函數(shù)都在任意的作用域內(nèi)隨意穿梭肋坚,那么代碼將陷入維護(hù)和迭代的黑洞,永世不得翻身。
Stubs and Mock
注:在關(guān)于單元測試的文章中我們常持茄幔可以聽到
Stub
和Mock
的概念诲泌,而對于剛剛開展單元測試的人來說常常會混淆兩者。簡單來說铣鹏,Stub
指的是當(dāng)我們需要依賴某些真實(shí)的數(shù)據(jù)接口的時(shí)候敷扫,我們通過提供偽造的數(shù)據(jù)來進(jìn)行測試。而Mock
則是在Stub
的基礎(chǔ)上增加了對所需要依賴接口的校驗(yàn)诚卸,保證該方法被調(diào)用葵第。
假設(shè)我們需要測試一個(gè)網(wǎng)絡(luò)層,誠然合溺,我們也可以使用https://httpbin.org/的開放接口進(jìn)行測試卒密,但是這樣的測試有一些問題:
- 測試返回時(shí)間的不確定性,不能夠快速測試
- 測試依賴外部環(huán)境棠赛,測試數(shù)據(jù)不穩(wěn)定
- 難以模擬一些
corener case
和錯(cuò)誤返回哮奇,難以提升測試覆蓋率
基于以上幾點(diǎn)原因,一個(gè)比較好的辦法就是Mock數(shù)據(jù)睛约。在OC的時(shí)代鼎俘,由于OC是動態(tài)的語言,所以我們有一個(gè)非常強(qiáng)大的庫--OC痰腮,我們可以依賴runtime
輕松的fake出想要的數(shù)據(jù)來進(jìn)行單元測試而芥。
當(dāng)然,來到了Swift時(shí)代之后膀值,runtime
的方法就行不通了棍丐,但是我們依舊可以使用自定義的URLProtocol
來實(shí)現(xiàn)Mock,比較不錯(cuò)的開源項(xiàng)目比如Mockingjay沧踏,使用它我們就可以非常簡單的完成網(wǎng)絡(luò)層的Mock歌逢。
Quick Check
如果你學(xué)過Haskell
,那么你大概率聽說過Quick Check
翘狱。在上一小節(jié)中秘案,我們知道在某些時(shí)候我們需要通過Mock的技術(shù)來偽造數(shù)據(jù),但是我們難道就止步于此了么潦匈?
One more thing...
例如阱高,當(dāng)我們需要測試一個(gè)除法的時(shí)候,我們編寫了如下的代碼:
func testDivision() {
XCTAssert(1.divide(a: 1) == 1)
}
嗯茬缩,這樣的測試用例很簡單赤惊,我們輸入了[(1,1)]
作為測試的輸入集,當(dāng)進(jìn)行單元測試的時(shí)候凰锡,我們總是能得到成功的測試結(jié)果未舟,但是很明顯圈暗,當(dāng)分母為0
這樣的重要的數(shù)據(jù)邊界條件的時(shí)候程序就會出現(xiàn)錯(cuò)誤。當(dāng)然裕膀,上述的例子還只是一個(gè)極其純粹的單元測試员串,在真實(shí)的軟件環(huán)境當(dāng)中,我們將遇到的問題將更加復(fù)雜昼扛。
在無窮的測試集中找到最小的最高效的測試集幾乎是單元測試最難的部分寸齐。
有限的人力人腦和無限的測試集將是永恒的矛盾,所以人們便想出了類似Quick Check
的這樣的隨機(jī)數(shù)據(jù)生成器野揪。主要的思想就是访忿,通過你給定的數(shù)據(jù)范圍和類型限定,程序自動為你生成相關(guān)的數(shù)據(jù)來進(jìn)行測試斯稳,當(dāng)然啦萬能的Github已經(jīng)有人實(shí)現(xiàn)過了--typelift/SwiftCheck海铆。
具體的相關(guān)使用并不想浪費(fèi)篇幅來講,其實(shí)更讓我在意的是它的局限挣惰。
確實(shí)卧斟,我們現(xiàn)在可以根據(jù)給定的類型或者是范圍來隨機(jī)生成測試集,我們可以依靠機(jī)器的蠻力來進(jìn)行這樣暴力的測試憎茂,但是它真的帶給了我們有效的測試么珍语?它真的帶給了我們高效的測試么?
不可替代的人力
Quick Check
確實(shí)給了我們解決問題的一個(gè)新的視野竖幔,但是它也有始終無法突破的局限板乙。例如上述的例子,我們確實(shí)可以通過Quick Check
隨機(jī)快速的生成測試數(shù)據(jù)集拳氢,但是“隨機(jī)”與其說是它的優(yōu)點(diǎn)募逞,不如說是它的劣勢。
“隨機(jī)”是機(jī)器無奈的選擇馋评,是程序的妥協(xié)放接。即便你的機(jī)器再快,在無限的測試集面前依然無限趨近于0留特,機(jī)器無法思考到分母不能為0纠脾,類似這樣的策略和思考過程正是人腦所擅長的。
我們總覺得依靠“點(diǎn)點(diǎn)點(diǎn)”的測試人員很“Low”蜕青,甚至于我們總是希望這樣的測試人員被淘汰出局苟蹈。但是我們要知道,“點(diǎn)點(diǎn)點(diǎn)”測試人員的價(jià)值并不在于靈活的手指右核,而在于靈活的思考策略汉操。經(jīng)驗(yàn)豐富的測試人員,總能在無限的測試集中找到最有效高效的子集蒙兰,從而保證絕大多數(shù)的情況之下的軟件質(zhì)量磷瘤。
當(dāng)然,如果AI可以解決這樣的策略問題當(dāng)然最好搜变,但目前來看“人工智能”還是蠢得可怕采缚。
結(jié)語
“測試是為發(fā)現(xiàn)錯(cuò)誤而執(zhí)行程序的過程”。 ---- 《單元測試的藝術(shù)》
在資本洪流之下挠他,中國的互聯(lián)網(wǎng)公司普遍生活在恐慌之下扳抽,唯恐被市場淘他們加班加點(diǎn),或小步或大步的跑著殖侵。面對這產(chǎn)品飄忽不定的需求贸呢,技術(shù)人是否還能保持一顆匠人之心。
十幾年前拢军,我們在雨后的泥地里玩著泥巴楞陷,樂此不疲。母親氣呼呼的過來把我拖走茉唉,“還在玩固蛾?還不去做作業(yè),玩這個(gè)有什么用度陆!”艾凯。我戀戀不舍的看著我用泥巴建起的王國。
“這很有用”懂傀。
感謝參考
編寫良好的單元測試
iOS Unit Testing and UI Testing Tutorial
Real World Mocking in Swift