單元測試主要用來檢測某個(gè)工作單元的結(jié)果是否符合預(yù)期,以此保證該工作單元的邏輯正確猪杭。上次寫封裝一個(gè) Swift-Style 的網(wǎng)絡(luò)模塊的時(shí)候在結(jié)尾提了一下單元測試的重要性,評論中有朋友對網(wǎng)絡(luò)層的單元測試有一些疑惑稿壁。我推薦他去看《單元測試的藝術(shù)》(這本書讓我對單元測試有了新的認(rèn)識)佑女,但由于該書是以 C# 為例寫的,可能會對 iOS 開發(fā)的朋友造成一定的閱讀障礙阻肿,所以我還是決定填一下坑瓦戚,簡單介紹一下用 Swift 進(jìn)行網(wǎng)絡(luò)層單元測試的方法。不過由于 Swift 的函數(shù)式特性丛塌,像《單元測試的藝術(shù)》中那樣單純地用 OOP 思維編寫測試可能會有些麻煩较解,本文臨近結(jié)尾部分寫了一點(diǎn)自己用過的使用“偽裝函數(shù)”進(jìn)行測試的方法,可能大家以前沒見過赴邻,我自己也是突然想到的印衔,歡迎提出各種意見。
網(wǎng)絡(luò)層的單元測試之所以讓人感覺難以下手姥敛,原因主要有兩點(diǎn):
- 網(wǎng)絡(luò)是個(gè)不穩(wěn)定的外部依賴奸焙。
- 網(wǎng)絡(luò)操作一般會涉及異步過程,而異步過程難以測試彤敛。
要直接測試網(wǎng)絡(luò)和異步調(diào)用忿偷,可以使用XCTest
提供的expectationWithDescription
+waitForExpectationsWithTimeout
,舉個(gè)例子:
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方法名_測試場景_期望結(jié)果 的格式命名臊泌。首先在異步回調(diào)外面調(diào)用expectationWithDescription
方法得到一個(gè)expectation
鲤桥,這個(gè)方法接受一個(gè)字符串,用來描述本次測試渠概,我傳了個(gè)空串茶凳,因?yàn)槲覀兊臏y試方法名已經(jīng)足夠清晰了。然后在回調(diào)中調(diào)用expectation.fulfill()
表明滿足測試條件播揪,接下來就可以進(jìn)行斷言贮喧。最后別忘了在回調(diào)外面加上waitForExpectationsWithTimeout(timeout, handler: nil)
,如果時(shí)間超過timeout
回調(diào)還沒有執(zhí)行猪狈,就會測試失敗箱沦,hander
會在超時(shí)后調(diào)用,可以寫一些清空狀態(tài)和還原現(xiàn)場的操作雇庙,以免影響之后的測試谓形,譬如task?.cancel()
灶伊。但是我這邊什么都沒做,因?yàn)閮?yōu)秀的單元測試之間本來就不應(yīng)該互相有影響寒跳。
上面的測試非常簡單吧聘萨,但是按《單元測試的藝術(shù)》一書中的觀點(diǎn),這樣的測試已經(jīng)不能算是單元測試童太,而是步入集成測試的范疇了:
集成測試是對一個(gè)工作單元進(jìn)行的測試米辐,這個(gè)測試對被測試的工作單元沒有完全的控制,并使用該單元的一個(gè)或多個(gè)真實(shí)的依賴物书释,例如時(shí)間翘贮、網(wǎng)絡(luò)、數(shù)據(jù)庫爆惧、線程或隨機(jī)數(shù)產(chǎn)生器等择膝。
上述這個(gè)測試非常不穩(wěn)定,它依賴于真實(shí)的網(wǎng)絡(luò)狀況检激,我們可能因?yàn)榫W(wǎng)絡(luò)不佳測試失敗肴捉,而不是因?yàn)槲覀兊拇a本身有邏輯錯(cuò)誤,而且這個(gè)測試有可能非常慢叔收,慢到你不愿意每次一修改代碼就去跑一遍測試齿穗,這樣的單元測試就有可能形同虛設(shè)。
集成測試當(dāng)然也非常重要饺律,但一般開發(fā)人員也就寫寫單元測試窃页。其實(shí) Alamofire 就有采用我上面說的方法進(jìn)行測試,所以如果你的網(wǎng)絡(luò)層像我一樣是以 Alamofire 為基礎(chǔ)構(gòu)建的复濒,那就表示你不太需要再去寫這樣的測試了脖卖,你只要保證跟 Alamofire 無關(guān)的那些代碼本身邏輯正確,以及正確調(diào)用了 Alamofire 即可巧颈。
譬如針對我的這個(gè)方法:
/**
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))
}
}
我一般會去測試它的返回值是否符合預(yù)期:
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)
}
這兩個(gè)測試基本可以保證檢查 URL 是否合法的邏輯和調(diào)用 Alamofire 的邏輯正確畦木。
由于該方法中使用了parseResult
方法,當(dāng)然我也要測試這個(gè)方法的正確性:
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<AnyObject, NSError> {
return Result<AnyObject, NSError>.Failure(error)
}
func makeResultForSuccessCaseWithValue(value: AnyObject) -> Result<AnyObject, NSError> {
return Result<AnyObject, NSError>.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")
}
這個(gè)測試也是測試返回值砸泛,測試了幾種可能發(fā)生的情況十籍,基本可以保證parseResult
方法的正確性。
工作單元可能有三種最終結(jié)果:返回值唇礁、改變系統(tǒng)狀態(tài)和調(diào)用第三方對象勾栗。相應(yīng)的單元測試一般可以分為三類:基于返回值的測試、基于狀態(tài)的測試和交互測試盏筐。我上面幾個(gè)測試都是在測試返回值围俘,這種測試最簡單直接也最好維護(hù)。要測試狀態(tài)的改變一般需要先測試初始狀態(tài),然后調(diào)用改變狀態(tài)的方法界牡,再測試改變后的狀態(tài)簿寂。而交互測試可能就需要用到 fake (偽對象),fake 分為 stub (存根)和 mock (模擬對象)兩種欢揖。stub 和 mock 很類似,它們最大的區(qū)別是奋蔚,你會對 mock 進(jìn)行斷言她混,但不會對 stub 進(jìn)行斷言。換句話說泊碑,一旦你對一個(gè) fake 進(jìn)行斷言了坤按,它就是個(gè) mock,否則就是個(gè) stub馒过。
由于 Swift 的反射非常弱雞臭脓,似乎并沒有什么特別好用的 mock 框架,所以一般來說可以用面向協(xié)議的思想來減少對象間的耦合腹忽,然后手動構(gòu)建一個(gè) fake 用于測試来累,當(dāng)然這需要一些依賴注入技術(shù)的配合。又因?yàn)?Alamofire 對外暴露的最常用函數(shù)request
是個(gè)全局函數(shù)窘奏,而它又會返回一個(gè)Request
對象嘹锁,我們要在該對象上調(diào)用responseJSON
方法,這樣一來光用偽對象似乎不足以滿足需求着裹。
Swift 畢竟是一門對 FP 支持度很高的語言领猾,所以工作單元還可能有第四種最終結(jié)果——調(diào)用第三方函數(shù)(這個(gè)說法好像怪怪的,領(lǐng)會精神啊哈哈)骇扇。那相對應(yīng)的摔竿,我們當(dāng)然可以使用一個(gè) fake function(偽函數(shù),同樣領(lǐng)會精神即可……)來配合測試少孝。依舊以我的 NetworkManager 為例继低,稍加改造,方便在測試時(shí)注入偽函數(shù)和偽對象:
typealias NetworkCompletionHandler = Result<AnyObject, NSError> -> 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<AnyObject, NSError> -> 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))
}
}
// ...
}
我聲明了一個(gè)新的類型NetworkRequest
郁季,它其實(shí)是個(gè)函數(shù),簽名跟 Alamofire 中的全局函數(shù)request
一致钱磅。用戶使用時(shí)只需調(diào)用defaultManager
即可梦裂,而測試時(shí)我們可以手動構(gòu)建一個(gè)符合NetworkRequest
簽名的函數(shù)通過初始化方法注入到NetworkManager
中。我還聲明了一個(gè)Responsable
的協(xié)議盖淡,然后用extension
顯式聲明 Alamofire 中的Request
遵守該協(xié)議年柠,這個(gè)協(xié)議可以讓我們在測試時(shí)構(gòu)建一個(gè)代替Request
的 fake 對象。
好了,萬事俱備冗恨,開始寫測試用例:
func testFetchDataWithAPI_requestWithMock_resultWithErrorCode666() {
struct MockResponse: Responsable {
func responseJSON(queue queue: dispatch_queue_t?,
options: NSJSONReadingOptions,
completionHandler: Alamofire.Response<AnyObject, NSError> -> Void)
-> MockResponse {
let unknowError = UMAError.errorWithCode(666, description: "error for test")
let result = Result<AnyObject, NSError>.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 風(fēng)格的單元測試答憔,不知道別人有沒有用過。