- 原文地址:The complete guide to Network Unit Testing in Swift
- 原文做者:S.T.Huang
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:swants
- 校對者:pthtc ZhiyuanSun
不得不認可,對於 iOS 開發寫測試並非很廣泛(至少和後端寫測試程度相比)。我過去是個獨立開發者並且最初也沒通過原生「測試驅動」的開發培訓,所以我花費了大量的時間來學習如何編寫測試用例,如何寫出可測試的代碼。這也是我寫這篇文章的初衷,我想把本身用 Swift 寫測試時摸索到的心得分享給你們,但願個人看法可以幫助你們節省學習時間,少走些彎路。前端
在這篇文章,咱們將會討論着手寫測試的入門知識:依賴注入。android
想象一下,你此時正在寫測試。 若是你的測試對象(被測系統)是和真實世界相連的,好比 Networking 和 CoreData,編寫測試代碼將會很是複雜。原則上講,咱們不但願咱們的測試代碼被客觀世界的事物所影響。被測系統不該依賴於其餘的複雜系統,這樣咱們纔可以保證在時間恆定和環境恆定條件下迅速完成測試。何況,保證咱們的測試代碼不會「污染」生產環境也是十分重要的。「污染」意味着什麼?意味着咱們的測試代碼將一些測試對象寫進了數據庫,提交了些測試數據到生產服務器等等。而避免這些狀況的發生就是 依賴注入 存在的意義。ios
讓咱們從一個例子開始。 假設你拿到個應該聯網而且在生產環境下才能被執行的類,聯網部分就被稱做該類的 依賴。如以前所言,當咱們執行測試時這個類的聯網部分必須可以被模擬的,或者假的環境所替換。換句話說,該類的依賴必須支持「可注入」,依賴注入使咱們的系統更加靈活。咱們可以爲生產代碼「注入」真實的網絡環境;與此同時,也可以「注入」模擬的網絡環境來讓咱們在不訪問互聯網的條件下運行測試代碼。git
譯者注:TL;DR 是 Too long;Don't read 的縮寫。在這裏的意思是篇幅較長,不想深刻研究,請直接看文章總結。github
在這篇文章,咱們將會討論:數據庫
開始動手吧! 如今咱們打算實現一個叫作 HttpClient 的類。這個 HttpClient 應該知足如下要求:swift
因此咱們對 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」 閉包將返回值回傳。api
HttpClient().get(url: url) { (success, response) in // Return data }
複製代碼
HttpClient 的用法。bash
這就是問題所在:咱們怎麼對它測試?咱們如何確保這些代碼達到上述的兩點要求?憑直覺,咱們能夠給 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 的狀況下運行。
如今讓咱們注意下第一點要求:
咱們想達到的效果是確保該 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
}
複製代碼
這個測試用例可表示爲:
咱們還須要寫斷言部分。
可是咱們怎麼知道 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)
}
複製代碼
咱們爲 lastURL 和 url 添加斷言,這樣就會得知注入後的 「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
! :) 很簡單,對不對?
經過這篇文章,咱們學到:
剛起步時,你必須花費大量時間來寫簡單的測試,並且測試代碼也是代碼,因此你仍須要保持測試代碼的簡潔和良好的架構。但編寫測試用例獲得的好處也是彌足珍貴的,代碼只有在恰當的測試後才能被擴展,測試幫你免於瑣碎 bug 的困擾。因此讓咱們一塊兒加油寫好測試吧!
全部的示例代碼都在 GitHub 上,代碼是以 Playground 的形式展現的,我還在上面添加了個額外的測試。 你能夠自由下載或 fork 這些代碼,而且歡迎任何反饋!
感謝閱讀個人文章 💚 。
感謝 Lisa Dziuba 和 Ahmed Sulaiman.
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。