如何讓系統單例更易測試

BigNerdCoding發表於2017-12-20

Cover

UIApplicationUIScreen 為代表的單例模式是 iOS 中最為常見的設計模式了,你可以在程式碼中的任意位置呼叫其屬性或者方法。但是這種便利也給程式程式碼來一些負面影響,這種全域性共享狀態的做法對於程式碼測試來說簡直就是噩夢。雖然我們可以對部分單例進行重構,但是系統單例依舊需要一些技巧進行改造才能變成測試友好物件。

下面是一個使用 URLSession.share 單例的常見網路任務程式碼:

enum ServiceResult {
    case success(Data)
    case error(Error)
}

class DataLoaderService {

    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(.success(data ?? Data()))
        }

        task.resume()
    }
    
}
複製程式碼

URLSession.shared 單例讓我們在測試時不得不面對等待和超時的情況,隨著使用的地方增多程式碼的測試性更是會直線下降。

使用協議對介面進行抽象

為了讓程式碼對測試更為友好,我們可以使用 Mock 方式將上訴程式碼中的單例替換掉。而要實現該目標,第一步我們就需要將上訴函式中的功能抽象為協議中的介面。

protocol NetworkEngine {
    typealias Handler = (Data?, URLResponse?, Error?) -> Void

    func performRequest(for url: URL, completionHandler: @escaping Handler)
}
複製程式碼

介面宣告好之後接下來就是讓 URLSession 實現該協議:

extension URLSession: NetworkEngine {
    typealias Handler = NetworkEngine.Handler

    func performRequest(for url: URL, completionHandler: @escaping Handler) {
        let task = dataTask(with: url, completionHandler: completionHandler)
        task.resume()
    }
}
複製程式碼

這樣後面我們 Mock 的時候只需要關注 NetworkEngine 就好。

將系統單例作為預設引數注入

為了保證原有 load 中的處理不變,接下來我們需要將 URLSession.shared 單例作為依賴注入 DataLoaderService

class DataLoaderService {
    private let engine: NetworkEngine

    init(engine: NetworkEngine = URLSession.share) {
         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(.success(data ?? Data()))
        }
    }

}
複製程式碼

這裡通過預設引數的方式,我們將原有的 URLSession.share 單例作為依賴注入到 DataLoaderService 中保證了原有功能的不變。

在測試中進行 Mock

在上面的改造完成後,我們就可以在進行單元測試時使用 Mock 的方式解決單例在測試中的問題。

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 = DataLoaderService(engine: engine)

    var result: ServiceResult?
    let url = URL(string: “mock/api”)!
    loader.load(from: url) { result = $0 }

    XCTAssertEqual(engine.requestedURL, url)
    XCTAssertEqual(result, .data(“Hello world”.data(using: .utf8)!))
}
複製程式碼

在上訴程式碼中我實現了一個 NetworkEngine 協議的簡單 Mock 類,並且將其作為依賴性注入到 DataLoaderService 例項中。在 Mock 類 NetworkEngineMock 我們並沒有真正的請求網路介面而是直接返回硬編碼,這樣進一步減少了測試時的複雜度。

總結

通過上面三個簡單的步驟,我們就能完成讓原有的方法變得更易測試。當然該方法並不囿於系統單例的使用場景的改造,這種面向協議的介面服務設計在 Swift 中有更大的舞臺。

原文地址

相關文章