原文: 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ā)揮),您起碼知道應(yīng)該要寫測試了,只是暫時還不知道怎么寫而已。
或者你有正在開發(fā)的app,但還沒寫測試,你希望可以在擴展app的時候,對修改的部分進行測試。也可能你已經(jīng)寫了一部分測試,但不確定寫得對不對。又或者你隨時想對正在開發(fā)的app進行測試。
這篇教程,演示了如何利用Xcode的test navigator來測試app的「模型/model」和「異步方法/asynchronous methods」;如何利用stubs、mocks模擬和library、system進行交互;如何測試UI、性能;以及如何使用「代碼覆蓋工具/code coverage tool」。學(xué)習(xí)過程中,會接觸到一些裝逼術(shù)語,學(xué)習(xí)完本寶典后,你就是大神了!
Testing, Testing...
What to Test?/測什么?
開始寫測試之前,有一件非常重要的事情:究竟要測什么?如果目的是擴展(修改)現(xiàn)有的app,那么首先要為即將要修改的部分寫測試。
測試通常包括:
- 核心功能/Core functionality:模型類和方法,以及他們和控制器的交互
- 常見的UI工作流
- 邊界條件/Boundary conditions
- Bug修復(fù)
First Things FIRST: Best Practices for Testing
FIRST是「Fast,Independent,Repeatable,Self-validating,Timely」的縮寫,描述了一套有效、簡明的單元測試標(biāo)準(zhǔn):
- Fast/高效:你寫的測試可以很快完成——只有這樣大家才不介意去跑測試代碼。
- Independent/Isolated:測試不應(yīng)該彼此依賴、拆解
- Repeatable:每次跑測試,得到的結(jié)果都應(yīng)該一致。當(dāng)然,外部數(shù)據(jù)和并發(fā)問題(concurrency issues)可能偶爾導(dǎo)致測試結(jié)果會不一樣。
- Self-validating:測試應(yīng)完全自動化;測試結(jié)果應(yīng)該是「pass」或者「fail」,而不需要程序員從一堆日志(log)文件中推測測試結(jié)果。
- Timely:理想情況下,在寫生產(chǎn)代碼前,就應(yīng)該寫好測試。(譯者:這里說的是理想情況下,所以有很多情況也是先寫功能,再寫測試的?)
遵循FIRST原則,可以保證寫出來的測試簡單、實用,不至于成為你的累贅。
Getting Started
下載、解壓、打開starter projects 中的BullsEye 和 HalfTune項目。
BullsEye是iOS Apprentice中的一個簡單的app(一個游戲app——譯者);項目已經(jīng)把游戲邏輯解耦到BullsEyeGame這個類了,并且加了另外一種游戲模式。
(運行BullsEye——譯者)在右下角,可以看到,有一個Segmented Control控件,可以讓使用者選擇游戲風(fēng)格:
Slide模式,將slider滑動到盡可能靠近預(yù)先設(shè)定的目標(biāo)值,
Type模式,猜測slider滑動條目前的值,將結(jié)果輸入頂部TextField中。
用戶選擇的游戲模式,app也會保存作為默認值(重啟app,默認游戲模式是使用者上次選擇的模式——譯者)
HalfTunes是NSURLsession Tutorial中的一個app,更新到Swift 3了(事實上已經(jīng)更新到Swift 4了——譯者)。在這個app,調(diào)用了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。看到test bundle時,點擊打開。如果BullsEyeTest沒有出現(xiàn),單擊切換到其他navigators,再返回test navagator。

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

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

