譯文: iOS Unit Testing and UI Testing Tutorial

原文: iOS Unit Testing and UI Testing Tutorial,作者:Audrey Tam。更新于2017年3月13日。以下為正文:

本教程講解如何往iOS apps中添加「單元測試/unit tests」、「UI測試/UI tests」,以及如何檢查「代碼的覆蓋率/code coverage」。

Version:

Swift 3,iOS 10,Xcode 8

很多開發(fā)者覺得寫測試沒什么卵用,但是,如果沒有「測試」,你原本牛逼閃閃的app,很容易變成一坨翔,所以,「測試」是必不可少的。如果你正在看這篇教程,那么恭喜您,你是一個有追求的人,一個脫離了低級趣味的人(該處譯者自由發(fā)揮),您起碼知道應該要寫測試了,只是暫時還不知道怎么寫而已。

或者你有正在開發(fā)的app,但還沒寫測試,你希望可以在擴展app的時候,對修改的部分進行測試。也可能你已經寫了一部分測試,但不確定寫得對不對。又或者你隨時想對正在開發(fā)的app進行測試。

這篇教程,演示了如何利用Xcode的test navigator來測試app的「模型/model」和「異步方法/asynchronous methods」;如何利用stubs、mocks模擬和library、system進行交互;如何測試UI、性能;以及如何使用「代碼覆蓋工具/code coverage tool」。學習過程中,會接觸到一些裝逼術語,學習完本寶典后,你就是大神了!

Testing, Testing...

What to Test?/測什么?

開始寫測試之前,有一件非常重要的事情:究竟要測什么?如果目的是擴展(修改)現有的app,那么首先要為即將要修改的部分寫測試。

測試通常包括:

  • 核心功能/Core functionality:模型類和方法,以及他們和控制器的交互
  • 常見的UI工作流
  • 邊界條件/Boundary conditions
  • Bug修復

First Things FIRST: Best Practices for Testing

FIRST是「Fast,Independent,Repeatable,Self-validating,Timely」的縮寫,描述了一套有效、簡明的單元測試標準:

  • Fast/高效:你寫的測試可以很快完成——只有這樣大家才不介意去跑測試代碼。
  • Independent/Isolated:測試不應該彼此依賴、拆解
  • Repeatable:每次跑測試,得到的結果都應該一致。當然,外部數據和并發(fā)問題(concurrency issues)可能偶爾導致測試結果會不一樣。
  • Self-validating:測試應完全自動化;測試結果應該是「pass」或者「fail」,而不需要程序員從一堆日志(log)文件中推測測試結果。
  • Timely:理想情況下,在寫生產代碼前,就應該寫好測試。(譯者:這里說的是理想情況下,所以有很多情況也是先寫功能,再寫測試的?)

遵循FIRST原則,可以保證寫出來的測試簡單、實用,不至于成為你的累贅。

Getting Started

下載、解壓、打開starter projects 中的BullsEye 和 HalfTune項目。

BullsEyeiOS Apprentice中的一個簡單的app(一個游戲app——譯者);項目已經把游戲邏輯解耦到BullsEyeGame這個類了,并且加了另外一種游戲模式。

(運行BullsEye——譯者)在右下角,可以看到,有一個Segmented Control控件,可以讓使用者選擇游戲風格:

  • Slide模式,將slider滑動到盡可能靠近預先設定的目標值,

  • Type模式,猜測slider滑動條目前的值,將結果輸入頂部TextField中。

用戶選擇的游戲模式,app也會保存作為默認值(重啟app,默認游戲模式是使用者上次選擇的模式——譯者)

HalfTunesNSURLsession Tutorial中的一個app,更新到Swift 3了(事實上已經更新到Swift 4了——譯者)。在這個app,調用了iTunes 的API來查詢歌曲,還可以下載、播放歌曲的片段。

坐穩(wěn)了,開車!

Unit Testing in Xcode

創(chuàng)建一個Unit Test Target

Xcode Test Navigator提供了使用測試的簡便方式;下面會利用它來創(chuàng)建test target,并且把測試跑起來。

打開BullsEye,按快捷鍵Command-6,打開test navigator。

點擊左下角的+按鈕,選擇菜單中的New Unit Test Target...

image

使用默認的名字:BullsEyeTests??吹?em>test bundle時,點擊打開。如果BullsEyeTest沒有出現,單擊切換到其他navigators,再返回test navagator。

image

