如何在单元测试中处理异步回调函数

随笔7个月前发布 史先生
58 0 0

欢迎回来,这一节,我们基于之前实现的MockURLSessionMockURLSessionDataTask来测试WeatherDataManager中和网络通信相关的功能。

该怎么做呢?

为了回答这个问题,我们首先应该考虑的问题是:究竟想要测试什么?例如,在上一节中,我们的目的是:测试resume()方法被调用。那么,现在呢?我们可以从一个最简单的场景开始:确保服务器的返回结果不为nil

为了测试这个结果,最简单的办法当然就是实际向DarkSky发送一个请求,然后测试返回值,为此,我们就“跟着感觉”写下了下面这个测试用例:

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

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

    XCTAssertNotNil(data)
}

执行一下测试就会发现,Xcode会直接告诉我们测试失败了。这是因为,Xcode的测试用例是单线程执行的,它不会等待weatherDataAt的回调函数执行完。因此,在执行XCTAssertNotNil时,网络请求还没有完成,此时data的值还是nil。于是,测试就失败了。

该怎么办呢?我们有两种方法测试异步执行的回调函数。

使用Xcode expectation

第一种,是使用在Xcode 6时引入的一个功能,叫做Expectation。简单来说,就是允许我们在一个时间范围里,给Xcode设置一个“期望”,如果期望满足了就表示测试成功,如果超时了,就表示测试失败。

直接来看代码。首先,我们用expectation方法,给“期望”添加一个描述:

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

其次,在我们之前编写的代码里,在条件满足的地方,调用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
    })

    /// ...
}

最后,给“期望值”设置一个超时时间,并进行测试:

func test_weatherDataAt_gets_data() {
    /// ...

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

这样,只要在5秒之内得到了返回值,测试就会成功,否则,就会失败。重新执行一下测试,如果一切顺利,我们就可以看到测试通过的结果了。

但是,通过Xcode expectation也只能部分解决我们的问题,对于测试异步执行的代码,这种方式仍有一些问题:

  • 首先,测试结果仍旧取决于网络状况,因此我们很难保证多次测试结果的一致性;
  • 其次,当我们要测试一个REST服务的时候,如果每个URL的测试都基于实际网络访问和超时的机制,将会显著增加测试执行的时间;

为此,我们需要需要第二种方法:把从网络获取到数据的部分mock出来,并且,让异步执行的代码同步执行,这样才可以精确管理测试用例的执行过程。

借助于上一节实现的MockURLSessionMockURLSessionDataTask,我们可以很容易完成这两个工作。

通过mock串行化异步执行的代码

为了控制从服务器得到的是正常的响应或是发生了错误,我们给MockURLSession添加三个属性:

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

    /// ...
}

这样,我们就可以直接通过设置这三个属性的值来模拟从服务器得到的返回结果了。接下来,我们还要调整MockURLSession.dataTask的实现:

class MockURLSession: URLSessionProtocol {
    /// ...

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

由于不用通过网络请求数据了,在这个“仿制”的版本里,我们直接调用dataTask的回调函数就好了。这样,就把一个异步回调方法,在测试的过程中变成了一个同步方法。

重新审视weatherDataAt的实现

接下来,在开始编写测试用例之前,我们再来看一眼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参数的时候说过,这部分代码很可能会和更新UI相关,因此,直接把它放在了主线程队列中执行。这看似没什么不合理,但当我们引入了单元测试之后,就有了新的发现:

  • 首先,有了刚才的经历我们就会知道,这样并不利于测试。尽管我们让dataTask的回调函数本身变成了同步执行,但在这个closure內的代码却是异步执行的,因此我们仍旧无法可靠地获取调用weatherDataAt之后的结果;
  • 其次,在面向对象的设计里,你也可能听说过这样的说法:尽可能把在设计上的决策推后到你真正需要它们的时候。因为一旦决定了,它就会成为制约你后面所有设计的一个限制。那么,回过头来想这个问题:我们一定会在这更新UI么?当代码日益复杂之后,我们如何记得已经把closure参数放在了主线程里呢?似乎我们也都没有特别有信心的答案;

因此,基于上面两点考虑,我们不应该限制dataTask clousure的执行环境:

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

没错,直接调用它就好了,如果要更新UI,我们应该明确在closure里指出让代码在主线程中执行。这样weatherDataAt的实现,就可以在测试环境里,用同步的方式执行了。

设计测试用例

一切都准备就绪之后,我们来设计weatherDataAt方法的测试用例。

测试可以正确的处理请求错误

第一个要测试的内容,是可以处理错误的请求。根据我们自己的实现,这种情况下,应该可以得到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)
}

测试可以正确处理服务器返回的状态码

第二个要测试的内容,是可以检测到服务器返回的非200 HTTP状态码,对这种情况,我们也会得到“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)
}

测试服务器返回的内容不正确

下一个要测试的内容,是服务器返回HTTP 200的时候,附带的数据不完整的情况。这次,我们故意创造一个非法的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)
}

测试可以正确解码服务器返回值

最后,应该测试合法的情况了。我们测试服务器的返回值可以自动解码成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)
}

虽然看着有点长,但是逻辑很简单,我们只是比较手工创建的WeatherData对象和解码出来的结果是否相同罢了。但为了上面的代码可以通过测试,我们还得做一些修改。

首先,为了让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时间戳,我们要在解码的时候,设置一下Date对象的解码方式。把didFinishGettingWeatherData中解码的部分改成下面这样:

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

完成后,test_weatherData_handle_response_decode()就应该可以通过测试了。

Refactor the test case

最后,我们整理一下所有的测试用例,把其中公共的部分定义成属性,把这些属性的设置,统一放到setUp方法里,Xcode会在执行每一个测试方法前执行这些代码

这也是implicitly unwrapped optional的一个典型的应用场景。

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)
    }

    /// ...
}

下面,是基于这些调整之后的测试用例完整代码:

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都可以正常工作了。稍后,当我们编写界面的时候,还会继续讨论UI测试的方法。通过这个过程,我们可以看到,单元测试,不仅可以有助于更早发现错误,也可以在某种程度上改进代码的质量。现在,把测试的话题先放放。在下一节,我们来定义Sky的view controllers,并把它们和models关联起来。

© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...