1. 程式人生 > >用 Swift 編寫網路層單元測試

用 Swift 編寫網路層單元測試

單元測試主要用來檢測某個工作單元的結果是否符合預期,以此保證該工作單元的邏輯正確。上次寫封裝一個 Swift-Style 的網路模組的時候在結尾提了一下單元測試的重要性,評論中有朋友對網路層的單元測試有一些疑惑。我推薦他去看《單元測試的藝術》(這本書讓我對單元測試有了新的認識),但由於該書是以 C# 為例寫的,可能會對 iOS 開發的朋友造成一定的閱讀障礙,所以我還是決定填一下坑,簡單介紹一下用 Swift 進行網路層單元測試的方法。不過由於 Swift 的函式式特性,像《單元測試的藝術》中那樣單純地用 OOP 思維編寫測試可能會有些麻煩,本文臨近結尾部分寫了一點自己用過的使用“偽裝函式”進行測試的方法,可能大家以前沒見過,我自己也是突然想到的,歡迎提出各種意見。

網路層的單元測試之所以讓人感覺難以下手,原因主要有兩點:

  • 網路是個不穩定的外部依賴。
  • 網路操作一般會涉及非同步過程,而非同步過程難以測試。

要直接測試網路和非同步呼叫,可以使用XCTest提供的expectationWithDescription+waitForExpectationsWithTimeout,舉個例子:

1234567891011 func testFetchDataWithAPI_invalidAPI_failureResult(){let expectation=expectationWithDescription("")let timeout=15asNSTimeIntervalNetworkManager.defaultManager.fetchDataWithAPI(.Invalid,responseKey:""){expectation.fulfill()XCTAssertTrue($0.isFailure
)}waitForExpectationsWithTimeout(timeout,handler:nil)}

測試方法按 test方法名_測試場景_期望結果 的格式命名。首先在非同步回撥外面呼叫expectationWithDescription方法得到一個expectation,這個方法接受一個字串,用來描述本次測試,我傳了個空串,因為我們的測試方法名已經足夠清晰了。然後在回撥中呼叫expectation.fulfill()表明滿足測試條件,接下來就可以進行斷言。最後別忘了在回撥外面加上waitForExpectationsWithTimeout(timeout, handler: nil),如果時間超過timeout回撥還沒有執行,就會測試失敗,hander會在超時後呼叫,可以寫一些清空狀態和還原現場的操作,以免影響之後的測試,譬如task?.cancel()。但是我這邊什麼都沒做,因為優秀的單元測試之間本來就不應該互相有影響。

上面的測試非常簡單吧,但是按《單元測試的藝術》一書中的觀點,這樣的測試已經不能算是單元測試,而是步入整合測試的範疇了:

整合測試是對一個工作單元進行的測試,這個測試對被測試的工作單元沒有完全的控制,並使用該單元的一個或多個真實的依賴物,例如時間、網路、資料庫、執行緒或隨機數產生器等。

上述這個測試非常不穩定,它依賴於真實的網路狀況,我們可能因為網路不佳測試失敗,而不是因為我們的程式碼本身有邏輯錯誤,而且這個測試有可能非常慢,慢到你不願意每次一修改程式碼就去跑一遍測試,這樣的單元測試就有可能形同虛設。

整合測試當然也非常重要,但一般開發人員也就寫寫單元測試。其實 Alamofire 就有采用我上面說的方法進行測試,所以如果你的網路層像我一樣是以 Alamofire 為基礎構建的,那就表示你不太需要再去寫這樣的測試了,你只要保證跟 Alamofire 無關的那些程式碼本身邏輯正確,以及正確呼叫了 Alamofire 即可。

譬如針對我的這個方法:

1234567891011121314151617181920212223242526 /** Fetch raw object - parameter api:              API address - parameter method:           HTTP method, default = POST - parameter parameters:       Request parameters, default = nil - parameter responseKey:      Key of target value, use '.' to get nested objects, e.g. "data.vehicle_list" - parameter jsonArrayHandler: Handle result with raw object - returns: Optional request object which is cancellable. */func fetchDataWithAPI(api:API,method:Alamofire.Method=.POST,parameters:[String:String]?=nil,responseKey:String,networkCompletionHandler:NetworkCompletionHandler)->Cancellable?{guard let url=api.url else{printLog("URL Invalid: \(api.rawValue)")returnnil}returnAlamofire.request(method,url,parameters:parameters).responseJSON{networkCompletionHandler(self.parseResult($0.result,responseKey:responseKey))}}

我一般會去測試它的返回值是否符合預期:

12345678910111213 functestFetchDataWithAPI_invalidURL_returnNil{let task=NetworkManager.defaultManager.fetchDataWithAPI(.InvalidURL,responseKey:""){}XCTAssertNil(task)}functestFetchDataWithAPI_validAPI_returnNotNil{let task=NetworkManager.defaultManager.fetchDataWithAPI(.ValidURL,responseKey:""){}XCTAssertNotNil(task)}

這兩個測試基本可以保證檢查 URL 是否合法的邏輯和呼叫 Alamofire 的邏輯正確。

由於該方法中使用了parseResult方法,當然我也要測試這個方法的正確性:

