通過 3 個(gè)簡(jiǎn)單的步驟測(cè)試使用了系統(tǒng)單例的 Swift 代碼

\color{orange}{\Large \mathtt{通過 3 個(gè)簡(jiǎn)單的步驟測(cè)試使用了系統(tǒng)單例的 Swift 代碼}}

大多數(shù)為蘋果的任何平臺(tái)編寫的應(yīng)用程序都依賴基于單例的API。從UIScreenUIApplication再到NSBundle,靜態(tài)API在FoundationUIKitAppKit中無處不在。

雖然單例非常方便,可以從任何地方輕松訪問某個(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 SundellTesting 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
        }
    }
}

感謝您的閱讀 ??

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容