前言
作為一名無所事事的公司蛀蟲,總是想在平靜的日子里搞出點事情。于是我發現,公司的網絡層作為基礎庫竟然沒有單元測試覆蓋,是不是有失軟件工程水準呢?于是就有了接下來的故事...
Why?
當我們做某件事情的時候,我們常常抱有強烈的目的性,那么單元測試的目的是什么呢?為什么要有單元測試呢?
遺憾的是,作為一個‘人’,我們無法控制我們想控制的事物按照預想的情況運作下去。即便是那些很厲害很厲害的開發人員,在介紹他的時候也只能說“幾乎沒有BUG”,而那些肉眼我們無法察覺的BUG就需要我們通過測試來發現并且修正它了。
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.
在計算機編程中,單元測試是一種軟件測試方法,通過該方法測試各個單位的源代碼,一個或多個計算機程序模塊的組合以及關聯的控制數據,使用和操作程序,以確定它們是否正常運行。
簡單的來說,單元測試是使用程序控制的以類或者函數為單元的期望判斷。比如,我們需要測試一個計算器中的加法(來自于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.");
}
在這個中,我們的測試目的只有一個,那就是在加法的情況之下,進行6+2
的運行,并且期望結果為8,如果期望不滿足,那么Xcode就會在該斷言上失敗。這幾乎是最簡單的一個單元測試了,但是在真實的世界中,我們所碰到的情況比這復雜的多。比如,我們需要測試的方法是異步的,我們所測試的方法互相依賴,我們需要測試一個方法的性能等等,那么如何在真實的復雜情況之下編寫出令人滿意的良好測試呢?
命名
按照Apple官方文檔,相信你能很快的新建一個項目的測試Target,當你新建一個.swift
文件之后你的心中可能會突然一下顫抖,然后發出宇宙終極的三問:我在哪?我是誰?我在干什么?
是的,你在公司,你是一個死宅碼農,你在寫單元測試!
可是,你卻遲遲不能動手寫下一行代碼,不是因為你不知道想要測試什么功能,你知道你想測試網絡層的Get請求是否正常運作,但是你不知道該怎么樣給這個測試取一個名字,就好像一個爸爸看到剛出生的baby一樣手足無措。
要不就叫它func testGet()
吧!
然而當你敲下方法名的定義之后,你敏銳的工程師思維開始發揮了作用,如果我的Get
方法帶參數怎么辦?如果不帶參正常運行,帶參失敗了怎么辦?也就是說我不止需要一個Get的測試方法,那么我的命名應該如何呢?
確實,這樣的測試不僅沒有能夠測試到應該覆蓋的測試case,同時也不便于維護,他人很難通過方法命名一眼就看出你測試的意圖。
那么良好的測試命名應該是怎么樣的呢?
總的來說,良好的測試命名應該有如下的特點:
- 全局測試內的命名統一。
- 命名可以清晰的闡明測試意圖。
- 命名可以清晰的闡明測試期望以及副作用(如果有的話)。
1.Plan A
在A方案中,我們單元測試的名稱將分為三部分:方法名稱(method name)+ 執行測試用例的狀態(state under test)+ 預期名為(expected behavior)示例如下:
/// 這是一個除法的測試,在分母為0的情況之下,我們期望拋出異常
func divide_ZeroAs2ndParam_ExceptionThrown()
可以看到,在這樣的命名規范之下,他人也可以通過方法名清晰明了的知道該方法在怎樣的期望輸入或者狀態之下會產出什么樣的輸出或者狀態。
更加詳細的關于該測試方法名的論述,大家可以看一下Roy Osherove](http://osherove.com/blog/2005/4/3/naming-standards-for-unit-tests.html)的Blog。
Tips:當我們修改了所測試的方法名字之后,原測試方法就已經偏離了命名規范,所以需要我們手動的修改測試方法。但是這樣的工作明顯是最無效和重復的。因此也可以這樣做:我們將原來的方法名稱(method name)更改成了抽象的方法名稱,而不是將原來的方法名稱一字不落的當做測試方法的前綴。
2. Plan B
在B方案中,我們將采用Given-When-Then
的方式進行命名組織,該組織方式來源于BDD(Behavior-Driven Development)。具體的命名例子如下:
/// Given: 當前測試所給予的輸入或者初始狀態
/// Action: 當前測試所要進行的操作
/// Then: 當前測試所期望的輸出狀態或者輸出
func Given_StateUnderTest_When_ActionUnderTest_Then_ExpectedOutcomes()
我們可以看到,在Given-When-Then
的命名方式之下,我們滿足了所有良好測試命名的特點,與此同時似乎還看起來有一些過于“啰嗦”,但是這也并不是什么大問題,畢竟清晰的意圖的優先級總比簡短的命名優先級更高。
總的來說,測試的命名并沒有刻板的規定,只要滿足自身的測試需要,滿足公認的測試名稱規范就可以。當然還有一些其他的命名方式,但是基本上也都是與上述的兩種方法類似或者是變種。最重要的是,我們知道了命名的準則,那么我們也可以制作出屬于自己的規范。
關于斷言數的爭論
在我跟同事關于單元測試的討論中,同事提出單元測試最好只有一個assert
,不然當測試不通過的時候無法知道具體fail在哪里。但是,具體在iOS的XCTest中,我們知道當某一個斷言無法滿足條件的時候,Xcode會直接卡在那個斷言之上,并且告訴你不通過的原因,如下圖所示:
但是我也知道,一個單元測試的用例最好只包含一個assert
這樣的觀點也由來已久,那么到底在編寫單元測試的用例的時候該不該使用多個斷言呢?
我們先來看看贊成單元測試用例只寫一個斷言的其他理由:
如果你在一個測試中包含了不只一個斷言,則你的測試目的就不只一個。在這種情況下,測試名稱變得奇怪不清晰,測試變得太長,反饋也變得不清晰;你永遠無法知道哪個斷言通過了,哪個斷言失敗了。假如你依次有三個斷言。如果第一個斷言失敗了,則后面兩個永遠都不會檢查。如果你修改了一些生產代碼,那么當代碼變化時,后面兩個斷言就無法發揮作用了。在這種情況下,你就會錯誤地認為自己的代碼有安全保障和回歸測試。 ---編寫良好的單元測試
其實,我確實同意上述的某些觀點的,比如測試的目的應當只有一個
,
但是當你只有一個測試目的的時候就代表我們只能有一個斷言么?我想這個推論應當是錯誤的。
我們可以在StackOverFlow里看到相關的討論,其中第二個回答我深以為然,比如我們要測試所得到數值是否在一個數值區間內,我們的單元測試代碼可能是這樣的:
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");
}
在這里我們所要測試的確確實實是一個單獨的目的,即“該數值是否在某個區間內”,但是很顯然我們需要兩個斷言來分別判斷數值的上界和下界。當然我們也可以通過isInRange
之類的方便來將兩個斷言合并成一個,但是這樣真的是一個好的測試用例么?當用例的失敗的時候,我們只能知道該數值不在指定的范圍內,但是我們甚至都不知道它是超過了上界還是下界。
綜上所述,“一個單元測試最好只有一個斷言”并不十分準確,或許我們應當信奉的應該是“一個單元測試應當只有一個邏輯單元,只有一個測試目的”,本著這樣的宗旨,寫出只有一個斷言的測試應該是自然而然的事情,在需要的時候可以使用多個斷言。
函數式編程和單元測試
在傳統的面向對象編程過程中,我們總是能會和各種各樣的狀態機進行交互,因為面向對象編程的核心是封裝,那么我們就免不了將各種狀態封裝在對象的內部。然而隨著軟件規模的不斷龐大,各種復雜的狀態機也導致了難以維護、難以迭代和難以測試的問題。
那么具體在單元測試當中,狀態機又是怎樣拖累我們的測試的呢?又為什么說純函數的方法便于單元測試呢?
首先我們需要搞懂什么是副作用:
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.
在計算機科學中,如果一個函數或表達式修改某個超出其范圍的狀態,或者除了返回一個值之外還有一個與其調用函數或外部世界的可觀察的交互,這個函數或表達式會產生副作用。 -------------- from wikipedia
反過來說,無副作用的函數是指不會對外部作用域產生影響并且函數的作用是恒定不變的。
對于單元測試而言,很明顯無副作用的函數更加容易測試,函數式編程的每個單元函數更加符合“單一職責”,而“單一職責”的函數則契合了單元測試里"測試的目的應當只有一個"的準則。
舉個例子,如下有一個非純函數的場景(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"])
}
}
我們可以看到,上述代碼段的寫法是經典的面向對象思想下的寫法。我們在測試的過程中創建了一個Person
的實例對象me
,然后在testAddFriend
方法中測試添加朋友的這一個操作是否正確執行。然而這樣簡單的操作卻存在著很大的“副作用”,首先,在執行操作的時候我們并不知道之前是否已經存在friends
,如果存在了之前已經存在過friends
,那么這里的斷言將會失敗,其次在addFriends
所產生的副作用也會影響之后的單元測試,可能會導致之前好好的單元測試用例發生不可預計的錯誤。
那么,經過無副作用
的函數應該是怎么樣的呢?在這里推薦一下onevcat關于單向數據流控制器的文章,在那里會有更加清晰易懂的純函數式的例子。在本篇文章中,主要為了更加簡單的展示“純函數”對測試的作用,因此也是一些比較簡單的改造,大概如下所示:
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"))
/// 在這里的測試沒有對外部變量產生任何副作用
XCTAssert(initState.friends == ["jason"])
}
func testOtherMethod() {
/// 其余的測試可以安全的進行,me不會受到不安全的變動
}
}
我們可以看到,經過簡單的函數式改造之后,測試函數就可以異常的純粹,測試用例也將清晰明了。所以,當你發先自己的單元測試無法進行下去,各種corner case
越來越多,各種狀態紛繁雜亂的時候,或許是時候考慮一下減少副作用,使用函數式的方法來改造我們的生產代碼,將自己解放出來。
雖然純函數式的編程有這樣那樣的好處,但是遺憾的是,在實際的編程開發中,我們總是不可避免的產生副作用。諸如:修改全局變量,修改靜態變量,修改inout
入參,拋出異常,I/O操作,調用其他的具有副作用的函數等等。那么我們需要做的是,將不可避免的副作用限制在可控的范圍之內,如果在程序中,所有的函數都在任意的作用域內隨意穿梭,那么代碼將陷入維護和迭代的黑洞,永世不得翻身。
Stubs and Mock
注:在關于單元測試的文章中我們常常可以聽到
Stub
和Mock
的概念,而對于剛剛開展單元測試的人來說常常會混淆兩者。簡單來說,Stub
指的是當我們需要依賴某些真實的數據接口的時候,我們通過提供偽造的數據來進行測試。而Mock
則是在Stub
的基礎上增加了對所需要依賴接口的校驗,保證該方法被調用。
假設我們需要測試一個網絡層,誠然,我們也可以使用https://httpbin.org/的開放接口進行測試,但是這樣的測試有一些問題:
- 測試返回時間的不確定性,不能夠快速測試
- 測試依賴外部環境,測試數據不穩定
- 難以模擬一些
corener case
和錯誤返回,難以提升測試覆蓋率
基于以上幾點原因,一個比較好的辦法就是Mock數據。在OC的時代,由于OC是動態的語言,所以我們有一個非常強大的庫--OC,我們可以依賴runtime
輕松的fake出想要的數據來進行單元測試。
當然,來到了Swift時代之后,runtime
的方法就行不通了,但是我們依舊可以使用自定義的URLProtocol
來實現Mock,比較不錯的開源項目比如Mockingjay,使用它我們就可以非常簡單的完成網絡層的Mock。
Quick Check
如果你學過Haskell
,那么你大概率聽說過Quick Check
。在上一小節中,我們知道在某些時候我們需要通過Mock的技術來偽造數據,但是我們難道就止步于此了么?
One more thing...
例如,當我們需要測試一個除法的時候,我們編寫了如下的代碼:
func testDivision() {
XCTAssert(1.divide(a: 1) == 1)
}
嗯,這樣的測試用例很簡單,我們輸入了[(1,1)]
作為測試的輸入集,當進行單元測試的時候,我們總是能得到成功的測試結果,但是很明顯,當分母為0
這樣的重要的數據邊界條件的時候程序就會出現錯誤。當然,上述的例子還只是一個極其純粹的單元測試,在真實的軟件環境當中,我們將遇到的問題將更加復雜。
在無窮的測試集中找到最小的最高效的測試集幾乎是單元測試最難的部分。
有限的人力人腦和無限的測試集將是永恒的矛盾,所以人們便想出了類似Quick Check
的這樣的隨機數據生成器。主要的思想就是,通過你給定的數據范圍和類型限定,程序自動為你生成相關的數據來進行測試,當然啦萬能的Github已經有人實現過了--typelift/SwiftCheck。
具體的相關使用并不想浪費篇幅來講,其實更讓我在意的是它的局限。
確實,我們現在可以根據給定的類型或者是范圍來隨機生成測試集,我們可以依靠機器的蠻力來進行這樣暴力的測試,但是它真的帶給了我們有效的測試么?它真的帶給了我們高效的測試么?
不可替代的人力
Quick Check
確實給了我們解決問題的一個新的視野,但是它也有始終無法突破的局限。例如上述的例子,我們確實可以通過Quick Check
隨機快速的生成測試數據集,但是“隨機”與其說是它的優點,不如說是它的劣勢。
“隨機”是機器無奈的選擇,是程序的妥協。即便你的機器再快,在無限的測試集面前依然無限趨近于0,機器無法思考到分母不能為0,類似這樣的策略和思考過程正是人腦所擅長的。
我們總覺得依靠“點點點”的測試人員很“Low”,甚至于我們總是希望這樣的測試人員被淘汰出局。但是我們要知道,“點點點”測試人員的價值并不在于靈活的手指,而在于靈活的思考策略。經驗豐富的測試人員,總能在無限的測試集中找到最有效高效的子集,從而保證絕大多數的情況之下的軟件質量。
當然,如果AI可以解決這樣的策略問題當然最好,但目前來看“人工智能”還是蠢得可怕。
結語
“測試是為發現錯誤而執行程序的過程”。 ---- 《單元測試的藝術》
在資本洪流之下,中國的互聯網公司普遍生活在恐慌之下,唯恐被市場淘他們加班加點,或小步或大步的跑著。面對這產品飄忽不定的需求,技術人是否還能保持一顆匠人之心。
十幾年前,我們在雨后的泥地里玩著泥巴,樂此不疲。母親氣呼呼的過來把我拖走,“還在玩?還不去做作業,玩這個有什么用!”。我戀戀不舍的看著我用泥巴建起的王國。
“這很有用”。
感謝參考
編寫良好的單元測試
iOS Unit Testing and UI Testing Tutorial
Real World Mocking in Swift