12345678910111213141516171819202122232425262728293031323334353637383940414243 let testKey="testKey"let jsonDictWithError:[String:AnyObject]=["code":1]let jsonDictWithoutData:[String:AnyObject]=["code":0]let jsonDictWithData:[String:AnyObject]=["testKey":"testValue"]let manager=NetworkManager.defaultManagerlet error=UMAError.errorWithCode(.Unknown)func makeResultForFailureCaseWithError(error:NSError)->Result{returnResult.Failure(error)}func makeResultForSuccessCaseWithValue(value:AnyObject)->Result{returnResult.Success(value)}func testParseResult_failureCase_returnFailureCase(){let result=makeResultForFailureCaseWithError(error)let formattedResult=manager.parseResult(result,responseKey:testKey)XCTAssertTrue(formattedResult.isFailure)}func testParseResult_successCaseWithCode1_returnFailureCaseWithCode1(){let result=makeResultForSuccessCaseWithValue(jsonDictWithError)let formattedResult=manager.parseResult(result,responseKey:testKey)XCTAssertEqual(formattedResult.error!.code,1)}func testParseResult_successCaseWithoutData_returnFailureCaseWithTransformFailed(){let result=makeResultForSuccessCaseWithValue(jsonDictWithoutData)let formattedResult=manager.parseResult(result,responseKey:testKey)XCTAssertEqual(formattedResult.error!.code,ErrorCode.TransformFailed.rawValue)}func testParseResult_successCaseWithData_returnTestValue(){let result=makeResultForSuccessCaseWithValue(jsonDictWithData)let formattedResult=manager.parseResult(result,responseKey:testKey)XCTAssertEqual(String(formattedResult.value!),"testValue")}

這個測試也是測試返回值,測試了幾種可能發生的情況,基本可以保證parseResult方法的正確性。

工作單元可能有三種最終結果:返回值、改變系統狀態和呼叫第三方物件。相應的單元測試一般可以分為三類:基於返回值的測試、基於狀態的測試和互動測試。我上面幾個測試都是在測試返回值,這種測試最簡單直接也最好維護。要測試狀態的改變一般需要先測試初始狀態,然後呼叫改變狀態的方法,再測試改變後的狀態。而互動測試可能就需要用到 fake (偽物件),fake 分為 stub (存根)和 mock (模擬物件)兩種。stub 和 mock 很類似,它們最大的區別是,你會對 mock 進行斷言,但不會對 stub 進行斷言。換句話說,一旦你對一個 fake 進行斷言了,它就是個 mock,否則就是個 stub。

由於 Swift 的反射非常弱雞,似乎並沒有什麼特別好用的 mock 框架,所以一般來說可以用面向協議的思想來減少物件間的耦合,然後手動構建一個 fake 用於測試,當然這需要一些依賴注入技術的配合。又因為 Alamofire 對外暴露的最常用函式request是個全域性函式,而它又會返回一個Request物件,我們要在該物件上呼叫responseJSON方法,這樣一來光用偽物件似乎不足以滿足需求。

Swift 畢竟是一門對 FP 支援度很高的語言,所以工作單元還可能有第四種最終結果——呼叫第三方函式(這個說法好像怪怪的,領會精神啊哈哈)。那相對應的,我們當然可以使用一個 fake function(偽函式,同樣領會精神即可……)來配合測試。依舊以我的 NetworkManager 為例,稍加改造,方便在測試時注入偽函式和偽物件:

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354 typealias NetworkCompletionHandler=Result->Voidtypealias NetworkRequest=(Alamofire.Method,URLStringConvertible,[String:AnyObject]?,Alamofire.ParameterEncoding,[String:String]?)->Responsableprotocol Responsable:Cancellable{func responseJSON(queue queue:dispatch_queue_t?,options:NSJSONReadingOptions,completionHandler:Alamofire.Response->Void)->Self}extension Alamofire.Request:Responsable{}classNetworkManager{// static 屬性自帶 lazy 效果,加上 let 可用作單例staticlet defaultManager=NetworkManager(request:Alamofire.request)let request:NetworkRequestinit(request:NetworkRequest){self.request=request}/**     Fetch raw object     - parameter api:              API address     - parameter method:           HTTP method, default = POST     - parameter parameters:       Request parameters, default = nil     - parameter responseKey:      Key of target value, use '.' to get nested objects, e.g. "data.vehicle_list"     - parameter jsonArrayHandler: Handle result with raw object     - returns: Optional request object which is cancellable.     */func fetchDataWithAPI(api:API,method:Alamofire.Method=.POST,parameters:[String:String]?=nil,responseKey:String,networkCompletionHandler:NetworkCompletionHandler)->Cancellable?{guard let url=api.url else{printLog("URL Invalid: \(api.rawValue)")returnnil}returnrequest(method,url,parameters,.URL,nil).responseJSON(queue:nil,options:.AllowFragments){networkCompletionHandler(self.parseResult($0.result,responseKey:responseKey))}}// ...}

我聲明瞭一個新的型別NetworkRequest,它其實是個函式,簽名跟 Alamofire 中的全域性函式request一致。使用者使用時只需呼叫defaultManager即可,而測試時我們可以手動構建一個符合NetworkRequest簽名的函式通過初始化方法注入到NetworkManager中。我還聲明瞭一個Responsable的協議,然後用extension 顯式宣告 Alamofire 中的Request遵守該協議,這個協議可以讓我們在測試時構建一個代替Request的 fake 物件。

好了,萬事俱備,開始寫測試用例: