对于任何一门编程语言,当你编写单元测试时,模拟对象(Mock Object
)都是一门关键的技术。 在模拟对象时,我们实际上是在创建它的一个“假”的版本,这个假的对象使用与真实对象相同的API,这让我们更容易地在测试用例中进行断言(Assert)和验证结果。
无论我们是在测试网络代码,或则测试依赖于加速度计等硬件传感器的代码,还是测试使用位置服务等系统API的代码,对象模拟都可以让我们更轻松地编写测试,并以更可可靠的方式,更快地运行这些测试。
但是,也存在可能不需要进行对象模拟的情况。例如有时候,在我们的测试中需要包含真实对象,以便让我们编写在实际条件下运行的测试。 现在,我们来看看模拟对象的几种不同情况,什么时候应该使用模拟?什么时候应该避免它?来使我们的测试更容易编写,读取和运行。
首先,让我们来看一下一个实际的例子,假设我们正在构建一个NetworkManager
,它允许我们从给定的URL加载数据:
class NetworkManager {
func loadData(from url: URL,
completionHandler: @escaping (NetworkResult) -> Void) {
let task = URLSession.shared.dataTask(with: url) { data, _, error in
// 创建并返回Result枚举对象值 .success 或者 .failure
let result = data.map(NetworkResult.success) ?? .failure(error)
completionHandler(result)
}
task.resume()
}
}
现在,我们要编写测试来验证在正确的情况下返回.success和.error。 要做到这一点,我们可以简单地调用我们的loadData API并等待返回结果,但这两者都要求我们的测试使用Internet连接运行,这样测试运行起来会慢很多(因为我们必须等待 要求执行真正的请求)。
现在,让我们使用Mock来代替真实的API请求。 我们在这里要做的是,让NetworkManager
在我们的测试代码中使用假会话(Session)
,这个假会话
不会通过网络执行任何请求,而是让我们准确地控制网络的行为方式。
对象模拟有两种不同的风格 - 局部模拟
和完全模拟
。 在进行局部模拟时,我们修改现有类型,以便在测试中仅部分表现不同,而在完全模拟时,您将替换整个实现。
如果我们想局部模拟它返回的URLSession和URLSessionDataTask,我们可以创建实际对象的子类,每个子类都覆盖我们期望被调用的方法,以便返回我们可以在测试中控制的特定结果。 让我们从创建一个模拟数据任务开始,该任务在回调时只运行一个闭包:
// 我们通过继承原类来创建一个部分模拟的子类
class URLSessionDataTaskMock: URLSessionDataTask {
private let closure: () -> Void
init(closure: @escaping () -> Void) {
self.closure = closure
}
// 重载‘resume’方法,直接调用回调closure
override func resume() {
closure()
}
}
现在让我们对URLSession做同样的事情,但是这次我们将覆盖dataTask方法以返回我们的模拟类的实例,如下所示:
class URLSessionMock: URLSession {
typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void
// 下面的属性
// 我们需要模拟的session对象返回的数据或错误对象
var data: Data?
var error: Error?
override func dataTask(
with url: URL,
completionHandler: @escaping CompletionHandler
) -> URLSessionDataTask {
let data = self.data
let error = self.error
return URLSessionDataTaskMock {
completionHandler(data, nil, error)
}
}
}
Okay,我们的模拟对象准备就绪! 现在我们要向NetworkManager
添加依赖注入
,以便让我们注入一个模拟的Session,来替代URLSession.shared:
class NetworkManager {
private let session: URLSession
// 使用默认参数(= .shared)可以避免修改我们主app的代码
init(session: URLSession = .shared) {
self.session = session
}
func loadData(from url: URL,
completionHandler: @escaping (NetworkResult) -> Void) {
let task = session.dataTask(with: url) { data, _, error in
let result = data.map(NetworkResult.success) ?? .failure(error)
completionHandler(result)
}
task.resume()
}
}
最后,让我们编写第一个测试,验证如果从网络请求返回Data,则返回成功的结果:
class NetworkManagerTests: XCTestCase {
func testSuccessfulResponse() {
// 设置我们的mock对象
let session = URLSessionMock()
let manager = NetworkManager(session: session)
// 创建返回数据,并赋值给模拟的session对象
let data = Data(bytes: [0, 1, 0, 1])
session.data = data
// 创建一个URL
let url = URL(fileURLWithPath: "url")
// 执行请求并验证结果
var result: NetworkResult?
manager.loadData(from: url) { result = $0 }
XCTAssertEqual(result, .success(data))
}
}
我们现在有一个测试,用于验证我们的NetworkManager
是否能够成功响应?! 厉害了,但还有很大的改进空间。 使用局部模拟,就像我们上面所做的那样,有时会很有用。但是,它有两个主要缺点:
让我们改为使用完全模拟,这意味着我们将用完全模拟的实现,替换整个URLSession类。 要做到这一点,我们不能像创建部分模拟时那样对URLSession进行子类化,而是将我们需要的API抽象为协议:
protocol NetworkSession {
func loadData(from url: URL,
completionHandler: @escaping (Data?, Error?) -> Void)
}
然后我们通过使用extension来使URLSession遵循协议接口:
extension URLSession: NetworkSession {
func loadData(from url: URL,
completionHandler: @escaping (Data?, Error?) -> Void) {
let task = dataTask(with: url) { (data, _, error) in
completionHandler(data, error)
}
task.resume()
}
}
最后,我们将使NetworkManager在其初始化程序中接受符合NetworkSession的对象,而不是URLSession实例:
class NetworkManager {
private let session: NetworkSession
init(session: NetworkSession = URLSession.shared) {
self.session = session
}
func loadData(from url: URL,
completionHandler: @escaping (NetworkResult) -> Void) {
session.loadData(from: url) { data, error in
let result = data.map(NetworkResult.success) ?? .failure(error)
completionHandler(result)
}
}
}
使用完全模拟的最大好处是,通过简单地实现NetworkSession协议,我们现在可以更轻松地为我们的测试创建模拟:
class NetworkSessionMock: NetworkSession {
var data: Data?
var error: Error?
func loadData(from url: URL,
completionHandler: @escaping (Data?, Error?) -> Void) {
completionHandler(data, error)
}
}
没有关于URLSession内部的假设,我们现在在NetworkManager和它的底层会话之间有一个更强大的API契约?。
使用完全模拟时要记住的一件事是,保持协议尽可能的简单 (一种方法是尽可能地分解和组合协议),否则你最终必须在你的模拟中实现许多方法和功能。在理想情况下,模拟应该是超级简单的,根本不应该包含任何逻辑。
现在我们已经了解了在Swift中实现模拟的各种方法,让我们看看我们实际上想要避免模拟的一个例子。当习惯于Mocking等技术时,有时你会觉得它能包治百病,所以会在各种情况下使用它。虽然大多数测试确实从模拟中受益,并且更容易单独测试给定的类,但并不总是必要的。
假设我们正在构建一个FileLoader,它允许我们从文件系统加载文件。为此,我们需要为给定的文件名解析文件系统URL,为此,我们将使用应用程序的Bundle。 Bundle API类似于我们之前使用的URLSession API,因为它是基于单例的,通常通过访问其共享实例来使用 - 在本例中为.main。所以,我们最初的想法可能是做与URLSession完全相同的事情 - 为它创建一个协议,然后进行模拟。
但是,当涉及到Bundle时,这实际上并不是必需的,实际上,模拟会给我们的测试代码增加不必要的复杂度。我们可以做的是简单地使用我们的测试套件的bundle,并包含我们想要在该包中加载的任何文件。在Xcode中,我们可以创建一个文件 - 让我们称之为TestFile.txt - 并将其添加到我们的测试target中。然后,我们通过在其初始化程序中为我们的测试用例类提供Bundle,让FileLoader使用我们的测试包,如下所示:
class FileLoaderTests: XCTestCase {
func testReadingFileAsString() throws {
// 将测试用例类作为参数,初始化Bundle,这样系统会使用测试bundle而不是主程序bundle
let bundle = Bundle(for: type(of: self))
let loader = FileLoader(bundle: bundle)
let string = try loader.stringFromFile(named: "TestFile.txt")
XCTAssertEqual(string, "I'm a test file!\n")
}
}
因此,并不总是需要模拟,如果我们可以避免它们(并且仍然编写好的和稳定的测试),它有时可以使测试代码更简单!?
“模拟或着不模拟,这是个问题。。。”? 我希望这篇文章能让你深入了解如何在Swift中应用各种模拟技术。我的建议是了解我们可以使用的各种技术,然后在您认为最合适的地方应用它们。