原文: 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項目。
BullsEye是iOS Apprentice中的一個簡單的app(一個游戲app——譯者);項目已經把游戲邏輯解耦到BullsEyeGame這個類了,并且加了另外一種游戲模式。
(運行BullsEye——譯者)在右下角,可以看到,有一個Segmented Control控件,可以讓使用者選擇游戲風格:
Slide模式,將slider滑動到盡可能靠近預先設定的目標值,
Type模式,猜測slider滑動條目前的值,將結果輸入頂部TextField中。
用戶選擇的游戲模式,app也會保存作為默認值(重啟app,默認游戲模式是使用者上次選擇的模式——譯者)
HalfTunes是NSURLsession 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...

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

可以看到模版文件導入了XCTest,定義了一個XCTestCase的子類:BullsEyeTests,還有setup(),tearDown()和一些example test 方法。
有三種跑測試的方法:
- 點擊菜單Product \ Test,或者快捷鍵Command-U。這種方式會將所有的test類都跑一邊。
- 點擊test navigator中的小箭頭按鈕。
- 點擊gutter中的菱形按鈕。(就是顯示代碼行數旁邊的按鈕——譯者)

通過點擊test navigator或者gutter中的按鈕,可以跑單獨一個測試方法。
試一下用上面不同的方法跑一下測試,直觀感受一下。因為現在這些測試什么都沒做,所以很快就跑完了。
所有測試跑完之后,菱形按鈕變成綠色,并呈現勾選狀態(tài)。點擊testPerformanceExample()方法下面的灰色菱形按鈕,打開Performance Result:

現在不需要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、when、then三部分,是一個好習慣:
- 在given部分,設置所需要的值:上面的例子,創(chuàng)建了一個
guess值,可以設定與targetValue的差異。 - 在when部分,執(zhí)行代碼進行測試:調用
gameUnderTest.check(_:)方法。 - 在then部分,assert(斷言)所期望的結果(在這個例子,
gameUnderTest.scoreRound是100 - 5),如果測試結果失敗,打印一條消息。
點擊菱形按鈕跑測試。app就會跑起來,菱形按鈕也會變成綠色勾選狀態(tài)。
Note:如果要看XCTestAssertions的完整列表,在代碼中按Command鍵同時點擊XCTAssertEqual 打開XCTestAssertions.h,或者到這里看: Apple’s Assertions Listed by Category.

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")
}
guess和targetValue之間相差還是5,所以score仍然是95。
打開breakpoint navigator,添加一個Test Failure Breakpoint;當測試方法發(fā)出失敗的assertion(斷言)時,測試就會停在這里。

把測試跑起來:測試失敗,應該會停在XCTAssertEqual這行。
打開debug console,檢查gameUnderTest和guess的值:

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

問題出在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中實際寫的一樣,下面這些是額外添加的:
-
expectation(_:)返回一個XCTestExpectation對象,并賦值保存為promise。通常也可以用expectation和future來命名。description參數描述了你期望發(fā)生的結果。 - 為了匹配
description,在異步方法回調成功時,調用promise.fulfill()。 -
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 中描述了幾種可行的方法。

來自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按鈕:

當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項目,在HalfTunesFakeTests,testPerformanceExample()用下面代碼代替:
// 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)計信息。

點擊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中勾選——譯者):

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

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

將鼠標懸停在updateSearchResults(_:)方法旁的藍色Coverage bar上,可以看到覆蓋率是71.88%。
點擊方法右邊的箭頭按鈕,打開這個方法的源文件,找到這個方法。鼠標懸停在右側邊欄的coverage annotations,這部分代碼就會高亮成綠色或者紅色。

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 的教程后,你可以胸有成竹地去測試所有東西。
這里是已經完成的項目。
下面是一些補充教程:
現在你可以為項目寫測試了,那么下一步當然就是自動化了:Continuous Integration(持續(xù)集成) 和 Continuous Delivery(持續(xù)交付)??梢詮腁pple Xcode Server和xcodebulid的Automating the Test Process開始了解,還有Wikipedia’s continuous delivery article這篇文章,借鑒了ThoughtWorks一文。
TDD in Swift Playgrounds 使用了
XCTestObservationCenter來在Playgrounds中跑XCTestCase單元測試。這樣可以在Playgrounds上開發(fā)和測試,然后再轉到app中。CMD+U Conference 中的文章Watch Apps: How Do We Test Them? ,演示了如何用 PivotalCoreKit來測試watchOS app。
如果已經寫好了app,但還沒有寫測試,可以參考 Working Effectively with Legacy Code by Michael Feathers一文,記住,沒有通過測試的代碼,不是好代碼。
Jon Reid的Quality Coding app archives,是一個學習更多關于 Test Driven Development的好地方。
如果有關于這個教程的任何疑問或建議,請加入論壇在下面進行討論。