可以看到模版文件導入了XCTest,定義了一個XCTestCase的子類:BullsEyeTests,還有setup(),tearDown()和一些example test 方法。

有三種跑測試的方法:

  1. 點擊菜單Product \ Test,或者快捷鍵Command-U。這種方式會將所有的test類都跑一邊。
  2. 點擊test navigator中的小箭頭按鈕。
  3. 點擊gutter中的菱形按鈕。(就是顯示代碼行數旁邊的按鈕——譯者)
image

通過點擊test navigator或者gutter中的按鈕,可以跑單獨一個測試方法。

試一下用上面不同的方法跑一下測試,直觀感受一下。因為現在這些測試什么都沒做,所以很快就跑完了。

所有測試跑完之后,菱形按鈕變成綠色,并呈現勾選狀態(tài)。點擊testPerformanceExample()方法下面的灰色菱形按鈕,打開Performance Result:

image

現在不需要testPerformanceExample()這個方法,暫時刪掉。

用XCTAssert測試Models

首先,下面要用XCTAssert 來測試BullsEye model的核心功能:BullEyeGame對象計算的分數是否正確?

來到BullsEyeTests.swift,在import語句下,添加如下代碼:

@testable import BullsEye

這句代碼給了unit test 權限訪問BullsEye中的類、方法。

BullsEyesTests類的頂部,添加以下屬性:

var gameUnderTest: BullsEyeGame!

setup()方法內,super.setUp() 之后,創(chuàng)建一個新的BullsEyeGame對象:

gameUnderTest = BullsEyeGame()
gameUnderTest.startNewGame()

上面創(chuàng)建了一個class 層級的SUT(System Under Test)對象,所以在這個測試類里的所有測試都可以訪問SUT對象里的屬性和方法。

也可以調用startNewGame方法——這個方法創(chuàng)建targetValue。很多測試會用到targetValue——用來測試游戲是否正確計算「分數」。

tearDown()方法內要釋放(release) SUT對象。

gameUnderTest = nil

注意:setup()創(chuàng)建、在tearDown()釋放 SUT對象,是一個好習慣。可以確保每個測試都是在干凈的環(huán)境中進行。更多資料,可以查看Jon Reid 關于此主題的帖子

現在開始寫第一個測試!

用以下代碼替換整個testExample()

// XCTAssert to test model
func testScoreIsComputed() {
  // 1. given
  let guess = gameUnderTest.targetValue + 5
  
  // 2. when
  _ = gameUnderTest.check(guess: guess)
  
  // 3. then
  XCTAssertEqual(gameUnderTest.scoreRound, 95, "Score computed from guess is wrong")
}

測試方法的方法名一般都以test開頭,后面跟的是要測什么東西。

把測試分解成given、whenthen三部分,是一個好習慣:

  1. given部分,設置所需要的值:上面的例子,創(chuàng)建了一個guess值,可以設定與targetValue的差異。
  2. when部分,執(zhí)行代碼進行測試:調用gameUnderTest.check(_:)方法。
  3. then部分,assert(斷言)所期望的結果(在這個例子,gameUnderTest.scoreRound是100 - 5),如果測試結果失敗,打印一條消息。

點擊菱形按鈕跑測試。app就會跑起來,菱形按鈕也會變成綠色勾選狀態(tài)。

Note:如果要看XCTestAssertions的完整列表,在代碼中按Command鍵同時點擊XCTAssertEqual 打開XCTestAssertions.h,或者到這里看: Apple’s Assertions Listed by Category.

image

Note:Given-When-Then 結構起源于Behavior Driven Development(BDD/行為驅動開發(fā)),而Given-When-Then 這個名字更通俗易懂。也可以用Arrange-Act-Assert,或者Assembl-Activate-Assert。

Debugging a Test

我們在BullsEyeGame故意內置了一個bug,現在就來找一下這個bug。

testScoreIsComputed重命名為testScoreIsComputedWhenGuessGTTarget ,然后再復制-粘貼一個,創(chuàng)建testScoreIsComputedWhenGuessLTTarget。

testScoreIsComputedWhenGuessLTTarget()這個測試中,given部分的targetValue減去5。其他保持不變。

func testScoreIsComputedWhenGuessLTTarget() {
  // 1. given
  let guess = gameUnderTest.targetValue - 5
  
  // 2. when
  _ = gameUnderTest.check(guess: guess)
  
  // 3. then
  XCTAssertEqual(gameUnderTest.scoreRound, 95, "Score computed from guess is wrong")
}

