歡迎來到真實世界 - Unit Test for Networking
在上班通勤的時候,最常做的事情就是打電動了。實在有點難在公車上面看書或做認真的事,請問除了高中生之外誰會在公車上認真做事的?那個奈米短的專注時間要思考任何事情都有困難。但是打Clash Royal就不一樣了,那是一款會讓你在毫秒之內進入精神時光屋的遊戲,只要開戰之後你就會到瞬間另外一個世界,直到戰鬥結束前你都不會回到現實,就算坐過站也是一樣。雖然一場只有兩分鐘,但對你來說每一場都像列寧格勒圍城戰一樣,都是漫長的持久戰,也像台灣的政治一樣,都是平行的時空。
如果想了完整的平行時空,就不能不提駭客任務(the Matrix),這部電影一直都是心目中最經典的電影之一,除了子彈時間這個讓人萬分驚豔的嘗試之外,許許多多的哲學問題在這部電影裡面都有簡單的著墨,是一部既大眾化又有點燒腦的好電影,下一部能夠相題並論的Cyberpunk商業電影,大概就只剩Inception了。
所以,對,在重要但無用的前情提要之後,就來進入到我們今天的主題了:歡迎來到真實世界!這將會是一連串的分享文,想要分享的是一個很多人(包括小弟我)都不願意提到的”那個議題”:Unit Test in iOS。嗯,很輕易就講出來了。
Welcome to the real world
一般來說,在iOS開發上,寫測試這件事不算是非常的普遍,所以相關的資源可能不像其它語言這麼多,小弟在還在玩沙的時候,就為了寫測試原地打轉了非常久,一直想要了解某些功能要怎樣測試、要怎樣設定情境,但又有點不知道怎樣下手。所以這一系列的文章會試著把小弟看過的資源跟理解的內容、還有實務上的運作整理出來,希望可以拋磚引玉,讓大家都能夠踏入這個殿堂之中。
因為是分享文,所以接下來並不會巨細瀰遺地把所有細節講出來,但我沒提到的都會是網路上已經非常常見、已經有教學的,所以請大家不用擔心,反之這些文章會以整個測試的脈絡為主,在觀念的釐清會多於語法的描述。
第一篇要來談的就是,關於寫測試,讓人最為困擾、也是整個測試101中一個非常重要的觀念:Depedency Injection。
在寫單元測試的時候,如果遇到你要測試的物件,是跟真實世界有介接的,像是network、database等等,就會變的比較不容易測試,因為你總不會希望每次跑測試,都需要連上網路,都需要接上資料庫並且寫入真實資料吧。寫測試有個大原則:不要仰賴任何真實環境,如果我們的測試需要仰賴真實環境,那我們就會遇到許多問題:
- 速度很慢
- 測試資料會汙染到真實環境
- 一但換個開發環境(沒網路等等)就無法開發了
所以最好的做法,是我們需要有一個虛擬的環境,讓我們的測試都跑在這個虛擬的環境之中,所有的請求都是直接接到這個假環境,而不是真實世界。這有點像在the Matrix裡面,我們只要抽換掉腦袋後面的插頭,就能夠把環境注入到我們運作中的腦袋之中,接下來我們做的事情,都跟另外一個世界沒有關係,在這個環境之中,因為它的一切物理特性都跟真實世界一樣,學到的東西也會是一樣的,所以我們可以在假環境中訓練,回到真實世界打鬥,也就是說,我們可以在測試環境中把code寫好,丟到真實世界中也能夠運作正常。
TL;DR
以下內容會提到:
- 怎麼利用Dependency Injection設計一個更好測試的物件
- 怎麼利用Protocol來製作mock物件
- 怎麼測試資料正確性跟行為正確性
Dependency Injection (DI)
Ok,回到我們即將要撰寫的程式上,我們現在要實做一個,能夠發出http get請求的HttpClient類別,這樣的類別,它可能需要滿足以下條件:
- 發出的request的URL要跟我們指定的一樣
- 要真的有發出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()
}
}
看起來這個程式,可以發出get request,並且把資料透過callback這個closure回傳,所以它可以這樣使用:
HttpClient().get(url: url) { (data, error) in
// Return data
}
問題來了,我們要怎樣測試它呢?我每次只要呼叫get(URL, completeClosure)這個method,它都會直接毫無懸念地連上網,並且義無反顧地上我指定的server拿資料,這樣不行!所以我們仔細地看一下這支程式,你會發現,直接連上網路的關鍵,在於那個萬惡的URLSession.shared這個singleton,只要它一直存在在這個程式裡面,我就每次都需要連上網路,並且抓資料下來,所以我們必需要來動手改造它,URLSession就是一種”環境”,我們要讓它是可以替換,可以被”注入”的。所以我們又咻咻咻地改寫了這個class:
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,也就是說,我們在創建HttpClient時,就需要把對應的環境”注入”這個物件之中,如果我們放了個假session,HttpClient還是會在這個假session之中打打殺殺,但就完全不會碰觸到真實世界的URLSession.shared了。所以我們的應用就變成了:
HttpClient(session: SomeURLSession() ).get(url: url) { (success, response) in
// Return data
}
未來在使用HttpClient時,都需要注意這邊有個URLSession的相依性,需要依照我們的使用情境來注入不一樣的session。
當我們把環境抽離之後,要寫測試就變得很容易了,依照我們的需求,我們需要寫兩隻簡單的單元測試,所以我們再度咻咻咻地寫出了以下的測試架構:
class HttpClientTests: XCTestCase {
var httpClient: HttpClient!
let session = MockURLSession()
override func setUp() {
super.setUp()
httpClient = HttpClient(session: session)
}
override func tearDown() {
super.tearDown()
}
}
這邊我們設定了一個session,我們希望知道我們的物件(HttpClient)跟這個假環境(session)的互動狀況,這個假環境通常被稱做Mock,目地就是要拿來了解我們製作中的物件,是不是有乖乖地執行某些method,或是有沒有做某些特定的行為。接下來,可以在setUp裡面看到,我們創了一個HttpClient,並且把這個MockSession注入了這個HttpClient之中,所以現在,這個HttpClient,已經離開母體,到了虛擬的世界了!接下來我們就可以放心地實作我們的規格,而不用擔心速度跟汙染資料的問題了。
Test data
好的,現在我們來看看我們的第一個目標:
- 發出的request的URL要跟我們指定的一樣
身為一個稱職的HttpClient,就需要能夠正確地發出request,不能亂動URL,所以我們咻咻咻地寫了一個簡單的get request到我們的測試之中:
func test_get_request_with_URL() {
guard let url = URL(string: "https://mockurl") else {
fatalError("URL can't be empty")
}
httpClient.get(url: url) { (success, response) in
// Return data
}
}
接續剛剛提到的,想知道我們的實作是不是有正確地做某些動作,我們需要對我們的mock物件動手腳,就上面這兩個case來說,我們需要的是:
mock物件要有個接口讓我們知道URLSession最後發出去的URL是甚麼
所以接下來,我們要進入我們的重頭戲:怎樣設計這個mock object。我們要做一個長得很像URLSession,也就是跟URLSession有一樣的method的物件,並且在我們mock的URLSession裡面,埋一些能夠記錄的變數,好讓我們知道我們的HttpClient是不是真的有call那些method。
一般我們要發出一個requesst,通常會這樣寫:
let task = session.dataTask(with: request) { (data, response, error) in
callback(data, error)
}
task.resume()
URLSession的dataTask()就是我們想mock的目標,所以我們先寫一個mock架構:
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
}
}
上面這就是基本的Mock架構,這是一個互動跟URLSession一樣的mock,能夠回傳dataTask,並且呼叫completionHandler作為成功的回應。在return這邊我們先留空白,因為dataTask跟Session有相依,這是另外一個我們需要mock的東西。回到上面的code,有一個接口跟URLSession一模一樣的datTask method,來跟我們的HttpClient做互動。我們的記錄,就埋在lastURL這個property裡面,所以一旦HttpClient的get()準備發出request,它就會呼叫這個datTask(),並且把最後發出去的URL存到lastURL這個變數裡面。
另一方面,我們的test case會寫成這樣:
func test_get_request_with_URL() {
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)
}
只要assert lastURL是不是有符合我們設定的url,就可以知道我們發出的get()是不是有正確地設定URL了。
在上面的mock實作中,有一個地方我們並沒有寫完,就是在return // dataTask
這邊,這個地方理論上要回傳一個URLSessionDataTask物件,但是這個物件需要從某個URLSession instance創建出來,因為我們的mock URLSession沒有這個功能,所以我們需要再mock一個URLSessionDataTask。
class MockURLSessionDataTask {
func resume() { }
}
這個mock,就只有一個功能,就是仿製dataTask的resume(),這樣在進到我們這個假環境之後,就會呼叫這個mock的resume,之後就可以在這邊做記錄,記錄resume是不是有正確地被呼叫,後面會再提到。
到目前為止,這些code都是compile不過的。人生就是這樣,你努力了一大圈,卻發現最後還是compile不過。但,先別對人生失望!這跟當魯蛇不一樣,compile失敗,只是一時的,是可以解決的(有沒有很正向思考!)。到目前為止,compile不過,都是因為我們雖然mock了這些東西,但對compiler來說,這些東西介面上還不能直接這樣用,我們需要利用protocol來讓compiler把真實環境跟測試環境都視為一樣的。回頭來看一下我們的HttpClient:
private let session: URLSession
這個private property我們設定成URLSession,但是我們製作的mock卻是MockURLSession,兩個類別不一樣,compiler會在呼叫的時候報錯:
class HttpClientTests: XCTestCase {
var httpClient: HttpClient!
let session = MockURLSession()
override func setUp() {
super.setUp()
httpClient = HttpClient(session: session) // 這邊會炸
}
override func tearDown() {
super.tearDown()
}
}
這時候,我們有幾種作法可以騙過compiler,一種是透過subclass,讓MockURLSession也是URLSession的subclass,在這邊我們不使用subclass,因為我們要mock的目標不是我們自己的物件(URLSession),如果用subclass,有可能會誤觸到我們測試定義以外的範圍。
另外一種方法,是透過protocol,讓URLSession跟MockURLSession都遵詢某種protocol,再修改
private let session: URLSession
成為
private let session: URLSessionProtocol
接下來只要讓URLSession跟MockURLSession都符合這個我們定義的URLSessionProtocol,就可以順利地compile了。因為HttpClient的dependency從原本的URLSession變成URLSessionProtocol,所以之後我們的mock不管怎樣修改,只要conform這個protocol,你就可以成為這個HttpClient的dependency,也就是說,只要行為模式一樣,就可以自由替換這個HttpClient執行的環境。
這個URLSessionProtocol,我們會這樣設計:
protocol URLSessionProtocol {
typealias DataTaskResult = (Data?, URLResponse?, Error?) -> Void
func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol
}
為了方便閱讀,我習慣幫closure加上typeaslias。
這邊我們只定義了一個需要conform的method:dataTask(NSURLRequest, DataTaskResult)
,因為目前我們的測試就只有需要這個。未來如果需要測試更多的東西,你就會需要在這邊定義更多的method,來讓test code能夠取用這些method。這個技巧,常應用在當我們mock不屬於我們的東西(core data, network等等)上。
順道一提(副本也太多),許多測試的原則都有提到,我們不能mock不屬於我們的東西(don’t mock things you don’t own),但為甚麼我們現在卻用了一堆篇幅(加上一堆廢話)在講如何mock不屬於我們的東西?在Test-Driven iOS Development with Swift 3這本書裡面有提到,don’t mock things you don’t own指的是,我不能去mock third party的任何東西,因為我們不能確保這些東西,未來在更新版本之後,是不是會規格會一樣、行為模式也一樣。要是規格或行為模式有變,我們的測試輕則不過,重則會過了但是其實有邏輯上的錯誤,這是系統設計上的雷區,所以我們不mock這些我們不能控制的東西,但first-party的東西,相對穩定,並且是所有人都有共識的,這些是你可以去mock的。
回到剛剛的protocol,還記得原本標準的URLSession裡面,dataTask()回傳的東西嗎?原本回傳的是一個URLSessionDataTask,這又是一個不屬於我們的method,所以我們也要動手mock它!是不是你的雙手已經開始不由自主地打字了?沒錯,就是你想的那樣,我們需要一個URLSessionDataTaskProtocol!
protocol URLSessionDataTaskProtocol {
func resume()
}
這個protocol更簡單,因為我們只會用到resume(),所以先定義它。
接下來,還有一個問題要解決,剛剛那兩個protocol,我們都直接套到我們的MockURLSession跟MockURLSessionDataTask上,並且我們也都乖乖地實作protocol所需要的method了,現在我們希望真實的URLSession跟URLSessionDataTask也符合這兩個protocol:
extension URLSession: URLSessionProtocol {}
extension URLSessionDataTask: URLSessionDataTaskProtocol {}
URLSessionDataTask沒甚麼大問題,它本來就有resume,並且長得一模一樣,所以直接套上這個protocol是ok的,但是就URLSession來說,原本的dataTask()回傳的是URLSessionDataTask,但我們新的dataTask()回傳的卻必須是URLSessionDataTaskProtocol,所以我們如果直接套上URLSessionProtocol,我們還是無法conform這個protocol,因為跟本就不存在這個method。We need to do something!
所以我們新增了一個method:
extension URLSession: URLSessionProtocol {
func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol {
return dataTask(with: request, completionHandler: completionHandler) as URLSessionDataTaskProtocol
}
}
這個func做的事情,單純就是原本dataTask的接口,從原本回傳URLSessionDataTask,改成回傳URLSessionDataTaskProtocol,只有定義上有變化,本質上是完全不變的,關鍵就在於利用as將做簡單型別轉換,但完全不影響物件本身,只是為了conform這個protocol而存在。
最後,再回到剛剛的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
}
}
那個return // dataTask
,是時候給它名份(?)了:
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
}
}
所以這個MockURLSession的dataTask回傳的,就是一個MockURLSessionDataTask(),並且它是可以執行resume()的。這時候,我們的測試就可以被執行並且帥帥地通過了!
好了,第一個測試到目前為止已經正式完畢,接下來我們要來看第二個測試了!
Test Behavior
我們的第二個測試條件是:
要真的有發出request
沒錯,我們希望我們的子弟兵們都要乖乖做事,不要沒做但卻跟我說做了。所以我們希望這些子弟兵在檢查藥室有無子彈時,都要大聲地喊出”無”,表示他們真的有檢查(例子怪怪的)(有人真的會檢查?)(藥室在那裡?)
好的,現在這個測試跟剛剛不一樣,剛剛lastURL要測的是資料是不是正確,而這個測試需要知道的是,某個method有沒有真的被呼叫到。我們想知道request有沒有真的被發出去,用宅宅的話來說,就是dataTask().resume()有沒有真的被呼叫,也就是說,我們只要在我們的假環境的resume裡面,做個簡單的記錄,就可以知道它有沒有被call過了。
我們先寫好我們的測試code:
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)
}
resumeWasCalled就是我們想測試的目標,如果它是true,就表示resume()真的有被執行,因為執行resume的是URLSessionDataTask,所以我們把resumeWasCalled設計在dataTask裡面,而這個dataTask,很剛好,也是我們的人!
所以我們可以輕易地在裡面多加一個property:
class MockURLSessionDataTask: URLSessionDataTaskProtocol {
private (set) var resumeWasCalled = false
func resume() {
resumeWasCalled = true
}
}
只要有人在這個Mock環境裡呼叫resume(),resumeWasCalled就會變成true,我們就可以在測試code裡面判斷resume是不是有被呼叫了!
是不是很簡單阿!(不是)
Recap
在上面的文章中,我們了解到了:
- 怎麼利用DI來抽換環境
- 利用protocol來確保各種環境的接口是一致的
- 怎樣做資料正確性的單元測試
- 怎樣做行為的單元測試
所有的程式都擺在Github上,是一個Playground,歡迎下載來玩玩看,這個Playground另外多實做了一個test,是驗證get是不是有正確地把資料透過callback回傳回來,可以看看它舉一反三!
寫測試要有個心理準備,就是需要兩倍以上的前期開發時間,並且它不是萬靈丹,你不會因為有了Unit Test,你就成為Bug-free man,就像你不會因為有了穩定的收入,就一定交得到女朋友一樣。但寫測試絕對是一件值得或者說需要被投入的事情,也是讓你的系統能夠永續的唯一關鍵。
最後,非常歡迎大家來幫我看一下這樣的測試邏輯是不是有問題,或者code有那邊可以加強的,沒有永遠正確的code,小弟非常樂意修改各種範例跟內容。有需要討論的地方也歡迎提出喔!
Bonus
最後最後,身為一個不是很強的工程師,希望自己能夠有更多機會了解怎樣架構程式,怎樣寫測試等等,小弟目前的想法是想找一些獨立開發者,共同維護一個簡單的project,並且互相code review,每次的commit都大概一兩個小時的量,然後一定要附上coverage 100%的test code,然後再review彼此的pull request。Project應該會是簡單的抓instagram圖的app之類的,不能賣錢的那種XD
有興趣的大大們,可以留下資料,可以找一天來kick off一下!XD
Happy coding!
Reference
這篇絕大多數的資料來自於
Mocking Classes You Don't Own
這篇語法大多是舊的,但概念是恆久不變的。
另外也參考了
Dependency Injection
文章非常巨細彌遺地列出了各種在iOS上的DI技巧,也包括了下一篇小弟會整理的Coredata Depedency Injection,值得一看(但文章真的很長XD)。
還有一本不錯的書
Test-Driven iOS Development with Swift
這本在amazon上買比較貴,可以去這裡買,小弟因為kindle太方便買的時候就沒有比價.....
HttpClient().get(url: url) { (success, response) in
這個地方是不是要改成 (data,error)?
的確是手誤,感謝指正!
Neo大~看完後歎為觀止~~收益良多 推推!!
感謝!過獎啦!希望大家都能一起交流!
這篇得花些時間才能消化,希望有一天我也可以寫出優質幽默的文章
那就一起學習吧!
Neo 大大豪厲害!開課啦!
小蛇我不厲害阿orz 騙吃騙喝