現(xiàn)在不需要testPerformanceExample()這個方法,暫時刪掉。
用XCTAssert測試Models
首先,下面要用XCTAssert 來測試BullsEye model的核心功能:BullEyeGame對象計算的分數(shù)是否正確?
來到BullsEyeTests.swift,在import語句下,添加如下代碼:
@testable import BullsEye
這句代碼給了unit test 權(quán)限訪問BullsEye中的類、方法。
在BullsEyesTests類的頂部,添加以下屬性:
var gameUnderTest: BullsEyeGame!
在setup()方法內(nèi),super.setUp() 之后,創(chuàng)建一個新的BullsEyeGame對象:
gameUnderTest = BullsEyeGame()
gameUnderTest.startNewGame()
上面創(chuàng)建了一個class 層級的SUT(System Under Test)對象,所以在這個測試類里的所有測試都可以訪問SUT對象里的屬性和方法。
也可以調(diào)用startNewGame方法——這個方法創(chuàng)建targetValue。很多測試會用到targetValue——用來測試游戲是否正確計算「分數(shù)」。
在tearDown()方法內(nèi)要釋放(release) SUT對象。
gameUnderTest = nil
注意:在
setup()創(chuàng)建、在tearDown()釋放 SUT對象,是一個好習(xí)慣??梢源_保每個測試都是在干凈的環(huán)境中進行。更多資料,可以查看Jon Reid 關(guān)于此主題的帖子。
現(xiàn)在開始寫第一個測試!
用以下代碼替換整個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三部分,是一個好習(xí)慣:
- 在given部分,設(shè)置所需要的值:上面的例子,創(chuàng)建了一個
guess值,可以設(shè)定與targetValue的差異。 - 在when部分,執(zhí)行代碼進行測試:調(diào)用
gameUnderTest.check(_:)方法。 - 在then部分,assert(斷言)所期望的結(jié)果(在這個例子,
gameUnderTest.scoreRound是100 - 5),如果測試結(jié)果失敗,打印一條消息。
點擊菱形按鈕跑測試。app就會跑起來,菱形按鈕也會變成綠色勾選狀態(tài)。
Note:如果要看XCTestAssertions的完整列表,在代碼中按Command鍵同時點擊XCTAssertEqual 打開XCTestAssertions.h,或者到這里看: Apple’s Assertions Listed by Category.