guesstargetValue之間相差還是5,所以score仍然是95。

打開breakpoint navigator,添加一個Test Failure Breakpoint;當測試方法發(fā)出失敗的assertion(斷言)時,測試就會停在這里。

image

把測試跑起來:測試失敗,應該會停在XCTAssertEqual這行。

打開debug console,檢查gameUnderTestguess的值:

image

guess的值是targetValue - 5 ,但是scoreRound是105,并不是期待中的95!

為了進一步找到問題點,使用平常的debug方式:在when語句中設置斷點,在BullsEyeGame.swift中的check(_:)方法內,創(chuàng)建difference的地方也設置一個斷點。然后再跑一次,逐步執(zhí)行,來到let difference語句,查看difference的值:

image

問題出在difference的值是負數,所以score的值變成100 - (-5);可以對diffenecne取絕對值來修復這個問題。在check(_:)方法中,取消注釋正確的那行,并刪除有問題的那行。

刪掉兩個斷點,再重新跑測試,這次沒有問題了。

用XCTestExpectation測試異步操作

上面已經學會如何測試models,如何在測試失敗時debug,現在繼續(xù)學習使用XCTestExpectation來測試網絡操作(network operations)。

打開HalfTunes項目:這個app用URLSession來查詢iTunes API 并下載歌曲片段。假設你要改成用AlamoFire來進行網絡操作。要確認這個改寫過程是否有紕漏,應該寫測試來驗證這些修改的代碼,在修改前、修改后都要跑測試。

URLSession方法是異步的:馬上返回,但要等一段時間才真正完成。要測試異步方法,可以用XCTestExpectation,它可以讓測試等到異步操作完成。

異步測試一般比較慢,所要要和unit tests 分開。

也是在+號菜單中選擇New Unit Test Target…并命名為HalfTunesSlowTests。在import下面導入HalfTunes app:

@testable import HalfTunes

這個類中的測試會用默認session向蘋果服務器發(fā)送請求,聲明sessionUnderTest變量,并在setup()中創(chuàng)建該對象、在tearDown():中釋放:

var sessionUnderTest: URLSession!

override func setUp() {
  super.setUp()
  sessionUnderTest = URLSession(configuration: URLSessionConfiguration.default)
}

override func tearDown() {
  sessionUnderTest = nil
  super.tearDown()
}

用下面異步測試的代碼替換原來的testExample()

// Asynchronous test: success fast, failure slow
func testValidCallToiTunesGetsHTTPStatusCode200() {
  // given
  let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
  // 1
  let promise = expectation(description: "Status code: 200")
  
  // when
  let dataTask = sessionUnderTest.dataTask(with: url!) { data, response, error in
    // then
    if let error = error {
      XCTFail("Error: \(error.localizedDescription)")
      return
    } else if let statusCode = (response as? HTTPURLResponse)?.statusCode {
      if statusCode == 200 {
        // 2
        promise.fulfill()
      } else {
        XCTFail("Status code: \(statusCode)")
      }
    }
  }
  dataTask.resume()
  // 3
  waitForExpectations(timeout: 5, handler: nil)
}

這個測試檢查發(fā)送有效查詢到iTunes,返回200狀態(tài)碼的情況。大多數測試代碼和在app中實際寫的一樣,下面這些是額外添加的:

  1. expectation(_:)返回一個XCTestExpectation對象,并賦值保存為promise。通常也可以用expectationfuture來命名。description參數描述了你期望發(fā)生的結果。
  2. 為了匹配description,在異步方法回調成功時,調用promise.fulfill()。
  3. waitForExpectations(_:handler:)確保測試一直運行,直到達成所有期望的結果(expectations),或者 timeout 超時結束,以先觸發(fā)者為準。

把測試跑起來,如果是連著網絡的,app在模擬器加載后,測試大概幾秒就能完成。

Fail Faster

測試失敗大家都不愿意看到,不過不可能百分百保證每次測試都能通過。下面介紹快速識別測試是否失敗,省下來的時間,就可以刷抖音刷朋友圈了:]

為了模擬測試失敗,刪除URL中「itunes」中的「s」:

let url = URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba")

再跑一次:如我們所愿,測試失敗了,但是它跑完timeout的時間(5秒——譯者)才提示失敗!這是因為我們之前寫的代碼,要等到「請求」成功后,才會調用promise.fulfill()。不過這次的「請求」是失敗的,所以只能等timeout超時后才能結束測試。

