如何在單元測試中處理異步回調(diào)函數(shù)

歡迎回來,這一節(jié),我們基于之前實現(xiàn)的MockURLSessionMockURLSessionDataTask來測試WeatherDataManager中和網(wǎng)絡(luò)通信相關(guān)的功能。

該怎么做呢?

為了回答這個問題,我們首先應(yīng)該考慮的問題是:究竟想要測試什么?例如,在上一節(jié)中,我們的目的是:測試resume()方法被調(diào)用。那么,現(xiàn)在呢?我們可以從一個最簡單的場景開始:確保服務(wù)器的返回結(jié)果不為nil

為了測試這個結(jié)果,最簡單的辦法當然就是實際向DarkSky發(fā)送一個請求,然后測試返回值,為此,我們就“跟著感覺”寫下了下面這個測試用例:

func test_weatherDataAt_gets_data() {
    var data: WeatherData? = nil

    WeatherDataManager.shared.weatherDataAt(
        latitude: 52,
        longitude: 100,
        completion: { (response, error) in
        data = response
    })

    XCTAssertNotNil(data)
}

執(zhí)行一下測試就會發(fā)現(xiàn),Xcode會直接告訴我們測試失敗了。這是因為,Xcode的測試用例是單線程執(zhí)行的,它不會等待weatherDataAt的回調(diào)函數(shù)執(zhí)行完。因此,在執(zhí)行XCTAssertNotNil時,網(wǎng)絡(luò)請求還沒有完成,此時data的值還是nil。于是,測試就失敗了。

該怎么辦呢?我們有兩種方法測試異步執(zhí)行的回調(diào)函數(shù)。

使用Xcode expectation

第一種,是使用在Xcode 6時引入的一個功能,叫做Expectation。簡單來說,就是允許我們在一個時間范圍里,給Xcode設(shè)置一個“期望”,如果期望滿足了就表示測試成功,如果超時了,就表示測試失敗。

直接來看代碼。首先,我們用expectation方法,給“期望”添加一個描述:

func test_weatherDataAt_gets_data() {
    let expect = expectation(
        description: "Loading data from \(API.authenticatedURL)")
    /// ...
}

其次,在我們之前編寫的代碼里,在條件滿足的地方,調(diào)用fulfill()方法通知Xcode:

func test_weatherDataAt_gets_data() {
    let expect = expectation(description: "Loading data from \(API.authenticatedURL)")

    var data: WeatherData?
    WeatherDataManager.shared.weatherDataAt(
        latitude: 52,
        longitude: 100,
        completion: { (response, error) in
        data = response
        expect.fulfill() // - Notify Xcode here
    })

    /// ...
}

最后,給“期望值”設(shè)置一個超時時間,并進行測試:

func test_weatherDataAt_gets_data() {
    /// ...

    waitForExpectations(timeout: 5, handler: nil)
    XCTAssertNotNil(data)
}

這樣,只要在5秒之內(nèi)得到了返回值,測試就會成功,否則,就會失敗。重新執(zhí)行一下測試,如果一切順利,我們就可以看到測試通過的結(jié)果了。

但是,通過Xcode expectation也只能部分解決我們的問題,對于測試異步執(zhí)行的代碼,這種方式仍有一些問題:

  • 首先,測試結(jié)果仍舊取決于網(wǎng)絡(luò)狀況,因此我們很難保證多次測試結(jié)果的一致性;
  • 其次,當我們要測試一個REST服務(wù)的時候,如果每個URL的測試都基于實際網(wǎng)絡(luò)訪問和超時的機制,將會顯著增加測試執(zhí)行的時間;

為此,我們需要需要第二種方法:把從網(wǎng)絡(luò)獲取到數(shù)據(jù)的部分mock出來,并且,讓異步執(zhí)行的代碼同步執(zhí)行,這樣才可以精確管理測試用例的執(zhí)行過程。

借助于上一節(jié)實現(xiàn)的MockURLSessionMockURLSessionDataTask,我們可以很容易完成這兩個工作。

通過mock串行化異步執(zhí)行的代碼

為了控制從服務(wù)器得到的是正常的響應(yīng)或是發(fā)生了錯誤,我們給MockURLSession添加三個屬性:

class MockURLSession: URLSessionProtocol {
    var responseData: Data?
    var responseHeader: HTTPURLResponse?
    var responseError: Error?

    /// ...
}

這樣,我們就可以直接通過設(shè)置這三個屬性的值來模擬從服務(wù)器得到的返回結(jié)果了。接下來,我們還要調(diào)整MockURLSession.dataTask的實現(xiàn):

class MockURLSession: URLSessionProtocol {
    /// ...