Note:Given-When-Then 結(jié)構(gòu)起源于Behavior Driven Development(BDD/行為驅(qū)動開發(fā)),而Given-When-Then 這個名字更通俗易懂。也可以用Arrange-Act-Assert,或者Assembl-Activate-Assert。
Debugging a Test
我們在BullsEyeGame故意內(nèi)置了一個bug,現(xiàn)在就來找一下這個bug。
將testScoreIsComputed重命名為testScoreIsComputedWhenGuessGTTarget ,然后再復(fù)制-粘貼一個,創(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;當(dāng)測試方法發(fā)出失敗的assertion(斷言)時,測試就會停在這里。

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

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

問題出在difference的值是負數(shù),所以score的值變成100 - (-5);可以對diffenecne取絕對值來修復(fù)這個問題。在check(_:)方法中,取消注釋正確的那行,并刪除有問題的那行。
刪掉兩個斷點,再重新跑測試,這次沒有問題了。
用XCTestExpectation測試異步操作
上面已經(jīng)學(xué)會如何測試models,如何在測試失敗時debug,現(xiàn)在繼續(xù)學(xué)習(xí)使用XCTestExpectation來測試網(wǎng)絡(luò)操作(network operations)。
打開HalfTunes項目:這個app用URLSession來查詢iTunes API 并下載歌曲片段。假設(shè)你要改成用AlamoFire來進行網(wǎng)絡(luò)操作。要確認這個改寫過程是否有紕漏,應(yīng)該寫測試來驗證這些修改的代碼,在修改前、修改后都要跑測試。
URLSession方法是異步的:馬上返回,但要等一段時間才真正完成。要測試異步方法,可以用XCTestExpectation,它可以讓測試等到異步操作完成。
異步測試一般比較慢,所要要和unit tests 分開。
也是在+號菜單中選擇New Unit Test Target…并命名為HalfTunesSlowTests。在import下面導(dǎo)入HalfTunes app:
@testable import HalfTunes
這個類中的測試會用默認session向蘋果服務(wù)器發(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)碼的情況。大多數(shù)測試代碼和在app中實際寫的一樣,下面這些是額外添加的:
-
expectation(_:)返回一個XCTestExpectation對象,并賦值保存為promise。通常也可以用expectation和future來命名。description參數(shù)描述了你期望發(fā)生的結(jié)果。 - 為了匹配
description,在異步方法回調(diào)成功時,調(diào)用promise.fulfill()。 -
waitForExpectations(_:handler:)確保測試一直運行,直到達成所有期望的結(jié)果(expectations),或者timeout超時結(jié)束,以先觸發(fā)者為準(zhǔn)。
把測試跑起來,如果是連著網(wǎng)絡(luò)的,app在模擬器加載后,測試大概幾秒就能完成。
Fail Faster
測試失敗大家都不愿意看到,不過不可能百分百保證每次測試都能通過。下面介紹快速識別測試是否失敗,省下來的時間,就可以刷抖音刷朋友圈了:]
為了模擬測試失敗,刪除URL中「itunes」中的「s」:
let url = URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba")
再跑一次:如我們所愿,測試失敗了,但是它跑完timeout的時間(5秒——譯者)才提示失?。∵@是因為我們之前寫的代碼,要等到「請求」成功后,才會調(diào)用promise.fulfill()。不過這次的「請求」是失敗的,所以只能等timeout超時后才能結(jié)束測試。
通過修改expectation,可以讓「測試失敗」的結(jié)果更早呈現(xiàn):原來需要等到「請求」成功,現(xiàn)在只需等到異步方法回調(diào)即可(無論回調(diào)成功或錯誤——譯者)。換言之,一旦app收到服務(wù)器的響應(yīng)(無論是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)
}
這里的關(guān)鍵,就是在異步方法回調(diào)后,馬上執(zhí)行promise.fulfill(),這只耗費很少時間。如果「請求」失敗,then中的assertions(斷言)會拋出失敗。
再跑一次測試,現(xiàn)在就會馬上顯示測試失敗了,這是因為「請求(request)」失敗了,而不是因為timeout超時導(dǎo)致失敗。
修復(fù)url,重新跑測試,確認現(xiàn)在測試能通過。
Faking Objects and Interactions
異步測試給了你信心——你的代碼會生成正確的輸入(input)給異步的API(比如AlamoFire——譯者)。你可能還需要測試當(dāng)接收到URLSession的輸入時,你的代碼是否可以正確工作,又或者當(dāng)UserDefaults、CloudKit更新時,是否還能正常工作。
很多apps和系統(tǒng)(system)或者庫(library)的對象交互(interact)——這些對象是不在你掌控之下的——要測試這些交互會很慢而且不可重復(fù)(unrepeatable),這違反了FIRST的兩條原則(第一、第三條——譯者)。為了避免此類問題,可以偽造交互獲得輸入(input)——通過從stubs,或者更新mock對象。(就是喂假數(shù)據(jù)——譯者)
當(dāng)你的代碼依賴到系統(tǒng)或庫對象,就可以用這種偽造的方式——創(chuàng)建一個假對象喂入數(shù)據(jù)來進行這一部分的測試。Dependency Injection by Jon Reid 中描述了幾種可行的方法。

