我们实现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掉相关的代码以隔离它们对测试过程的影响;