通過修改expectation,可以讓「測試失敗」的結果更早呈現:原來需要等到「請求」成功,現在只需等到異步方法回調即可(無論回調成功或錯誤——譯者)。換言之,一旦app收到服務器的響應(無論是OK 或者error),就可以提示開發(fā)者了。在這之后,再進一步確認「請求」是否成功。

為了了解其中的工作原理,再創(chuàng)建一個測試。首先,把之前URL刪除的「s」補回來,然后在類中添加如下測試:

// Asynchronous test: faster fail
func testCallToiTunesCompletes() {
  // given
  let url = URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba")
  // 1
  let promise = expectation(description: "Completion handler invoked")
  var statusCode: Int?
  var responseError: Error?
  
  // when
  let dataTask = sessionUnderTest.dataTask(with: url!) { data, response, error in
    statusCode = (response as? HTTPURLResponse)?.statusCode
    responseError = error
    // 2
    promise.fulfill()
  }
  dataTask.resume()
  // 3
  waitForExpectations(timeout: 5, handler: nil)
  
  // then
  XCTAssertNil(responseError)
  XCTAssertEqual(statusCode, 200)
}

這里的關鍵,就是在異步方法回調后,馬上執(zhí)行promise.fulfill(),這只耗費很少時間。如果「請求」失敗,then中的assertions(斷言)會拋出失敗。

再跑一次測試,現在就會馬上顯示測試失敗了,這是因為「請求(request)」失敗了,而不是因為timeout超時導致失敗。

修復url,重新跑測試,確認現在測試能通過。

Faking Objects and Interactions

異步測試給了你信心——你的代碼會生成正確的輸入(input)給異步的API(比如AlamoFire——譯者)。你可能還需要測試當接收到URLSession的輸入時,你的代碼是否可以正確工作,又或者當UserDefaults、CloudKit更新時,是否還能正常工作。

很多apps和系統(tǒng)(system)或者庫(library)的對象交互(interact)——這些對象是不在你掌控之下的——要測試這些交互會很慢而且不可重復(unrepeatable),這違反了FIRST的兩條原則(第一、第三條——譯者)。為了避免此類問題,可以偽造交互獲得輸入(input)——通過從stubs,或者更新mock對象。(就是喂假數據——譯者)

當你的代碼依賴到系統(tǒng)或庫對象,就可以用這種偽造的方式——創(chuàng)建一個假對象喂入數據來進行這一部分的測試。Dependency Injection by Jon Reid 中描述了幾種可行的方法。

image

來自Stub的假數據

接下來的測試,會檢查updateSearchResults(_:)方法是否正確地解析了下載到的數據,檢查searchResults.count是否正確。SUT對象是view controller,這里會利用stubs和一些預先準備好的數據偽造session。

+菜單中選擇New Unit Test Target…,命名為HalfTunesFakeTests。在import語句下面,導入HalfTunes app:

@testable import HalfTunes

聲明SUT對象,并在setup()中創(chuàng)建、在tearDown()中釋放:

var controllerUnderTest: SearchViewController!

override func setUp() {
  super.setUp()
  controllerUnderTest = UIStoryboard(name: "Main", 
      bundle: nil).instantiateInitialViewController() as! SearchViewController!
}

override func tearDown() {
  controllerUnderTest = nil
  super.tearDown()
}

Note:這里的SUT是view controller,因為HalfTunes項目有一個很大的問題——現在所有事情都在SearchViewController.swift完成。在Moving the networking code into separate modults 中會解決這個問題,并且讓測試工作得到簡化。(應該是說要將網絡請求這部分功能解耦出來——譯者)

接下來,偽造的session需要一些簡單的JSON數據,以喂給測試。因為只需要幾組數據,所以在URL字符串后面拼接上&limit=3來進行限制:

https://itunes.apple.com/search?media=music&entity=song&term=abba&limit=3

將這個URL復制粘貼到瀏覽器中。會下載到一個名為1.txt或類似的文件。打開確認這是一個JSON文件,然后重命名為abbaData.json,最后把它拖到HalfTunesFakeTests組中。

Supporting Files中已經有一個叫做DHURLSessionMock.swift的文件。這個文件定義了一個簡單的協(xié)議DHURLSession,里面有方法(stubs)可以創(chuàng)建一個基于URL或者URLRequest的data task。也定義了遵守該協(xié)議的URLSessionMock類,可以讓你基于選擇的數據、response和error創(chuàng)建一個mock 類型的 URLSesison對象。