來自Stub的假數(shù)據(jù)
接下來的測試,會檢查updateSearchResults(_:)方法是否正確地解析了下載到的數(shù)據(jù),檢查searchResults.count是否正確。SUT對象是view controller,這里會利用stubs和一些預(yù)先準(zhǔn)備好的數(shù)據(jù)偽造session。
從+菜單中選擇New Unit Test Target…,命名為HalfTunesFakeTests。在import語句下面,導(dǎo)入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項目有一個很大的問題——現(xiàn)在所有事情都在SearchViewController.swift完成。在Moving the networking code into separate modults 中會解決這個問題,并且讓測試工作得到簡化。(應(yīng)該是說要將網(wǎng)絡(luò)請求這部分功能解耦出來——譯者)
接下來,偽造的session需要一些簡單的JSON數(shù)據(jù),以喂給測試。因為只需要幾組數(shù)據(jù),所以在URL字符串后面拼接上&limit=3來進行限制:
https://itunes.apple.com/search?media=music&entity=song&term=abba&limit=3
將這個URL復(fù)制粘貼到瀏覽器中。會下載到一個名為1.txt或類似的文件。打開確認這是一個JSON文件,然后重命名為abbaData.json,最后把它拖到HalfTunesFakeTests組中。
Supporting Files中已經(jīng)有一個叫做DHURLSessionMock.swift的文件。這個文件定義了一個簡單的協(xié)議DHURLSession,里面有方法(stubs)可以創(chuàng)建一個基于URL或者URLRequest的data task。也定義了遵守該協(xié)議的URLSessionMock類,可以讓你基于選擇的數(shù)據(jù)、response和error創(chuàng)建一個mock 類型的 URLSesison對象。
接下來設(shè)置假資料和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當(dāng)作SUT的屬性注入(inject)到app中:
controllerUnderTest.defaultSession = sessionMock
Note:在測試中會直接使用偽造的session,這里只是展示如何注入,后續(xù)就可以調(diào)用SUT方法,使用view controller的
defalutSession屬性。
現(xiàn)在就可以寫測試確認updateSearchResult(_:)方法是否能正確解析假數(shù)據(jù)。用以下代碼替換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是假設(shè)為異步方法的。
在when的斷言是「searchResults should be empty before the data task runs」——這是很明顯的,因為我們在setup()中創(chuàng)建的是一個全新的SUT。
假數(shù)據(jù)包含了三個Track對象的JSON數(shù)據(jù),所以then的斷言是「the view controller’s searchResults array contains three items」。
測試跑起來。應(yīng)該很快就跑完了,因為這不是真正和服務(wù)器交互。
Fake Update to Mock Object
上面的測試,利用stub從假對象中提供假資料(input)。接下來,會用mock對象測試你的代碼是否能正確更新UserDefaults。
重新打開BullsEye項目。這個app有兩種游戲模式:使用者移動slider接近目標(biāo)值,或者通過slider的位置猜測目標(biāo)值。右下角的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更改次數(shù)。在類似的測試中也會設(shè)置一個Bool變量,不過這里用一個Int記錄次數(shù)更具彈性——比如,測試可以精確地記錄方法的每次調(diào)用。
在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:) 方法只被調(diào)用了一次。
測試跑起來;正常來說是沒問題的。
UI Testing in Xcode
Xcode 7開始有了UI 測試,可以創(chuàng)建一個「UI 測試」記錄和UI的交互?!窾I測試」的工作原理——查詢app的UI對象、合成事件,然后將他們發(fā)送到這些對象。這個API允許開發(fā)者仔細檢查UI對象的屬性、狀態(tài),以便將他們與預(yù)期狀態(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按鈕:

當(dāng)app出現(xiàn)在模擬器后,點擊游戲模式切換開關(guān)的Slider segment,還有頂部的label。然后點擊Xcode Record按鈕停止記錄。
現(xiàn)在testGameStyleSwitch()中就會有如下三行代碼:(根據(jù)游戲模式的不同,Text文本內(nèi)容有所不同——譯者)
let app = XCUIApplication()
app.buttons["Slide"].tap()
app.staticTexts["Get as close as you can to: "].tap()
如果還出現(xiàn)了其他代碼,把其他代碼刪掉。
第一行復(fù)制了在setup()中創(chuàng)建的屬性,后面不需要點擊任何東西了,所以刪除第一行,還有第二行、第三行后面的.tap()。
打開["Slide"]右邊的一個小下拉菜單,選擇segmentedControls.buttons["Slide"]。
現(xiàn)在的代碼變成這樣:
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: "]
現(xiàn)在兩個按鈕和兩個頂部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是否正確存在(顯示)。把測試跑起來,應(yīng)該可以看到所有斷言(assertions)都成功了。
性能測試
蘋果官方文檔是這樣定義的:性能測試,會將需要測試的代碼塊運行十次,收集平均執(zhí)行時間和運行的標(biāo)準(zhǔn)偏差(standard deviation for the runs)。有了這個平均值,就可以以此值為基準(zhǔn),進行性能評估。
寫性能測試很簡單:只需要把需要測試的代碼放到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)
}
}
跑起來,然后點擊出現(xiàn)在measure()閉包尾部的圖標(biāo),查看統(tǒng)計信息。

