單元測試主要用來檢測某個工作單元的結果是否符合預期,以此保證該工作單元的邏輯正確。上次寫封裝一個 Swift-Style 的網路模組的時候在結尾提了一下單元測試的重要性,評論中有朋友對網路層的單元測試有一些疑惑。我推薦他去看《單元測試的藝術》(這本書讓我對單元測試有了新的認識),但由於該書是以 C# 為例寫的,可能會對 iOS 開發的朋友造成一定的閱讀障礙,所以我還是決定填一下坑,簡單介紹一下用 Swift 進行網路層單元測試的方法。不過由於 Swift 的函式式特性,像《單元測試的藝術》中那樣單純地用 OOP 思維編寫測試可能會有些麻煩,本文臨近結尾部分寫了一點自己用過的使用“偽裝函式”進行測試的方法,可能大家以前沒見過,我自己也是突然想到的,歡迎提出各種意見。
網路層的單元測試之所以讓人感覺難以下手,原因主要有兩點:
- 網路是個不穩定的外部依賴。
- 網路操作一般會涉及非同步過程,而非同步過程難以測試。
要直接測試網路和非同步呼叫,可以使用XCTest
提供的expectationWithDescription
+waitForExpectationsWithTimeout
,舉個例子:
1 2 3 4 5 6 7 8 9 10 11 |
func testFetchDataWithAPI_invalidAPI_failureResult() { let expectation = expectationWithDescription("") let timeout = 15 as NSTimeInterval NetworkManager .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 即可。
譬如針對我的這個方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
/** 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)") return nil } return Alamofire.request(method, url, parameters: parameters).responseJSON { networkCompletionHandler(self.parseResult($0.result, responseKey: responseKey)) } } |
我一般會去測試它的返回值是否符合預期:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
func testFetchDataWithAPI_invalidURL_returnNil { let task = NetworkManager .defaultManager .fetchDataWithAPI(.InvalidURL, responseKey: "") {} XCTAssertNil(task) } func testFetchDataWithAPI_validAPI_returnNotNil { let task = NetworkManager .defaultManager .fetchDataWithAPI(.ValidURL, responseKey: "") {} XCTAssertNotNil(task) } |
這兩個測試基本可以保證檢查 URL 是否合法的邏輯和呼叫 Alamofire 的邏輯正確。
由於該方法中使用了parseResult
方法,當然我也要測試這個方法的正確性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
let testKey = "testKey" let jsonDictWithError: [String: AnyObject] = ["code": 1] let jsonDictWithoutData: [String: AnyObject] = ["code": 0] let jsonDictWithData: [String: AnyObject] = ["testKey": "testValue"] let manager = NetworkManager.defaultManager let error = UMAError.errorWithCode(.Unknown) func makeResultForFailureCaseWithError(error: NSError) -> Result { return Result.Failure(error) } func makeResultForSuccessCaseWithValue(value: AnyObject) -> Result { return Result.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 為例,稍加改造,方便在測試時注入偽函式和偽物件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
typealias NetworkCompletionHandler = Result -> Void typealias NetworkRequest = (Alamofire.Method, URLStringConvertible, [String : AnyObject]?, Alamofire.ParameterEncoding, [String : String]?) -> Responsable protocol Responsable: Cancellable { func responseJSON(queue queue: dispatch_queue_t?, options: NSJSONReadingOptions, completionHandler: Alamofire.Response -> Void) -> Self } extension Alamofire.Request: Responsable {} class NetworkManager { // static 屬性自帶 lazy 效果,加上 let 可用作單例 static let defaultManager = NetworkManager(request: Alamofire.request) let request: NetworkRequest init(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)") return nil } return request(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 物件。
好了,萬事俱備,開始寫測試用例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
func testFetchDataWithAPI_requestWithMock_resultWithErrorCode666() { struct MockResponse: Responsable { func responseJSON(queue queue: dispatch_queue_t?, options: NSJSONReadingOptions, completionHandler: Alamofire.Response -> Void) -> MockResponse { let unknowError = UMAError.errorWithCode(666, description: "error for test") let result = Result.Failure(unknowError) let response = Alamofire.Response(request: nil, response: nil, data: nil, result: result) completionHandler(response) return self } func cancel() {} } let request: NetworkRequest = {_, _, _, _, _ in return MockResponse() } let testableManager = NetworkManager(request: request) testableManager.fetchDataWithAPI(.PostCategory, responseKey: "data") { XCTAssertEqual($0.error!.code, 666) } } |
我覺得這是非常具有 Swift 風格的單元測試,不知道別人有沒有用過。