大多數(shù)為蘋果的任何平臺(tái)編寫的應(yīng)用程序都依賴基于單例的API。從UIScreen到UIApplication再到NSBundle,靜態(tài)API在Foundation、UIKit和AppKit中無處不在。
雖然單例非常方便,可以從任何地方輕松訪問某個(gè)API,但在涉及到代碼解耦和測(cè)試時(shí),它們也會(huì)帶來挑戰(zhàn)。單例也是一個(gè)相當(dāng)常見的錯(cuò)誤來源,狀態(tài)最終被共享和改變導(dǎo)致沒有在整個(gè)系統(tǒng)中正確傳播。
然而,雖然我們可以重構(gòu)我們自己的代碼,只在真正需要的地方使用單例,但我們對(duì)系統(tǒng)API給我們的東西卻無能為力。但好消息是,你可以使用一些技術(shù)來使你的代碼在使用系統(tǒng)單例時(shí)仍然易于管理和測(cè)試。
讓我們看看一些使用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目前很難測(cè)試,因?yàn)樗鼘⒆詣?dòng)調(diào)用共享的URL會(huì)話并執(zhí)行網(wǎng)絡(luò)調(diào)用。這就需要我們?cè)跍y(cè)試代碼中加入等待和超時(shí),而且很快就變得非常棘手和不穩(wěn)定。
相反,讓我們通過3個(gè)簡(jiǎn)單的步驟,使這段代碼仍然像目前一樣簡(jiǎn)單易用,但使它更容易測(cè)試。
1. 抽象成一個(gè)協(xié)議
我們的首要任務(wù)是將URLSession中我們需要的部分轉(zhuǎn)移到一個(gè)協(xié)議中,然后我們可以在測(cè)試中輕松地模擬。在我的演講 "編寫具有強(qiáng)大可測(cè)試性的Swift代碼 "中,我建議盡可能避免使用模擬,雖然這對(duì)你自己的代碼來說是一個(gè)很好的策略,但當(dāng)與系統(tǒng)的單例進(jìn)行交互時(shí),模擬就成了提高可預(yù)測(cè)性的一個(gè)重要工具。
讓我們創(chuàng)建一個(gè)NetworkEngine協(xié)議并使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的一個(gè)實(shí)現(xiàn)細(xì)節(jié)。這樣,我們就不必在測(cè)試中創(chuàng)建多個(gè)模擬,而可以專注于NetworkEngine的API。
2. 使用以單例為默認(rèn)參數(shù)的協(xié)議
現(xiàn)在,讓我們更新之前的DataLoader,以使用新的NetworkEngine協(xié)議,并將其作為一個(gè)依賴關(guān)系注入。我們將使用URLSession.shared作為默認(rèn)參數(shù),這樣我們就可以保持向后的兼容性和與以前一樣的便利。
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()))
}
}
}
通過使用默認(rèn)參數(shù),我們?nèi)匀豢梢暂p松地創(chuàng)建一個(gè)DataLoader,而不需要提供一個(gè)NetworkEngine——只需使用DataLoader(),就像以前一樣。
3. 在你的測(cè)試中模擬該協(xié)議
最后,讓我們寫一個(gè)測(cè)試——在這里我們將模擬NetworkEngine,使我們的測(cè)試快速、可預(yù)測(cè)并易于維護(hù):
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)!))
}
上面你可以看到,我試圖讓我的模擬盡可能的簡(jiǎn)單。與其用大量的邏輯來創(chuàng)建復(fù)雜的模擬,不如讓它們返回一些硬編碼的值,然后在測(cè)試中進(jìn)行斷言,這通常是個(gè)好主意。否則,風(fēng)險(xiǎn)是你最終測(cè)試你的模擬比你實(shí)際測(cè)試你的生產(chǎn)代碼更多。
就是這樣!
我們現(xiàn)在有了可測(cè)試的代碼,為了方便起見,仍然使用系統(tǒng)的單例——所有這些都是通過這3個(gè)簡(jiǎn)單的步驟完成的。
1. 抽象成一個(gè)協(xié)議
2. 使用以單例為默認(rèn)參數(shù)的協(xié)議
3. 在你的測(cè)試中模擬該協(xié)議
譯自 John Sundell 的 Testing Swift code that uses system singletons in 3 easy steps
PS: 因?yàn)閟wift版本的原因,當(dāng)前版本測(cè)試代碼最終是跑不起來的,因?yàn)?code>Result沒有遵循Equatable協(xié)議,可以這樣修改:
if case .data(let data) = result {
XCTAssertEqual(data, "Hello world".data(using: .utf8)!)
}
或者直接在DataLoader補(bǔ)充如下代碼:
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
}
}
}
感謝您的閱讀 ??