大多数为苹果的任何平台编写的应用程序都依赖基于单例的API。从UIScreen
到UIApplication
再到NSBundle
,静态API在Foundation
、UIKit
和AppKit
中无处不在。
虽然单例非常方便,可以从任何地方轻松访问某个API,但在涉及到代码解耦和测试时,它们也会带来挑战。单例也是一个相当常见的错误来源,状态最终被共享和改变导致没有在整个系统中正确传播。
然而,虽然我们可以重构我们自己的代码,只在真正需要的地方使用单例,但我们对系统API给我们的东西却无能为力。但好消息是,你可以使用一些技术来使你的代码在使用系统单例时仍然易于管理和测试。
让我们看看一些使用URLSession.shared
单例的代码:
class DataLoader {
enum Result {
case data(Data)
case error(Error)
}
func load(from url: URL, completionHandler: @escaping (Result) -> Void) {
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
if let error = error {
return completionHandler(.error(error))
}
completionHandler(.data(data ?? Data()))
}
task.resume()
}
}
上述的DataLoader
目前很难测试,因为它将自动调用共享的URL会话并执行网络调用。这就需要我们在测试代码中加入等待和超时,而且很快就变得非常棘手和不稳定。
相反,让我们通过3个简单的步骤,使这段代码仍然像目前一样简单易用,但使它更容易测试。
1. 抽象成一个协议
我们的首要任务是将URLSession
中我们需要的部分转移到一个协议中,然后我们可以在测试中轻松地模拟。在我的演讲 "编写具有强大可测试性的Swift代码 "中,我建议尽可能避免使用模拟,虽然这对你自己的代码来说是一个很好的策略,但当与系统的单例进行交互时,模拟就成了提高可预测性的一个重要工具。
让我们创建一个NetworkEngine
协议并使URLSession
遵循它:
protocol NetworkEngine {
typealias Handler = (Data?, URLResponse?, Error?) -> Void
func performRequest(for url: URL, completionHandler: @escaping Handler)
}
extension URLSession: NetworkEngine {
typealias Handler = NetworkEngine.Handler
func performRequest(for url: URL, completionHandler: @escaping Handler) {
let task = dataTask(with: url, completionHandler: completionHandler)
task.resume()
}
}
正如你在上面看到的,我们让URLSessionDataTask
成为URLSession
的一个实现细节。这样,我们就不必在测试中创建多个模拟,而可以专注于NetworkEngine
的API。
2. 使用以单例为默认参数的协议
现在,让我们更新之前的DataLoader
,以使用新的NetworkEngine
协议,并将其作为一个依赖关系注入。我们将使用URLSession.shared
作为默认参数,这样我们就可以保持向后的兼容性和与以前一样的便利。
class DataLoader {
enum Result {
case data(Data)
case error(Error)
}
private let engine: NetworkEngine
init(engine: NetworkEngine = URLSession.shared) {
self.engine = engine
}
func load(from url: URL, completionHandler: @escaping (Result) -> Void) {
engine.performRequest(for: url) { (data, response, error) in
if let error = error {
return completionHandler(.error(error))
}
completionHandler(.data(data ?? Data()))
}
}
}
通过使用默认参数,我们仍然可以轻松地创建一个DataLoader
,而不需要提供一个NetworkEngine
——只需使用DataLoader()
,就像以前一样。
3. 在你的测试中模拟该协议
最后,让我们写一个测试——在这里我们将模拟NetworkEngine
,使我们的测试快速、可预测并易于维护:
func testLoadingData() {
class NetworkEngineMock: NetworkEngine {
typealias Handler = NetworkEngine.Handler
var requestedURL: URL?
func performRequest(for url: URL, completionHandler: @escaping Handler) {
requestedURL = url
let data = "Hello world".data(using: .utf8)
completionHandler(data, nil, nil)
}
}
let engine = NetworkEngineMock()
let loader = DataLoader(engine: engine)
var result: DataLoader.Result?
let url = URL(string: "my/API")!
loader.load(from: url) { result = $0 }
XCTAssertEqual(engine.requestedURL, url)
XCTAssertEqual(result, .data("Hello world".data(using: .utf8)!))
}
上面你可以看到,我试图让我的模拟尽可能的简单。与其用大量的逻辑来创建复杂的模拟,不如让它们返回一些硬编码的值,然后在测试中进行断言,这通常是个好主意。否则,风险是你最终测试你的模拟比你实际测试你的生产代码更多。
就是这样!
我们现在有了可测试的代码,为了方便起见,仍然使用系统的单例——所有这些都是通过这3个简单的步骤完成的。
1. 抽象成一个协议
2. 使用以单例为默认参数的协议
3. 在你的测试中模拟该协议
译自 John Sundell 的 Testing Swift code that uses system singletons in 3 easy steps
PS: 因为swift版本的原因,当前版本测试代码最终是跑不起来的,因为Result
没有遵循Equatable
协议,可以这样修改:
if case .data(let data) = result {
XCTAssertEqual(data, "Hello world".data(using: .utf8)!)
}
或者直接在DataLoader
补充如下代码:
extension DataLoader.Result: Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case (.data(let dataL), .data(let dataR)) where dataL == dataR: return true
case _: return false
}
}
}
感谢您的阅读 🚀