在测试中mock掉不属于自己的代码

我们实现weatherDataAt方法的时候,其实犯了一个小错误。就是,在调用完URLSession.shared.dataTask之后,没有调用resume()方法开始执行。实话说,这并不是故意为之,而是因为在录制视频的时候,自己真的忘记了。因此,从这个例子就可以看到,单元测试真的很重要,它能有效的帮助我们尽早发现代码中潜在的问题。

在这一节,我们正好就以这个错误为契机,来了解下如何在开发中进行单元测试。

什么是testable code

进行单元测试的一个重要前提,就是我们要测试的代码是“可测试”的。所谓“可测试”,是指代码的测试要满足一些条件,包括:

  • 测试过程要不依赖于任何外部条件和系统;
  • 在任何环境、测试任意多次,结果应该保持不变;

具体到我们的weatherDataAt来说:

  • 首先,由于它访问了DarkSky,调用它需要访问互联网就是一个典型的外部依赖;
  • 其次,由于它的执行结果直接受到DarkSky返回结果的影响,因此也不满足测试结果的不变性;

所以,我们当前这个版本的weatherDataAt并不是一个可以测试的代码。该怎么办呢?

一个来自直觉的思路,就是我们得有机会,给weatherDataAt传递一个假的URLSession对象。

Dependency Injection

为此,我们需要对weatherDataAt做一些修改。在weatherDataAt里,我们把URLSession对象拿出来,不能写死在函数里,把它变成WeatherDataManager的一个属性:

final class WeatherDataManager {
    internal let baseURL: URL
    internal let urlSession: URLSession

    internal init(baseURL: URL, urlSession: URLSession) {
        self.baseURL = baseURL
        self.urlSession = urlSession
    }

    // ...
}

这里,还有一点要注意的是,WeatherDataManager所有的属性以及init方法的访问权限,从private变成了internal,一会儿我们就会看到,这是为了方便在测试用例中,访问这些元素。

然后,之前的单例对象的初始化,也要做对应的修改:

final class WeatherDataManager {
    static let shared = WeatherDataManager(
        baseURL: API.authenticatedURL,
        urlSession: URLSession.shared)
}

最后,我们修改weatherDataAt,让它使用WeatherDataManager自带的urlSession属性:

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

    self.urlSession.dataTask(...)
}

现在,相比之前,至少就存在了一种可能性,我们可以更换掉weatherDataAt使用的URLSession对象。我们管这种在init中可以传入其它依赖对象的方式,就叫做Dependency Injection

那么,接下来,如何实际在测试的时候换掉这个URLSession对象呢?我们有两种方法:

第一种,就是定义URLSession的派生类,并改写所有weatherDataAt使用的方法。但这只是理论上可行的方案,因为只要我们没有改写URLSession的全部实现,就很难确定这个派生类确切的行为,而这种不确定性在测试过程中是无法接受的。

第二种,就是依赖Swift中的protocol,我们模拟一个看上去像URLSession的类型,这个过程,叫做mock。

Mock URLSession

为了mock URLSession,我们在Sky groups中新建一个Protocols group,在其中添加一个URLSessionProtocol.swift的文件,并添加下面的代码:

protocol URLSessionProtocol {
    typealias dataTaskHandler =
        (Data?, URLResponse?, Error?) -> Void

    func dataTask(
        with request: URLRequest,
        completionHandler: @escaping dataTaskHandler)
        -> URLSessionDataTask
}

可以看到,在URLSessionProtocol中,我们定义了一个和URLSession.dataTask签名相同的方法。

实际上,对于weatherDataAt方法来说,它的要求也仅仅是有这个方法可用就好了。于是,我们可以把WeatherDataManager的代码改成这样:

final class WeatherDataManager {
    internal let baseURL: URL
    internal let urlSession: URLSessionProtocol

    internal init(baseURL: URL,
        urlSession: URLSessionProtocol) {
        self.baseURL = baseURL
        self.urlSession = urlSession
    }
}

相比之前,我们把urlSession的类型,从一个具体的URLSession改成了URLSessionProtocol约束。这样,我们就可以给WeatherDataManager注入任意遵从URLSessionProtocol的类型了。

但这时,shared定义会发生一个错误:Argument type ‘URLSession’ does not conform to expected type ‘URLSessionProtocol’

static let shared = WeatherDataManager(
    baseURL: API.authenticatedURL,
    urlSession: URLSession.shared) // ERROR here

这很自然,尽管URLSession也实现了dataTask方法,但编译器并不认同它遵从了URLSessionProtocol。这很好办,我们明确告知编译器就好了。在Sky group中,新建一个Extensions group,在其中添加一个URLSession.swift,并添加下面的代码就好了。

extension URLSession: URLSessionProtocol {}

就像刚才说过的,无需实现任何方法,URLSession已经自带了实现好的dataTask。现在,真的URLSession对象理论上已经可以恢复正常工作了。

但为了测试,我们不能使用真的URLSession对象,还得创建一个假的对象模拟URLSession的行为。借助于我们之前创建的URLSessionProtocol,这很容易。在SkyTests group中,新建一个MockURLSession.swift,并添加下面的代码:

@testable import Sky

class MockURLSession: URLSessionProtocol {
    func dataTask(
        with request: URLRequest,
        completionHandler: @escaping dataTaskHandler)
        -> URLSessionDataTask {
        return URLSessionDataTask()
    }
}

写到这里,我们回过头想想之前的问题。在weatherDataAt方法的实现里,我们忘记了调用URLSessionDataTask对象的resume()方法。而我们希望通过测试用例,发现这个问题。于是,接下来的问题就变成了:如何确定resume()方法被调用了呢?