接下來設置假資料和response,并在setup()中創(chuàng)建偽造的session對象(在創(chuàng)建STU下面):

let testBundle = Bundle(for: type(of: self))
let path = testBundle.path(forResource: "abbaData", ofType: "json")
let data = try? Data(contentsOf: URL(fileURLWithPath: path!), options: .alwaysMapped)

let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
let urlResponse = HTTPURLResponse(url: url!, statusCode: 200, httpVersion: nil, headerFields: nil)

let sessionMock = URLSessionMock(data: data, response: urlResponse, error: nil)

setup()方法的最后,把偽造的session當作SUT的屬性注入(inject)到app中:

controllerUnderTest.defaultSession = sessionMock

Note:在測試中會直接使用偽造的session,這里只是展示如何注入,后續(xù)就可以調用SUT方法,使用view controller的defalutSession屬性。

現在就可以寫測試確認updateSearchResult(_:)方法是否能正確解析假數據。用以下代碼替換testExample()

// Fake URLSession with DHURLSession protocol and stubs
func test_UpdateSearchResults_ParsesData() {
  // given
  let promise = expectation(description: "Status code: 200")
  
  // when
  XCTAssertEqual(controllerUnderTest?.searchResults.count, 0, "searchResults should be empty before the data task runs")
  let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
  let dataTask = controllerUnderTest?.defaultSession.dataTask(with: url!) {
    data, response, error in
    // if HTTP request is successful, call updateSearchResults(_:) which parses the response data into Tracks
    if let error = error {
      print(error.localizedDescription)
    } else if let httpResponse = response as? HTTPURLResponse {
      if httpResponse.statusCode == 200 {
        promise.fulfill()
        self.controllerUnderTest?.updateSearchResults(data)
      }
    }
  }
  dataTask?.resume()
  waitForExpectations(timeout: 5, handler: nil)
  
  // then
  XCTAssertEqual(controllerUnderTest?.searchResults.count, 3, "Didn't parse 3 items from fake response")
}

這里還是必須寫成異步測試,因為stub是假設為異步方法的。

when的斷言是「searchResults should be empty before the data task runs」——這是很明顯的,因為我們在setup()中創(chuàng)建的是一個全新的SUT。

假數據包含了三個Track對象的JSON數據,所以then的斷言是「the view controller’s searchResults array contains three items」。

測試跑起來。應該很快就跑完了,因為這不是真正和服務器交互。

Fake Update to Mock Object

上面的測試,利用stub從假對象中提供假資料(input)。接下來,會用mock對象測試你的代碼是否能正確更新UserDefaults。

重新打開BullsEye項目。這個app有兩種游戲模式:使用者移動slider接近目標值,或者通過slider的位置猜測目標值。右下角的segmented control用于切換游戲模式,并且更新gameStyle這個使用者默認選項。

下一個測試就是檢查app是否正確更新了gameStyle這個默認值。

在test navigator,點擊New Unit Test Target…,命名為BullsEyeMockTests,在import后添加如下代碼:

@testable import BullsEye

class MockUserDefaults: UserDefaults {
  var gameStyleChanged = 0
  override func set(_ value: Int, forKey defaultName: String) {
    if defaultName == "gameStyle" {
      gameStyleChanged += 1
    }
  }
}

MockUserDefaults重寫了set(_:forKey)方法,記錄了gameStyleChanged更改次數。在類似的測試中也會設置一個Bool變量,不過這里用一個Int記錄次數更具彈性——比如,測試可以精確地記錄方法的每次調用。

BullsEyeMockTests中聲明SUT和mock:

var controllerUnderTest: ViewController!
var mockUserDefaults: MockUserDefaults!

setup()方法中,創(chuàng)建一個SUT和mock對象,然后注入mock對象——作為SUT的屬性:

controllerUnderTest = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as! ViewController!
mockUserDefaults = MockUserDefaults(suiteName: "testing")!
controllerUnderTest.defaults = mockUserDefaults

tearDown()中釋放SUT和mock對象:

controllerUnderTest = nil
mockUserDefaults = nil

用如下代碼替換testExample()

