用 Swift 編寫網(wǎng)絡(luò)層單元測試

單元測試主要用來檢測某個(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)格的單元測試答憔,不知道別人有沒有用過。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末掀抹,一起剝皮案震驚了整個(gè)濱河市虐拓,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌傲武,老刑警劉巖蓉驹,帶你破解...
    沈念sama閱讀 216,372評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異揪利,居然都是意外死亡态兴,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評論 3 392
  • 文/潘曉璐 我一進(jìn)店門疟位,熙熙樓的掌柜王于貴愁眉苦臉地迎上來瞻润,“玉大人,你說我怎么就攤上這事甜刻∩茏玻” “怎么了?”我有些...
    開封第一講書人閱讀 162,415評論 0 353
  • 文/不壞的土叔 我叫張陵得院,是天一觀的道長楚午。 經(jīng)常有香客問我,道長尿招,這世上最難降的妖魔是什么矾柜? 我笑而不...
    開封第一講書人閱讀 58,157評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮就谜,結(jié)果婚禮上怪蔑,老公的妹妹穿的比我還像新娘。我一直安慰自己丧荐,他們只是感情好缆瓣,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,171評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著虹统,像睡著了一般弓坞。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上车荔,一...
    開封第一講書人閱讀 51,125評論 1 297
  • 那天渡冻,我揣著相機(jī)與錄音,去河邊找鬼忧便。 笑死族吻,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播超歌,決...
    沈念sama閱讀 40,028評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼砍艾,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了巍举?” 一聲冷哼從身側(cè)響起脆荷,我...
    開封第一講書人閱讀 38,887評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎懊悯,沒想到半個(gè)月后蜓谋,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,310評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡定枷,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,533評論 2 332
  • 正文 我和宋清朗相戀三年孤澎,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了届氢。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片欠窒。...
    茶點(diǎn)故事閱讀 39,690評論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖退子,靈堂內(nèi)的尸體忽然破棺而出岖妄,到底是詐尸還是另有隱情,我是刑警寧澤寂祥,帶...
    沈念sama閱讀 35,411評論 5 343
  • 正文 年R本政府宣布荐虐,位于F島的核電站,受9級特大地震影響丸凭,放射性物質(zhì)發(fā)生泄漏福扬。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,004評論 3 325
  • 文/蒙蒙 一惜犀、第九天 我趴在偏房一處隱蔽的房頂上張望铛碑。 院中可真熱鬧,春花似錦虽界、人聲如沸汽烦。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽撇吞。三九已至,卻和暖如春礁叔,著一層夾襖步出監(jiān)牢的瞬間牍颈,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評論 1 268
  • 我被黑心中介騙來泰國打工琅关, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留颂砸,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 47,693評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像人乓,于是被迫代替她去往敵國和親勤篮。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,577評論 2 353

推薦閱讀更多精彩內(nèi)容