[譯] Swift 網路單元測試完全手冊

Swants發表於2019-03-03
[譯] Swift 網路單元測試完全手冊

不得不承認,對於 iOS 開發寫測試並不是很普遍(至少和後端寫測試程度相比)。我過去是個獨立開發者而且最初也沒經過原生“測試驅動”的開發培訓,因此我花費了大量的時間來學習如何編寫測試用例,如何寫出可測試的程式碼。這也是我寫這篇文章的初衷,我想把自己用 Swift 寫測試時摸索到的心得分享給大家,希望我的見解能夠幫助大家節省學習時間,少走些彎路。

在這篇文章,我們將會討論著手寫測試的入門知識:依賴注入

想象一下,你此時正在寫測試。
如果你的測試物件(被測系統)是和真實世界相連的,比如 Networking 和 CoreData,編寫測試程式碼將會非常複雜。原則上講,我們不希望我們的測試程式碼被客觀世界的事物所影響。被測系統不應依賴於其他的複雜系統,這樣我們才能夠保證在時間恆定和環境恆定條件下迅速完成測試。況且,保證我們的測試程式碼不會“汙染”生產環境也是十分重要的。“汙染”意味著什麼?意味著我們的測試程式碼將一些測試物件寫進了資料庫,提交了些測試資料到生產伺服器等等。而避免這些情況的發生就是 依賴注入 存在的意義。

讓我們從一個例子開始。
假設你拿到個應該聯網並且在生產環境下才能被執行的類,聯網部分就被稱作該類的 依賴。如之前所言,當我們執行測試時這個類的聯網部分必須能夠被模擬的,或者假的環境所替換。換句話說,該類的依賴必須支援“可注入”,依賴注入使我們的系統更加靈活。我們能夠為生產程式碼“注入”真實的網路環境;與此同時,也能夠“注入”模擬的網路環境來讓我們在不訪問網際網路的條件下執行測試程式碼。

TL;DR

譯者注:TL;DR 是 Too long;Don`t read 的縮寫。在這裡的意思是篇幅較長,不想深入研究,請直接看文章總結。

在這篇文章,我們將會討論:

  1. 如何使用 依賴注入 技術設計一個物件
  2. 在 Swift 中如何使用協議設計一個模擬物件
  3. 如何測試物件使用的資料及如何測試物件的行為

依賴注入

開始動手吧! 現在我們打算實現一個叫做 HttpClient 的類。這個 HttpClient 應該滿足以下要求:

  1. HttpClient 跟初始的網路元件對於同一 URL 應提交同樣的 request。
  2. HttpClient 應能夠提交 request。

所以我們對 HttpClient 的初次實現是這樣的:

class HttpClient {
    typealias completeClosure = ( _ data: Data?, _ error: Error?)->Void
    func get( url: URL, callback: @escaping completeClosure ) {
        let request = NSMutableURLRequest(url: url)
        request.httpMethod = "GET"
        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
            callback(data, error)
        }
        task.resume()
    }
}
複製程式碼

HttpClient 看起來可以提交一個 “GET” 請求,並通過 “callback” 閉包將返回值回傳。

HttpClient().get(url: url) { (success, response) in // Return data }
複製程式碼

HttpClient 的用法。

這就是問題所在:我們怎麼對它測試?我們如何確保這些程式碼達到上述的兩點要求?憑直覺,我們可以給 HttpClient 傳入一個 URL,執行程式碼,然後在閉包裡觀察得到的結果。但是這些操作意味著我們在執行 HttpClient 時必須每次都連線網際網路。更糟糕的是如果你測試的 URL 是連線生產伺服器:你的測試在一定程度上會影響伺服器效能,而且你提交的測試資料將會被提交到真實的世界。就像我們之前描述的,我們必須讓 HttpClient “可測試”。

我們來看下 URLSession。URLSession 是 HttpClient 的一種‘環境’,是 HttpClient 連線網際網路的入口。還記得我們剛討論的“可測試”程式碼嗎? 我們需要將網際網路部分變得可替換,於是我們修改了 HttpClient 的實現:

class HttpClient {
    typealias completeClosure = ( _ data: Data?, _ error: Error?)->Void
    private let session: URLSession
    init(session: URLSessionProtocol) {
        self.session = session
    }
    func get( url: URL, callback: @escaping completeClosure ) {
        let request = NSMutableURLRequest(url: url)
        request.httpMethod = "GET"
        let task = session.dataTask(with: request) { (data, response, error) in
            callback(data, error)
        }
        task.resume()
    }
}
複製程式碼

我們將

let task = URLSession.shared.dataTask()
複製程式碼

修改成了

let task = session.dataTask()
複製程式碼

我們增加了新的變數:session,並新增了對應的 init 方法。之後每當我們建立 HttpClient 物件時,就必須初始化 session。也就是說,我們已經將 session “注入”到了我們建立的 HttpClient 物件中。現在我們就能夠在執行生產程式碼時注入 ‘URLSession.shared’,而執行測試程式碼時注入一個模擬的 session。Bingo!

這時 HttpClient 的用法就變成了:HttpClient(session: SomeURLSession() ).get(url: url) { (success, response) in // Return data }

給此時的 HttpClient 寫測試程式碼就會變得非常簡單。因此我們開始佈置我們的測試環境:

class HttpClientTests: XCTestCase { 
    var httpClient: HttpClient! 
    let session = MockURLSession()
    override func setUp() {
        super.setUp()
        httpClient = HttpClient(session: session)
    }
    override func tearDown() {
        super.tearDown()
    }
}
複製程式碼

這是個規範的 XCTestCase 設定。httpClient 變數就是被測系統,session 變數是我們將為 httpClient 注入的環境。因為我們要在測試環境執行程式碼,所以我們將 MockURLSession 物件傳給 session。這時我們將模擬的 session 注入到了 httpClient,使得 httpClient 在 URLSession.shared 被替換成 MockURLSession 的情況下執行。

測試資料

現在讓我們注意下第一點要求:

  1. HttpClient 和初始的網路元件對於同一 URL 應提交同樣的 request 。

我們想達到的效果是確保該 request 的 url 和我們傳入 “get” 方法的 url 完全一致。

以下是我們的測試用例:

func test_get_request_withURL() {
    guard let url = URL(string: "https://mockurl") else {
        fatalError("URL can`t be empty")
    }
    httpClient.get(url: url) { (success, response) in
        // Return data
    }
    // Assert 
}
複製程式碼