显然,系统默认的URLSessionDataTask对象并没有提供这个功能。因此,我们并不能让MockURLSession.dataTask方法直接返回系统的URLSessionDataTask对象。同样,我们也要定义一个“仿制品”。

Mock URLSessionDataTask

有了之前mock URLSession的经验,我们自然可以拿来“仿制”URLSessionDataTask

首先,在Protocols group中,新建一个URLSessionDataTaskProtocol.swift文件,并添加下面的代码:

protocol URLSessionDataTaskProtocol {
    func resume()
}

其次,在Extensions中新建一个URLSessionDataTask.swift文件,在这里,让URLSessionDataTask也遵从URLSessionDataTaskProtocol

extension URLSessionDataTask: URLSessionDataTaskProtocol { }

由于URLSessioinDataTask已经实现了resume()方法,和之前的URLSession类似,这里我们无需再实现任何方法,只是明确告知编译器就好了。

第三,为了使用URLSessionDataTaskProtocol,我们要修改一下之前创建的URLSessionProtocol

protocol URLSessionProtocol {
    func dataTask(
        with request: URLRequest,
        completionHandler: @escaping dataTaskHandler)
        -> URLSessionDataTaskProtocol
}

这时,如果重新编译一下,就会看到编译器提示我们发生了一个错误:Type ‘URLSession’ does not conform to protocol ‘URLSessionProtocol’。这很正常,因为URLSession自带的dataTask方法返回的是一个具体的URLSessionDataTask对象,而不是任意一个遵从URLSessionDataTaskProtocol的类型,为此,我们要自己来实现一个。

在之前创建的URLSession.swift里,添加下面的代码:

extension URLSession: URLSessionProtocol {
    func dataTask(
        with request: URLRequest,
        completionHandler: @escaping DataTaskHandler)
        -> URLSessionDataTaskProtocol {
        return (dataTask(
            with: request,
            completionHandler: completionHandler)
            as URLSessionDataTask)
            as URLSessionDataTaskProtocol
    }
}

在上面的代码里,唯一需要注意的是,我们用dataTask(with: request, completionHandler: completionHandler) as URLSessionDataTask这样的形式,明确调用了URLSession中原生的dataTask方法。

由于之前我们已经让URLSessionDataTask遵从了URLSessionDataTaskProtocol,因此,我们直接把原生dataTask的返回值再转型成URLSessionDataTaskProtocol就好了。重新编译一下,编译器就不会再报错了。

最后,我们实现一个“假的”URLSessionDataTask用于测试。在SkyTests group中,新建一个MockURLSessionDataTask.swift,在其中,添加下面的代码:

@testable import Sky

class MockURLSessionDataTask: URLSessionDataTaskProtocol {
    private (set) var isResumeCalled = false

    func resume() {
        self.isResumeCalled = true
    }
}

这样,我们就能通过isResumeCalled属性来判断resume()方法是否被调用了。

修改MockURLSession

现在,有了MockURLSessionDataTask之后,我们就可以修改一下在MockURLSession中仿制的dataTask

class MockURLSession: URLSessionProtocol {
    var sessionDataTask = MockURLSessionDataTask()

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

可以看到,这次,在“假的”URLSession里,我们返回了刚才定义的仿制品。

编写测试用例

现在,一切就都准备就绪了。在之前创建的WeatherDataManagerTest中,添加一个测试用例:

func test_weatherDataAt_starts_the_session() { }

首先,我们先创建一个“假的”URLSession对象,再创建一个“假的”URLSessionDataTask对象:

func test_weatherDataAt_starts_the_session() {
    let session = MockURLSession()
    let dataTask = MockURLSessionDataTask()
}

其次,我们设置session.sessionDataTask属性。这是MockURLSession.dataTask方法的返回值:

func test_weatherDataAt_starts_the_session() {
    /// ...
    session.sessionDataTask = dataTask
}

第三,我们创建一个WeatherDataManager对象,并调用weatherDataAt方法:

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

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

由于我们给WeatherDataManager.init传递的是我们“仿制”的URLSession,也就是一个MockURLSession对象,因此,它是带有resume()调用检测能力的。而我们正是要测试在weatherDataAt的实现中,调用了resume()方法。

最后,我们测试isResumeCalled属性就好了:

func test_weatherDataAt_starts_the_session() {
    /// ...
    XCTAssert(session.sessionDataTask.isResumeCalled)
}

至此,我们的测试用例就写好了。按Shift + Command + U构建一下测试用例,然后按Ctrl + Option + Command + U执行一下测试,就会发现测试失败了:

test_weatherDataAt_starts_the_session] : XCTAssertTrue failed -
Test Case '-[SkyTests.WeatherDataManagerTest test_weatherDataAt_starts_the_session]' failed (0.021 seconds).

通过这个提示,我们就知道,weatherDataAt没有启动session,也就是没有调用resume()方法,这正好符合我们的预期。

现在,回到weatherDataAt的实现,修改这个bug:

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

    self.urlSession.dataTask(...).resume()
}

回到WeahterDataManagerTest.swift,重新跑一下测试,就会发现测试成功了。

以上,就是这一节的内容。通过单元测试,我们修复了一个在网络编程中常犯的错误。现在,你应该对单元测试有一个比较具体的认识了,简单来说,主要有两点:

  • 每一个测试用例,应该只测试一个内容,例如:测试resume()方法是否被调用了;
  • 对于不属于我们自己的代码,例如:系统API、第三方框架等,我们要mock掉相关的代码以隔离它们对测试过程的影响;
© 版权声明

相关文章

暂无评论

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