1
Write a post

歡迎來到真實世界 - Unit Test for Networking

Published Jul 30, 2017Last updated Jul 31, 2017
歡迎來到真實世界 - 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等等,就會變的比較不容易測試,因為你總不會希望每次跑測試,都需要連上網路,都需要接上資料庫並且寫入真實資料吧。寫測試有個大原則:不要仰賴任何真實環境,如果我們的測試需要仰賴真實環境,那我們就會遇到許多問題:

  1. 速度很慢
  2. 測試資料會汙染到真實環境
  3. 一但換個開發環境(沒網路等等)就無法開發了

所以最好的做法,是我們需要有一個虛擬的環境,讓我們的測試都跑在這個虛擬的環境之中,所有的請求都是直接接到這個假環境,而不是真實世界。這有點像在the Matrix裡面,我們只要抽換掉腦袋後面的插頭,就能夠把環境注入到我們運作中的腦袋之中,接下來我們做的事情,都跟另外一個世界沒有關係,在這個環境之中,因為它的一切物理特性都跟真實世界一樣,學到的東西也會是一樣的,所以我們可以在假環境中訓練,回到真實世界打鬥,也就是說,我們可以在測試環境中把code寫好,丟到真實世界中也能夠運作正常。

TL;DR

以下內容會提到:

  1. 怎麼利用Dependency Injection設計一個更好測試的物件
  2. 怎麼利用Protocol來製作mock物件
  3. 怎麼測試資料正確性跟行為正確性

Dependency Injection (DI)

Ok,回到我們即將要撰寫的程式上,我們現在要實做一個,能夠發出http get請求的HttpClient類別,這樣的類別,它可能需要滿足以下條件:

  1. 發出的request的URL要跟我們指定的一樣
  2. 要真的有發出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

好的,現在我們來看看我們的第一個目標:

  1. 發出的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,很剛好,也是我們的人!

5j6gZzXMLXkhdpkC68CU2l.jpg

所以我們可以輕易地在裡面多加一個property:

  class MockURLSessionDataTask: URLSessionDataTaskProtocol {
    private (set) var resumeWasCalled = false
  
    func resume() {
      resumeWasCalled = true
    }
  }

只要有人在這個Mock環境裡呼叫resume(),resumeWasCalled就會變成true,我們就可以在測試code裡面判斷resume是不是有被呼叫了!

是不是很簡單阿!(不是)

Recap

在上面的文章中,我們了解到了:

  1. 怎麼利用DI來抽換環境
  2. 利用protocol來確保各種環境的接口是一致的
  3. 怎樣做資料正確性的單元測試
  4. 怎樣做行為的單元測試

所有的程式都擺在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太方便買的時候就沒有比價.....

Discover and read more posts from ShihTing Huang (Neo)
get started
Enjoy this post?

Leave a like and comment for ShihTing

3
8
8Replies
vanness wu
21 days ago

HttpClient().get(url: url) { (success, response) in
這個地方是不是要改成 (data,error)?

ShihTing Huang (Neo)
21 days ago

的確是手誤,感謝指正!

陳小智
22 days ago

Neo大~看完後歎為觀止~~收益良多 推推!!

ShihTing Huang (Neo)
22 days ago

感謝!過獎啦!希望大家都能一起交流!

陳小智
22 days ago

這篇得花些時間才能消化,希望有一天我也可以寫出優質幽默的文章

ShihTing Huang (Neo)
22 days ago

那就一起學習吧!

Jerry Hsu
22 days ago

Neo 大大豪厲害!開課啦!

ShihTing Huang (Neo)
22 days ago

小蛇我不厲害阿orz 騙吃騙喝

Show more replies

Get curated posts in your inbox

Learn programming by reading more posts like this