這個測試用例可表示為:

  • Precondition: Given a url “https://mockurl”
  • When: Submit a http GET request
  • Assert: The submitted url should be equal to “https://mockurl”

我們還需要寫斷言部分。

但是我們怎麼知道 HttpClient 的 “get” 方法確實提交了正確的 url 呢?讓我們再看眼依賴:URLSession。通常,“get” 方法會用拿到的 url 建立一個 request,並把 request 傳給 URLSession 來完成提交:

let task = session.dataTask(with: request) { (data, response, error) in
    callback(data, error)
}
task.resume()
複製程式碼

接下來,在測試環境中 request 將會傳給 MockURLSession,所以我們只要 hack 進我們自己的 MockURLSession 就可以檢視 request 是否被正確建立了。

下面是 MockURLSession 的粗略實現:

class MockURLSession {
    private (set) var lastURL: URL?
    func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTask {
        lastURL = request.url
        completionHandler(nextData, successHttpURLResponse(request: request), nextError)        
        return // dataTask, will be impletmented later
    }
}
複製程式碼

MockURLSession 的作用和 URLSession 一樣,URLSession 和 MockURLSession 有同樣的 dataTask() 方法和相同的回撥閉包型別。雖然 URLSession 比 MockURLSession 的 dataTask() 做了更多的工作,但它們的介面是類似的。正是由於它們的介面相似,我們才能不需要修改 “get” 方法太多程式碼就可以用 MockURLSession 替換掉 URLSession。接著我們建立一個 lastURL 變數來跟蹤 “get” 方法提交的最終 url 。簡單點說,就是當測試的時候,我們建立一個注入 MockURLSession 的 HttpClient,然後觀察 url 是否前後相同。

以下是測試用例的大概實現:

func test_get_request_withURL() {
    guard let url = URL(string: "https://mockurl") else {
        fatalError("URL can`t be empty")
    }
    httpClient.get(url: url) { (success, response) in
        // Return data
    }
    XCTAssert(session.lastURL == url)
}
複製程式碼

我們為 lastURLurl 新增斷言,這樣就會得知注入後的 “get” 方法是否正確建立了帶有正確 url 的 request。

上面的程式碼仍有一處地方需要實現:return // dataTask。在 URLSession 中返回值必須是個 URLSessionDataTask 物件,但是 URLSessionDataTask 已經不能正常建立了,所以這個 URLSessionDataTask 物件也需要被模擬建立:

class MockURLSessionDataTask {  
    func resume() { }
}
複製程式碼

作為 URLSessionDataTask,模擬物件需要有相同的方法 resume()。這樣才會把模擬物件當做 dataTask() 的返回值。

如果你跟著我一塊敲程式碼,就會發現你的程式碼會被編譯器報錯:

class HttpClientTests: XCTestCase {
    var httpClient: HttpClient!
    let session = MockURLSession()
    override func setUp() {
        super.setUp()
        httpClient = HttpClient(session: session) // Doesn`t compile 
    }
    override func tearDown() {
        super.tearDown()
    }
}
複製程式碼

這是因為 MockURLSession 和 URLSession 的介面不一樣。所以當我們試著注入 MockURLSession 的時候會發現 MockURLSession 並不能被編譯器識別。我們必須讓模擬的物件和真實物件擁有相同的介面,所以我們引入了 “協議” !

HttpClient 的依賴:

private let session: URLSession
複製程式碼

我們希望不論 URLSession 還是 MockURLSession 都可以作為 session 物件,因此我們將 session 的 URLSession 型別改為 URLSessionProtocol 協議:

private let session: URLSessionProtocol
複製程式碼

這樣我們就能夠注入 URLSession 或 MockURLSession 或者其它遵循這個協議的物件。

以下是協議的實現:

protocol URLSessionProtocol { typealias DataTaskResult = (Data?, URLResponse?, Error?) -> Void
    func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol
}
複製程式碼

測試程式碼中我們只需要一個方法:dataTask(NSURLRequest, DataTaskResult),因此在協議中我們也只需定義一個必須實現的方法。當我們需要模擬不屬於我們的物件時這個技術通常很適用。

還記得 MockURLDataTask 嗎?另一個不屬於我們的物件,是的,我們要再建立個協議。

protocol URLSessionDataTaskProtocol { func resume() }
複製程式碼

我們還需讓真實的物件遵循這個協議。

extension URLSession: URLSessionProtocol {}
extension URLSessionDataTask: URLSessionDataTaskProtocol {}
複製程式碼

URLSessionDataTask 有個同樣的 resume() 協議方法,所以這項修改對於 URLSessionDataTask 是沒有影響的。

問題是 URLSession 沒有 dataTask() 方法來返回 URLSessionDataTaskProtocol 協議,因此我們需要擴充方法來遵循協議。

extension URLSession: URLSessionProtocol {
    func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol {
        return dataTask(with: request, completionHandler: completionHandler) as URLSessionDataTaskProtocol
    }
}
複製程式碼

這個簡單的方法只是將返回型別從 URLSessionDataTask 改成了 URLSessionDataTaskProtocol,不會影響到 dataTask() 的其它行為。

現在我們就能夠補全 MockURLSession 缺失的部分了:

class MockURLSession {
    private (set) var lastURL: URL?
    func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTask {
        lastURL = request.url
        completionHandler(nextData, successHttpURLResponse(request: request), nextError)        
        return // dataTask, will be impletmented later
    }
}
複製程式碼

我們已經知道   // dataTask… 可以是一個 MockURLSessionDataTask:

class MockURLSession: URLSessionProtocol {
    var nextDataTask = MockURLSessionDataTask()
    private (set) var lastURL: URL?
    func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol {
        lastURL = request.url
        completionHandler(nextData, successHttpURLResponse(request: request), nextError)
        return nextDataTask
    }
}
複製程式碼

在測試環境中模擬物件就會充當 URLSession 的角色,並且 url 也能夠被記錄供斷言判斷。是不是有種萬丈高樓平地起的感覺! 所有的程式碼都已經編譯完成並且測試也順利通過!

讓我們繼續。

測試行為

第二點要求是:

The HttpClient should submit the request

我們希望 HttpClient 的 “get” 方法將 request 如預期地提交。

和之前驗證資料是否正確的測試不同,我們現在要測試的是方法是否被順利呼叫。換句話說,我們想知道 URLSessionDataTask.resume() 方法是否被呼叫了。讓我們繼續使用剛才的老把戲:
我們建立一個新的 resumeWasCalled 變數來記錄 resume() 方法是否被呼叫。

我們簡單寫一個測試:

func test_get_resume_called() {
    let dataTask = MockURLSessionDataTask()
    session.nextDataTask = dataTask
    guard let url = URL(string: "https://mockurl") else {
        fatalError("URL can`t be empty")
    }
    httpClient.get(url: url) { (success, response) in
        // Return data
    }
    XCTAssert(dataTask.resumeWasCalled)
}
複製程式碼

dataTask 變數是我們自己擁有的模擬物件,所以我們可以新增一個屬性來監控 resume() 方法的行為:

class MockURLSessionDataTask: URLSessionDataTaskProtocol {
    private (set) var resumeWasCalled = false
    func resume() {
        resumeWasCalled = true
    }
}
複製程式碼

如果 resume() 方法被呼叫了,resumeWasCalled 就會被設定成 true! ? 很簡單,對不對?

總結

通過這篇文章,我們學到:

  1. 如何調整依賴注入來改變生產/測試環境。
  2. 如何利用協議來建立模擬物件。
  3. 如何檢測傳值的正確性。
  4. 如何斷言某個函式的行為。

剛起步時,你必須花費大量時間來寫簡單的測試,而且測試程式碼也是程式碼,所以你仍需要保持測試程式碼的簡潔和良好的架構。但編寫測試用例得到的好處也是彌足珍貴的,程式碼只有在恰當的測試後才能被擴充套件,測試幫你免於瑣碎 bug 的困擾。所以讓我們一起加油寫好測試吧!

所有的示例程式碼都在 GitHub 上,程式碼是以 Playground 的形式展示的,我還在上面新增了個額外的測試。 你可以自由下載或 fork 這些程式碼,並且歡迎任何反饋!

感謝閱讀我的文章 ? 。

參考文獻

  1. Mocking Classes You Don’t Own
  2. Dependency Injection
  3. Test-Driven iOS Development with Swift

感謝 Lisa DziubaAhmed Sulaiman.


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章