// Mock to test interaction with UserDefaults
func testGameStyleCanBeChanged() {
  // given
  let segmentedControl = UISegmentedControl()
  
  // when
  XCTAssertEqual(mockUserDefaults.gameStyleChanged, 0, "gameStyleChanged should be 0 before sendActions")
  segmentedControl.addTarget(controllerUnderTest, 
      action: #selector(ViewController.chooseGameStyle(_:)), for: .valueChanged)
  segmentedControl.sendActions(for: .valueChanged)
  
  // then
  XCTAssertEqual(mockUserDefaults.gameStyleChanged, 1, "gameStyle user default wasn't changed")
}

When的assertion(斷言)是「gameStyleChanged should be 0 before sendActions」,因此在「點擊」segmented control之前,gameStyleChanged是0。所以如果then assertion(斷言)還是true的話,表示 set(_:forKey:) 方法只被調用了一次。

測試跑起來;正常來說是沒問題的。

UI Testing in Xcode

Xcode 7開始有了UI 測試,可以創(chuàng)建一個「UI 測試」記錄和UI的交互?!窾I測試」的工作原理——查詢app的UI對象、合成事件,然后將他們發(fā)送到這些對象。這個API允許開發(fā)者仔細檢查UI對象的屬性、狀態(tài),以便將他們與預期狀態(tài)進行比較。

BullsEye項目的test navigator,添加一個新的UI Test Target。檢查確認Target to be Tested選擇的是BullsEye,然后用默認的名稱BullsEyeUITests。

BullsEyeUITests類的頂部添加一個屬性:

var app: XCUIApplication!

setup()中,用如下代碼替換XCUIApplication().launch()

app = XCUIApplication()
app.launch()

testExample()的名字改為testGameStyleSwitch()。

testGameStyleSwitch()中另起一行,然后點擊deitor窗口底部的紅色Record按鈕:

image

當app出現在模擬器后,點擊游戲模式切換開關的Slider segment,還有頂部的label。然后點擊Xcode Record按鈕停止記錄。

現在testGameStyleSwitch()中就會有如下三行代碼:(根據游戲模式的不同,Text文本內容有所不同——譯者)

let app = XCUIApplication()
app.buttons["Slide"].tap()
app.staticTexts["Get as close as you can to: "].tap()

如果還出現了其他代碼,把其他代碼刪掉。

第一行復制了在setup()中創(chuàng)建的屬性,后面不需要點擊任何東西了,所以刪除第一行,還有第二行、第三行后面的.tap()

打開["Slide"]右邊的一個小下拉菜單,選擇segmentedControls.buttons["Slide"]

現在的代碼變成這樣:

app.segmentedControls.buttons["Slide"]
app.staticTexts["Get as close as you can to: "]

修改一下,創(chuàng)建一個given,如下:

// given
let slideButton = app.segmentedControls.buttons["Slide"]
let typeButton = app.segmentedControls.buttons["Type"]
let slideLabel = app.staticTexts["Get as close as you can to: "]
let typeLabel = app.staticTexts["Guess where the slider is: "]

現在兩個按鈕和兩個頂部labels都有名稱了,繼續(xù)添加如下代碼:

// then
if slideButton.isSelected {
  XCTAssertTrue(slideLabel.exists)
  XCTAssertFalse(typeLabel.exists)
  
  typeButton.tap()
  XCTAssertTrue(typeLabel.exists)
  XCTAssertFalse(slideLabel.exists)
} else if typeButton.isSelected {
  XCTAssertTrue(typeLabel.exists)
  XCTAssertFalse(slideLabel.exists)
  
  slideButton.tap()
  XCTAssertTrue(slideLabel.exists)
  XCTAssertFalse(typeLabel.exists)
}

這個測試,檢查點擊、選擇了不同按鈕之后,label是否正確存在(顯示)。把測試跑起來,應該可以看到所有斷言(assertions)都成功了。

性能測試

蘋果官方文檔是這樣定義的:性能測試,會將需要測試的代碼塊運行十次,收集平均執(zhí)行時間和運行的標準偏差(standard deviation for the runs)。有了這個平均值,就可以以此值為基準,進行性能評估。

寫性能測試很簡單:只需要把需要測試的代碼放到measure()方法的閉包(closure)中。

重新打開HalfTunes項目,在HalfTunesFakeTeststestPerformanceExample()用下面代碼代替:

// Performance 
func test_StartDownload_Performance() {
  let track = Track(name: "Waterloo", artist: "ABBA", 
      previewUrl: "http://a821.phobos.apple.com/us/r30/Music/d7/ba/ce/mzm.vsyjlsff.aac.p.m4a")
  measure {
    self.controllerUnderTest?.startDownload(track)
  }
}

跑起來,然后點擊出現在measure()閉包尾部的圖標,查看統(tǒng)計信息。

image

點擊Set BaseLine,再次執(zhí)行performance test——結果可能比baseline更好或者更差。Edit按鈕可以將最新的值重設為baseline。

每臺設備的configuration都保存了baseline相關信息,因此可以在不同的設備上執(zhí)行相同的測試,不同設備的處理器速度、內存等各不相同,它們會維護不同的baseline。

App的每次修改,都有可能影響到性能,可以再次運行性能測試,和baseline比較一下。

Code Coverage

Code coverage工具,可以幫忙檢查哪些代碼已經跑過測試,哪些代碼還沒測試。

Note:當code coverage打開時,是否應該跑性能測試?蘋果官方文檔時這樣說的:Code coverage 數據收集會導致性能的損耗……以線性方式影響代碼的執(zhí)行,因此code coverage啟用時,對性能影響還是可以接受的( performance results remain comparable from test run to test run when it is enabled)。但是,當你需要精確評估性能的時候,應該考慮是否在測試中啟用code coveage。

要啟用code coverage,編輯scheme的Test,并勾選Code Coverage復選框(Xcode 9 是在Options中勾選——譯者):

image

把所有測試都跑起來(Command-U),然后打開reports navigator(Command-8)。選擇By Time,選中列表中最上面一個,再選擇Coverage這個tab(Xcode 9 點擊左邊的{} Coverage):

image

點擊SearchViewController.swift左邊的三角形,查看方法列表:

image

將鼠標懸停在updateSearchResults(_:)方法旁的藍色Coverage bar上,可以看到覆蓋率是71.88%。

點擊方法右邊的箭頭按鈕,打開這個方法的源文件,找到這個方法。鼠標懸停在右側邊欄的coverage annotations,這部分代碼就會高亮成綠色或者紅色。

image

coverage annotations還顯示了每部分代碼在一次測試中的執(zhí)行次數;沒有被執(zhí)行的部分高亮為紅色。如你所愿,for循環(huán)跑了3次,而錯誤的分支,沒有被執(zhí)行。如果要提高這個方法的覆蓋率,可以復制一份abbaData.json,修改其中的內容,就可以導致不同的錯誤——比如,將把key "results"改為"result",跑測試的時候,就會執(zhí)行print("Results key not found in dictionary")這個分支。

100% Coverage?

應該追求100%的代碼覆蓋率嗎?搜索一下「100% unit test coverage」,網上有一大波爭論和相反意見,以及關于「100% unit test coverage」定義本身的爭論。反對派說最后的10-15%是不值得去測試的。贊成派認為正因為這部分難于測試,所以最后的10-15%是非常重要的。搜索一下「hard to unit test bad design」,可以找到有說服力的論據—— untestable code is a sign of deeper design problems——難以(不能)測試的代碼,往往意味著這個設計本身是有問題的。如果再深究的話,將會延伸到Test Driven Development(測試驅動開發(fā))。

Where to Go From Here

到此為止,我們可以利用很多有用的工具為項目進行測試了。希望看完這個關于iOS Unit Testing 和 UI Testing 的教程后,你可以胸有成竹地去測試所有東西。

這里是已經完成的項目。

下面是一些補充教程:

如果有關于這個教程的任何疑問或建議,請加入論壇在下面進行討論。

?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,724評論 25 709
  • Spring Cloud為開發(fā)人員提供了快速構建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務發(fā)現,斷路器,智...
    卡卡羅2017閱讀 136,502評論 19 139
  • 單元測試不是一個小工程,需要多用些時間才能做好,不要希望通過這個文章就能掌握單元測試,這只是一個入門,需要自己動手...
    勇不言棄92閱讀 8,087評論 9 60
  • 黎洛當時給夏達打了那通電話,電話里黎洛告訴夏達自己這么做的目的像黎陽一樣:是為了給父親報仇。 就要開學了,夏達要乘...
    種麥子閱讀 373評論 0 1
  • 中途休息,教官帶領大家簡單參觀了監(jiān)獄。 金水是一個對幾何圖形非常不敏感的人,轉了半天也沒弄清楚這座大院的具體結構,...
    甘醇閱讀 672評論 6 0

友情鏈接更多精彩內容