點擊Set BaseLine,再次執(zhí)行performance test——結(jié)果可能比baseline更好或者更差。Edit按鈕可以將最新的值重設(shè)為baseline。
每臺設(shè)備的configuration都保存了baseline相關(guān)信息,因此可以在不同的設(shè)備上執(zhí)行相同的測試,不同設(shè)備的處理器速度、內(nèi)存等各不相同,它們會維護不同的baseline。
App的每次修改,都有可能影響到性能,可以再次運行性能測試,和baseline比較一下。
Code Coverage
Code coverage工具,可以幫忙檢查哪些代碼已經(jīng)跑過測試,哪些代碼還沒測試。
Note:當(dāng)code coverage打開時,是否應(yīng)該跑性能測試?蘋果官方文檔時這樣說的:Code coverage 數(shù)據(jù)收集會導(dǎo)致性能的損耗……以線性方式影響代碼的執(zhí)行,因此code coverage啟用時,對性能影響還是可以接受的( performance results remain comparable from test run to test run when it is enabled)。但是,當(dāng)你需要精確評估性能的時候,應(yīng)該考慮是否在測試中啟用code coveage。
要啟用code coverage,編輯scheme的Test,并勾選Code Coverage復(fù)選框(Xcode 9 是在Options中勾選——譯者):

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

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

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

coverage annotations還顯示了每部分代碼在一次測試中的執(zhí)行次數(shù);沒有被執(zhí)行的部分高亮為紅色。如你所愿,for循環(huán)跑了3次,而錯誤的分支,沒有被執(zhí)行。如果要提高這個方法的覆蓋率,可以復(fù)制一份abbaData.json,修改其中的內(nèi)容,就可以導(dǎo)致不同的錯誤——比如,將把key "results"改為"result",跑測試的時候,就會執(zhí)行print("Results key not found in dictionary")這個分支。
100% Coverage?
應(yīng)該追求100%的代碼覆蓋率嗎?搜索一下「100% unit test coverage」,網(wǎng)上有一大波爭論和相反意見,以及關(guān)于「100% unit test coverage」定義本身的爭論。反對派說最后的10-15%是不值得去測試的。贊成派認為正因為這部分難于測試,所以最后的10-15%是非常重要的。搜索一下「hard to unit test bad design」,可以找到有說服力的論據(jù)—— untestable code is a sign of deeper design problems——難以(不能)測試的代碼,往往意味著這個設(shè)計本身是有問題的。如果再深究的話,將會延伸到Test Driven Development(測試驅(qū)動開發(fā))。
Where to Go From Here
到此為止,我們可以利用很多有用的工具為項目進行測試了。希望看完這個關(guān)于iOS Unit Testing 和 UI Testing 的教程后,你可以胸有成竹地去測試所有東西。
這里是已經(jīng)完成的項目。
下面是一些補充教程:
現(xiàn)在你可以為項目寫測試了,那么下一步當(dāng)然就是自動化了: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ā)和測試,然后再轉(zhuǎn)到app中。CMD+U Conference 中的文章Watch Apps: How Do We Test Them? ,演示了如何用 PivotalCoreKit來測試watchOS app。
如果已經(jīng)寫好了app,但還沒有寫測試,可以參考 Working Effectively with Legacy Code by Michael Feathers一文,記住,沒有通過測試的代碼,不是好代碼。
Jon Reid的Quality Coding app archives,是一個學(xué)習(xí)更多關(guān)于 Test Driven Development的好地方。
如果有關(guān)于這個教程的任何疑問或建議,請加入論壇在下面進行討論。