    func dataTask(
        with request: URLRequest,
        completionHandler: @escaping DataTaskHandler)
        -> URLSessionDataTaskProtocol {
        completionHandler(responseData, responseHeader, responseError)
        return sessionDataTask
    }
}

由于不用通過網(wǎng)絡(luò)請求數(shù)據(jù)了,在這個“仿制”的版本里,我們直接調(diào)用dataTask的回調(diào)函數(shù)就好了。這樣,就把一個異步回調(diào)方法,在測試的過程中變成了一個同步方法。

重新審視weatherDataAt的實現(xiàn)

接下來,在開始編寫測試用例之前,我們再來看一眼weatherDataAt的代碼。它是一個testable的方法么?

func weatherDataAt(
    latitude: Double,
    longitude: Double,
    completion: @escaping CompletionHandler) {
    /// ...

    self.urlSession.dataTask(
        with: request,
        completionHandler: {
        (data, response, error) in
        DispatchQueue.main.async {
            self.didFinishGettingWeatherData(
                data: data,
                response: response,
                error: error,
                completion: completion)
        }
    }).resume()

在當初我們給dataTask傳遞closure參數(shù)的時候說過,這部分代碼很可能會和更新UI相關(guān),因此,直接把它放在了主線程隊列中執(zhí)行。這看似沒什么不合理,但當我們引入了單元測試之后,就有了新的發(fā)現(xiàn):

  • 首先,有了剛才的經(jīng)歷我們就會知道,這樣并不利于測試。盡管我們讓dataTask的回調(diào)函數(shù)本身變成了同步執(zhí)行,但在這個closure內(nèi)的代碼卻是異步執(zhí)行的,因此我們?nèi)耘f無法可靠地獲取調(diào)用weatherDataAt之后的結(jié)果;
  • 其次,在面向?qū)ο蟮脑O(shè)計里,你也可能聽說過這樣的說法:盡可能把在設(shè)計上的決策推后到你真正需要它們的時候。因為一旦決定了,它就會成為制約你后面所有設(shè)計的一個限制。那么,回過頭來想這個問題:我們一定會在這更新UI么?當代碼日益復(fù)雜之后,我們?nèi)绾斡浀靡呀?jīng)把closure參數(shù)放在了主線程里呢?似乎我們也都沒有特別有信心的答案;

因此,基于上面兩點考慮,我們不應(yīng)該限制dataTask clousure的執(zhí)行環(huán)境:

self.urlSession.dataTask(
    with: request,
    completionHandler: {
    (data, response, error) in
    self.didFinishGettingWeatherData(
        data: data,
        response: response,
        error: error,
        completion: completion)
    }).resume()

沒錯,直接調(diào)用它就好了,如果要更新UI,我們應(yīng)該明確在closure里指出讓代碼在主線程中執(zhí)行。這樣weatherDataAt的實現(xiàn),就可以在測試環(huán)境里,用同步的方式執(zhí)行了。

設(shè)計測試用例

一切都準備就緒之后,我們來設(shè)計weatherDataAt方法的測試用例。

測試可以正確的處理請求錯誤

第一個要測試的內(nèi)容,是可以處理錯誤的請求。根據(jù)我們自己的實現(xiàn),這種情況下,應(yīng)該可以得到DataManagerError.failedRequest。在WeatherDataManagerTest里,添加下面的代碼:

 func test_weatherDataAt_handle_invalid_request() {
    let session = MockURLSession()
    session.responseError = NSError(
        domain: "Invalid Request",
        code: 100,
        userInfo: nil)

    let manager = WeatherDataManager(
        baseURL: URL(string: "https://darksky.net")!,
        urlSession: session)

    var error: DataManagerError? = nil
    manager.weatherDataAt(
        latitude: 52,
        longitude: 100,
        completion: {
        (_, e) in
        error = e
    })

    XCTAssertEqual(error, DataManagerError.failedRequest)
}

測試可以正確處理服務(wù)器返回的狀態(tài)碼

第二個要測試的內(nèi)容,是可以檢測到服務(wù)器返回的非200 HTTP狀態(tài)碼,對這種情況,我們也會得到``DataManagerError.failedRequest`:

func test_weatherDataAt_handle_statuscode_not_equal_to_200() {
    let url = URL(string: "https://darksky.net")!
    let session = MockURLSession()
    session.responseHeader = HTTPURLResponse(
        url: url, statusCode: 400,
        httpVersion: nil,
        headerFields: nil)

    let data = "{}".data(using: .utf8)!
    session.responseData = data

    var error: DataManagerError? = nil
    let manager = WeatherDataManager(
        baseURL: url, urlSession: session)

    manager.weatherDataAt(
        latitude: 52,
        longitude: 100,
        completion: {
        (_, e) in
        error = e
    })

    XCTAssertEqual(error, DataManagerError.failedRequest)
}

測試服務(wù)器返回的內(nèi)容不正確

下一個要測試的內(nèi)容,是服務(wù)器返回HTTP 200的時候,附帶的數(shù)據(jù)不完整的情況。這次,我們故意創(chuàng)造一個非法的JSON字符串形式:{。并期望得到DataManagerError.failedRequest

func test_weatherDataAt_handle_invalid_response() {
    let url = URL(string: "https://darksky.net")!
    let session = MockURLSession()
    session.responseHeader = HTTPURLResponse(
        url: url,
        statusCode: 200,
        httpVersion: nil,
        headerFields: nil)

    /// Make a invalid JSON response here
    let data = "{".data(using: .utf8)!
    session.responseData = data

    var error: DataManagerError? = nil
    let manager = WeatherDataManager(
        baseURL: url, urlSession: session)

    manager.weatherDataAt(
        latitude: 52,
        longitude: 100,
        completion: {
        (_, e) in
        error = e
    })

    XCTAssertEqual(error, DataManagerError.invalidResponse)
}

測試可以正確解碼服務(wù)器返回值

最后,應(yīng)該測試合法的情況了。我們測試服務(wù)器的返回值可以自動解碼成model對象:

func test_weatherDataAt_handle_response_decode() {
    let url = URL(string: "https://darksky.net")!
    let session = MockURLSession()
    session.responseHeader = HTTPURLResponse(
        url: url,
        statusCode: 200,
        httpVersion: nil,
        headerFields: nil)

    let data = """
    {
        "longitude" : 100,
        "latitude" : 52,
        "currently" : {
            "temperature" : 23,
            "humidity" : 0.91,
            "icon" : "snow",
            "time" : 1507180335,
            "summary" : "Light Snow"
        }
    }
    """.data(using: .utf8)!
    session.responseData = data

    var decoded: WeatherData? = nil
    let manager = WeatherDataManager(
        baseURL: url, urlSession: session)

    manager.weatherDataAt(
        latitude: 52,
        longitude: 100,
        completion: {
            (d, _) in
            decoded = d
    })

    let expected = WeatherData(
        latitude: 52,
        longitude: 100,
        currently: WeatherData.CurrentWeather(
            time: Date(timeIntervalSince1970: 1507180335),
            summary: "Light Snow",
            icon: "snow",
            temperature: 23,
            humidity: 0.91))

    XCTAssertEqual(decoded, expected)
}

雖然看著有點長,但是邏輯很簡單,我們只是比較手工創(chuàng)建的WeatherData對象和解碼出來的結(jié)果是否相同罷了。但為了上面的代碼可以通過測試,我們還得做一些修改。

首先,為了讓WeatherData對象支持比較,我們得讓它遵從protocol Equatable。在WeatherData.swift中,添加下面的代碼:

extension WeatherData.CurrentWeather: Equatable {
    static func ==(
        lhs: WeatherData.CurrentWeather,
        rhs: WeatherData.CurrentWeather) -> Bool {
        return lhs.time == rhs.time &&
            lhs.summary == rhs.summary &&
            lhs.icon == rhs.icon &&
            lhs.temperature == rhs.temperature &&
            lhs.humidity == rhs.humidity
    }
}

extension WeatherData: Equatable {
    static func ==(lhs: WeatherData,
        rhs: WeatherData) -> Bool {
        return lhs.latitude == rhs.latitude &&
            lhs.longitude == rhs.longitude &&
            lhs.currently == rhs.currently
    }
}

都是直接比較屬性相等的代碼,很簡單。

其次,由于DarkSky返回的是UNIX時間戳,我們要在解碼的時候,設(shè)置一下Date對象的解碼方式。把didFinishGettingWeatherData中解碼的部分改成下面這樣:

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let weatherData = try decoder.decode(
    WeatherData.self, from: data)

完成后,test_weatherData_handle_response_decode()就應(yīng)該可以通過測試了。

Refactor the test case

最后,我們整理一下所有的測試用例,把其中公共的部分定義成屬性,把這些屬性的設(shè)置,統(tǒng)一放到setUp方法里,Xcode會在執(zhí)行每一個測試方法前執(zhí)行這些代碼

這也是implicitly unwrapped optional的一個典型的應(yīng)用場景。

class WeatherDataManagerTest: XCTestCase {
    let url = URL(string: "https://darksky.net")!
    var session: MockURLSession!
    var manager: WeatherDataManager!

    override func setUp() {
        super.setUp()
        // Put setup code here. This method is called before the invocation of each test method in the class.
        self.session = MockURLSession()
        self.manager = WeatherDataManager(baseURL: url, urlSession: session)
    }

    /// ...
}

下面,是基于這些調(diào)整之后的測試用例完整代碼:

func test_weatherDataAt_starts_the_session() {
    let dataTask = MockURLSessionDataTask()
    session.sessionDataTask = dataTask

    manager.weatherDataAt(
        latitude: 52,
        longitude: 100,
        completion: { _, _ in  })

    XCTAssert(session.sessionDataTask.isResumeCalled)
}

func test_weatherDataAt_handle_invalid_request() {
    session.responseError = NSError(
        domain: "Invalid Request", code: 100, userInfo: nil)
    var error: DataManagerError? = nil

    manager.weatherDataAt(latitude: 52, longitude: 100, completion: {
        (_, e) in
        error = e
    })

    XCTAssertEqual(error, DataManagerError.failedRequest)
}

func test_weatherDataAt_handle_statuscode_not_equal_to_200() {
    session.responseHeader = HTTPURLResponse(
        url: url, statusCode: 400, httpVersion: nil, headerFields: nil)

    let data = "{}".data(using: .utf8)!
    session.responseData = data

    var error: DataManagerError? = nil

    manager.weatherDataAt(latitude: 52, longitude: 100, completion: {
        (_, e) in
        error = e
    })

    XCTAssertEqual(error, DataManagerError.failedRequest)
}

func test_weatherDataAt_handle_invalid_response() {
    session.responseHeader = HTTPURLResponse(
        url: url,
        statusCode: 200,
        httpVersion: nil,
        headerFields: nil)

    let data = "{".data(using: .utf8)!
    session.responseData = data

    var error: DataManagerError? = nil

    manager.weatherDataAt(
        latitude: 52,
        longitude: 100,
        completion: {
        (_, e) in
        error = e
    })

    XCTAssertEqual(error, DataManagerError.invalidResponse)
}

func test_weatherDataAt_handle_response_decode() {
    session.responseHeader = HTTPURLResponse(
        url: url,
        statusCode: 200,
        httpVersion: nil,
        headerFields: nil)

    let data = """
    {
        "longitude" : 100,
        "latitude" : 52,
        "currently" : {
            "temperature" : 23,
            "humidity" : 0.91,
            "icon" : "snow",
            "time" : 1507180335,
            "summary" : "Light Snow"
        }
    }
    """.data(using: .utf8)!
    session.responseData = data

    var decoded: WeatherData? = nil

    manager.weatherDataAt(
        latitude: 52,
        longitude: 100,
        completion: {
            (d, _) in
            decoded = d
    })

    let expected = WeatherData(
        latitude: 52,
        longitude: 100,
        currently: WeatherData.CurrentWeather(
            time: Date(timeIntervalSince1970: 1507180335),
            summary: "Light Snow",
            icon: "snow",
            temperature: 23,
            humidity: 0.91))

    XCTAssertEqual(decoded, expected)
}

至此,我們就可以確定model的解碼以及manager都可以正常工作了。稍后,當我們編寫界面的時候,還會繼續(xù)討論UI測試的方法。通過這個過程,我們可以看到,單元測試,不僅可以有助于更早發(fā)現(xiàn)錯誤,也可以在某種程度上改進代碼的質(zhì)量?,F(xiàn)在,把測試的話題先放放。在下一節(jié),我們來定義Sky的view controllers,并把它們和models關(guān)聯(lián)起來。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 原文地址:https://www.raywenderlich.com/150073/ios-unit-testin...
    默默熊閱讀 5,139評論 1 67
  • 前言 單元測試和UI測試大致步驟網(wǎng)上很多文章都有,如果會的可以忽略,關(guān)鍵是錯誤總結(jié),網(wǎng)上很少有文章提及到,感興趣的...
    _YGL_閱讀 5,422評論 20 23
  • 單元測試不是一個小工程,需要多用些時間才能做好,不要希望通過這個文章就能掌握單元測試,這只是一個入門,需要自己動手...
    勇不言棄92閱讀 8,114評論 9 60
  • 一、簡介 單元測試(Unit Testing) 是一種軟件測試方法,主要用于確定各個獨立的軟件模塊是否正確。在這個...
    陽光下的灰塵閱讀 1,548評論 0 2
  • 前言: 對于單元測試來說,我想大部分同行,在項目中,很少會用到,也有一大部分,知道單元測試這個東西,但是確切的說沒...
    Andy_FML閱讀 1,918評論 0 1

友情鏈接更多精彩內(nèi)容