編寫測試可不是一項迷人的工作;然而,由于測試可以避免使你的寶貝應用程序變成一塊充斥錯誤的大垃圾場,所以編寫測試又是一項非常有必要做的工作。如果你正在閱讀本文,那么你應當已經知道你應該為您的代碼和用戶界面編寫測試,只是不確定如何在Xcode中編寫測試。
也許你已經開發出一個能夠工作的應用程序,只是還沒有對它進行測試;另一方面,當您擴展該應用程序時,你又想對其任何的更改進行測試。也許你已經寫了一些測試,但尚不能確定它們是否是正確的測試。或者,你現在正在開發您的應用程序,并且想隨著工作的進展對之進行測試。
本教程將向您全面展示如何使用Xcode中的測試導航器來測試應用程序的模型和異步方法,以及如何通過使用代理(注stub,有的文章譯作“存根”)和模擬(mock)來模仿與庫或系統對象的交互,如何測試用戶界面和性能,以及如何使用代碼覆蓋工具。隨著文章的展開,你會不斷熟悉一些與測試相關的術語,到文章結尾時你會沉著地把依賴關系注入到你的被測系統(SUT,system under test)中!
測試,測試……
測試什么?
在寫任何測試之前,首先要明確最基本的問題︰你需要測試什么?如果你的目標是擴展一款現有的應用程序,那么您應該首先為您計劃更改的任何組件編寫測試。
更一般的情況下,你的測試應包括如下一些內容︰
核心功能︰模型類和方法及其與控制器的交互
最常見的用戶界面工作流
邊界條件
錯誤修復
當務之急
首字母縮略詞FIRST描述了一套簡明有效的單元測試標準。這些標準是︰
Fast(快速)︰測試的運行速度應該很快,這樣一來人們就不會介意運行它們。
Independent/Isolated(獨立/分離)︰一個測試不應因另一個測試而進行安裝或拆卸。
Repeatable(可重復)︰每次運行測試時,您應該獲得相同的結果。值得注意的是,外部數據提供者和并發問題可能會導致程序的間歇性故障。
Self-validating(自我驗證)︰測試應該能夠完全自動化進行;輸出應該要么是“pass”(即“通過”)要么是“fail”(即“失敗”),而不是提供給程序員一個解釋性的日志文件。
Timely(及時)︰理想情況下,應該只是在你編寫生產代碼之前編寫測試。
遵循上述FIRST原則進行測試能夠確保您的測試明確而有用,而不致使之成為您的應用程序中的路障。
開始
首先,請從網址https://koenig-media.raywenderlich.com/uploads/2016/12/Starters.zip處下載、解壓縮、打開并觀察本文提供的兩個初始示例工程BullsEye和HalfTunes。
注意,工程BullsEye基于文章https://www.raywenderlich.com/store/ios-apprentice中提供的一個樣本程序。我已經把游戲邏輯提取到一個BullsEyeGame類中,并相應地添加了另一種游戲風格。
在游戲的右下角提供了一個分段的控制器組件,供用戶選擇游戲風格︰或者是Slide類型,允許玩家移動滑塊組件以盡可能接近目標值;或者是Type類型,允許玩家猜測滑塊到達的位置。控件相應的動作代碼中還會將用戶選擇的游戲風格存儲為該用戶的默認設置。
另一個示例工程HalfTunes則來自于我們的另一個教程NSURLSession(https://www.raywenderlich.com/110458/nsurlsession-tutorial-getting-started),現已被更新到Swift 3版本。用戶可以使用iTunes API查詢歌曲,然后下載并播放對應的歌曲片段。
下面,讓我們正式開始測試!
Xcode中的單元測試
創建單元測試目標
Xcode中的測試導航器(Test Navigator)為進行程序測試提供了最容易使用的方式;你可以使用它創建測試目標并在你的程序上運行測試。
現在,請打開工程BullsEye并按下組合鍵Command+5來打開它的測試導航器。
然后,點擊左下方的+按鈕;之后,從菜單中選擇“New Unit Test Target…”命令,如圖所示。
在此,請直接使用默認的名稱BullsEyeTests。當測試包出現在測試導航器中時,單擊它,從而在編輯器中打開它。如果BullsEyeTests不會自動出現,你可以單擊其他導航器,然后再返回到當前測試導航器即可。
注意到,模板導入了XCTest并定義了XCTestCase的一個子類BullsEyeTests,同時提供了setup()方法,tearDown()方法,還有系統默認的示例測試方法。
歸納起來,共有三種辦法可以運行測試類:
- 使用命令Product\Test或者Command-U;這將會運行所有的測試類。
- 使用測試導航器中的箭頭命令。
- 也可以點擊代碼左邊緣上的鉆石按鈕。
另外,您還可以通過單擊測試導航器中或代碼左邊緣上的鉆石按鈕運行單個測試方法。
建議你嘗試上面不同的方式來運行測試,從而感受一下需要多長時間以及運行測試看起來的樣子。當前的樣本測試并不做任何事,所以它們的運行速度會非常快!
當所有測試都成功時,鉆石按鈕會變綠,并在上面顯示對號標記。你可以單擊testPerformanceExample()方法最后面的灰色鉆石按鈕來打開性能結果(Performance Result)小窗進行觀察,參考下圖。
現在,我們并不需要函數testPerformanceExample();所以,把它刪除即可。
使用XCTAssert測試模型
首先,您將使用XCTAssert來測試BullsEye模型的一個核心功能︰一個BullsEyeGame對象能否正確計算出一個回合的得分?
為此,請在文件BullsEyeTests.swift中緊貼著導入語句下方添加下面這一行代碼︰
@testable import BullsEye
這一行代碼使單元測試能夠訪問到BullsEye中的類和方法。
接下來,請在BullsEyeTests類的頂部添加下面的屬性:
var gameUnderTest: BullsEyeGame!
然后,在setup()方法中在調用超類語句的下面啟動一個新的BullsEyeGame對象:
gameUnderTest = BullsEyeGame()
gameUnderTest.startNewGame()
上面的代碼將創建一個類級的SUT(System Under Test,測試系統)對象。這樣一來,測試類中的所有測試都可以訪問該SUT對象的屬性和方法。
在這里,你還可以調用游戲的startNewGame方法——此方法只創建一個targetValue值。您的很多測試都將使用這個targetValue值,來測試程序能夠正確計算出游戲中的得分。
最后,切記在tearDown()方法中在調用超類前釋放掉你的SUT對象︰
gameUnderTest = nil
【注意】一種值得推薦的測試做法是在方法setup()中創建SUT對象并在tearDown()方法中釋放它,以確保每個測試都對應一個徹底的清理。更多的有關細節討論,請參考Jon Reid的帖子http://qualitycoding.org/teardown/。
現在,你已經準備好編寫你的第一個測試了!
請使用如下代碼替換工程中的方法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部分中,設置所需的任何值。在此示例中,您創建一個猜測值,以便可以指定它與targetValue值區別多大。
- 在when部分中,執行被測試代碼——調用方法gameUnderTest.check(_:)。
- 在then部分中,斷言你期望的結果(在現在情況下,gameUnderTest.scoreRound的值是100-5):如果測試失敗則打印對應的消息。
現在,你可以單擊測試導航器或者代碼左邊的鉆石圖標按鈕運行測試。你會注意到應用程序將進行構建并運行起來,最后鉆石圖標將更改為一個綠色的對號標記!
【注意】若要查看XCTestAssertions的完整列表,你可以在按下Command鍵的同時單擊代碼中的XCTAssertEqual打開文件XCTestAssertions.h。此外,你還可以參考蘋果官方網站提供的按類別提供的斷言列表
(https://developer.apple.com/library/prerelease/content/documentation/DeveloperTools/Conceptual/testing_with_xcode/chapters/04-writing_tests.html#//apple_ref/doc/uid/TP40014132-CH4-SW35)。
另外,上述測試中的Given-When-Then結構來源于行為驅動測試(Behavior Driven Development,簡稱BDD)中的易于理解的行業術語。其實,你還可以使用另外一些命名系統,例如Arrange-Act-Assert和Assemble-Activate-Assert,等等。
調試一個測試
在BullsEyeGame工程中,我故意放置了一個錯誤。現在,我們進行測試,以便找到這個錯誤。為了觀察此錯誤導致的問題,請把testScoreIsComputed重新命名為testScoreIsComputedWhenGuessGTTarget,然后復制、粘貼并編輯它,從而創建另一個方法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")
}
注意到:猜測值和targetValue值之間的區別仍然是5,因此分數應仍為95。
在斷點導航器中,添加一個測試失敗(Test Failure)斷點;當一個測試方法發出一個失敗的斷言時這將停止測試運行。
現在運行你的測試:它應該在XCTAssertEqual一行停止,并出示一個測試錯誤。
然后,你可以在調試控制臺上觀察gameUnderTest和guess的輸出結果:
你應該注意到:guess的值是-5,但scoreRound的值是105,而不是95!
為了進一步分析,你可以使用通常的調試過程︰在when語句上設置一個斷點,也在BullsEyeGame.swift文件上設置一個斷點——即在其中的方法check(:)上設置。然后,再次運行測試,并以逐過程調試方式(即step-over)調試let語句來檢查應用程序中的不同值
現在的問題是,差值是一個負數;所以,得分是100-(-5)。解決方法是使用差異的絕對值即可。為此,在方法check(:)中取消正確代碼前面的注釋,并刪除不正確的代碼即可。
刪除上面設置的兩個斷點并再一次運行測試,以確認上面代碼行現在已順利通過。
使用XCTestExpectation測試異步操作
到目前為止,你已經學會了如何測試模型和調試測試失敗。接下來,讓我們繼續學習如何使用XCTestExpectation來測試網絡相關的操作。
首先,請打開HalfTunes項目。你會注意到,它使用URLSession來查詢iTunes API和下載歌曲樣本。假設您想修改它,以便使用AlamoFire進行網絡操作。為了查看是否出現任何中斷情況,您應為網絡操作編寫測試,并在更改代碼之前和之后運行它們。
URLSession方法是異步執行的︰它們會馬上返回,但只有運行一段時間后才真正完成。為了測試異步方法,你應使用XCTestExpectation使你的測試等待異步操作完成。
值得注意的是,異步測試通常很慢,所以你應該把它們與你另外的一些運行速度更快的單元測試分開。
從菜單“+”下選擇并運行命令“New Unit Test Target…”,然后把目標命名為HalfTunesSlowTests。然后,在import語句的下面導入HalfTunes程序:
@testable import HalfTunes
在此類中的所有測試都將使用默認會話把請求發送到蘋果公司的服務器。所以,我們在方法setup()中聲明并創建一個sessionUnderTest對象,然后在方法tearDown()中釋放它:
var sessionUnderTest: URLSession!
override func setUp() {
super.setUp()
sessionUnderTest = URLSession(configuration: URLSessionConfiguration.default)
}
override func tearDown() {
sessionUnderTest = nil
super.tearDown()
}
接下來,使用TestExample()函數來替換您的異步測試︰
//異步測試時:成功測試很快,失敗測試卻比較慢
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)
}
上面這個測試的目的是檢查發送到iTunes的有效的查詢是否能夠返回狀態碼200。顯然,其中大部分代碼與你在上面應用程序中所寫的一樣,只是增加了如下幾行︰
1.expectation(:)返回一個XCTestExpectation對象;此對象存儲在變量promise中。此對象的其他常用名字是expectation和future。另外,description參數描述了你期望發生的事情。
2.為了匹配description參數,您需要在異步方法的完成處理程序的成功條件閉包中調用promise.fulfill()。
3.waitForExpectations(:handler:)的作用是保持所有測試在運行中,直到所有的期望得以實現,或者timeout值指定的時間間隔結束——無論兩者哪一種早發生都行。
現在,再來運行該測試。如果你已經連接到互聯網,則當應用程序在模擬器中加載后成功測試大約花費一秒鐘時間。
使測試失敗更快一些
測試失敗會導致不少問題,但它未必花費很多時間。現在,我們來解決如何快速確定是否您的測試失敗的問題。
為了修改一下您的測試,從而導致異步操作時失敗,你只需要從下面的URL中刪除“itunes”一詞后面的s字母即可:
let url = URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba")
運行上述測試時︰它會失敗,而且此測試會花費所有指定的超時間隔時間!這是因為它的期望是請求成功——正是在這個位置調用了promise.fulfill()方法。既然請求失敗,那么測試僅當在超過指定時限時才結束。
你可以使這個測試失敗更快一些——這只要通過改變它的期望值即可達到︰不是等待請求成功,而只需要等到異步方法的完成處理程序觸發即可。只要應用程序接收到來自服務器端的響應(或者是成功或者是失敗)這種情況就會發生;但是,這的確符合預期結果。然后,您的測試可以檢查請求是否成功。
為了查看這是如何工作的,您要創建一個新的測試。首先,修復此測試——這可以通過撤消上面的url更改操作輕松完成,然后將下面的測試添加到您的類中︰
// 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)
}
上面代碼中最關鍵的一點是,只需輸入完成處理程序實現的期望——這需要大約一秒鐘即會發生。如果請求失敗,那么斷言也會失敗。
現在再來運行上面的測試︰它現在大約需要一秒鐘即會失敗;它的失敗是因為請求失敗了,而不是因為測試運行超時。
修復上面的url,然后再一次運行測試,以確認它現在能夠成功通過測試。
偽造對象和交互
異步測試能夠給你信心——你的代碼會為一個異步API提供正確的輸入。你可能也想測試您的代碼能夠正常工作——當它從URLSession接收輸入時,或當它正確更新了UserDefaults或者CloudKit數據庫時。
大多數應用程序都會與系統或庫對象(你不能控制這些對象)進行交互,而與這些對象的交互測試很可能是極其緩慢的,而且不可重復的——這正違反了文章開始時FIRST原則中的兩條。相反,你可以偽造這些交互——通過從代理(stub)中獲取輸入或更新模擬對象(Mock Object)來實現。
當您的代碼依賴于一個系統或庫中的對象時,通過上面偽造的辦法可以創建一個假的對象來實現那一部分功能并把這種偽造注入到您的代碼中。喬恩·里德的依賴性注入技術文章(https://www.objc.io/issues/15-testing/dependency-injection/)中就介紹了好幾種方法來達到這一目的。
從代理(stub)中偽造輸入
在本節中的測試中,你將要檢查應用程序的updateSearchResults(_:)方法能夠正確解析由會話下載的數據——通過檢查屬性searchResults.count的值是正確的來實現。SUT是視圖控制器;你要使用代理(stub)技術來偽裝一個會話和一些預先下載的數據。
為此,從“+”菜單下選擇命令“New Unit Test Target…”并命名它為HalfTunesFakeTests。然后,在import語句的下面導入HalfTunes程序:
@testable import HalfTunes
接下來,聲明SUT,并在setup()方法中創建它,且在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()
}
【注】SUT(被測系統)是視圖控制器,因為HalfTunes工程中擁有大量的視圖控制器問題——所有的工作都是在文件searchviewcontroller.swift中完成的。“將網絡代碼移動到單獨的模塊”(詳見文章http://williamboles.me/networking-with-nsoperation-as-your-wingman/)將會減少這一問題,而且也使測試更為容易。
接下來,您將需要一些樣本JSON數據,供您的偽造的會話提供給你的測試使用。只需要做一少部分工作即可;因此,請限制一下您的來自iTunes的下載結果——在URL字符串的后面添加一個限制串&limit=3:
https://itunes.apple.com/search?media=music&entity=song&term=abba&limit=3
復制此URL并把它粘貼到瀏覽器中。這將下載一個名為1.txt或類似的文件。你可以預覽一下它,以便確認這是一個JSON格式的文件,然后重命名它為abbaData.json,并把該文件添加到HalfTunesFakeTests組中。
HalfTunes項目包含了支持文件DHURLSessionMock.swift。這個文件中定義了一個簡單的協議——DHURLSession,其提供的方法(代理)用于使用一個URL或URLRequest來創建一個數據任務。它還定義了符合該協議的URLSessionMock對象,該對象中提供的初始化器可以讓你使用你選擇的數據、響應和誤差等來創造一個模擬URLSession對象。
現在,我們來構建偽造的數據和響應,并創建偽造的會話對象;這些都實現于方法setup()中,相應的代碼位于創建SUT對象的語句之后:
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)
At the end of setup(), inject the fake session into the app as a property of the SUT:
controllerUnderTest.defaultSession = sessionMock
【注意】您將直接在您的測試中使用偽造的會話,但是這將向你展示如何注入這種偽造的會話;這樣一來,你進一步的測試可以調用使用視圖控制器defaultSession屬性的SUT方法。
現在,您可以編寫測試來檢查是否調用updateSearchResults(_:)方法能夠解析偽造的數據。為此,請把TestExample()方法替換為以下內容︰
//使用DHURLSession協議和代理偽造URLSession
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的值應當是空的——這應該是真實情況,因為您在setup()方法中創建了一個全新的SUT。
偽造的數據包含了提供給三個跟蹤(Track)對象使用的JSON數據;所以,then斷言的作用是:視圖控制器的searchResults數組應當包含三項。
再次運行該測試。這次應該成功,而且速度很快,因為不存在任何真實的網絡連接!
偽造對模擬對象的更新
以前的測試使用代理從假對象提供輸入。接下來,你可以使用一個模擬對象來測試你的代碼可以正確更新UserDefaults。
重新打開BullsEye項目。注意到,該應用程序提供了兩種游戲風格:用戶可以選擇移動滑塊來匹配目標值或從滑塊位置猜測目標值。借助于界面右下角的分段控制開關可以切換游戲風格并更新用戶默認的游戲風格。
你要編寫的下一個測試將檢查應用程序能夠正確地更新用戶默認的游戲風格數據。
在測試導航器中,點擊命令“New Unit Test Target…”,并命名為BullsEyeMockTests。然后,在導入語句下面添加以下內容:
@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標志的值加1。通常你會看到類似的測試中是設置一個布爾變量,但是在此我們使用一個整數值加1,這可以進一步增加你的靈活控制——例如你的測試可以檢查該方法僅被正確地調用一次。
在BullsEyeMockTests類中聲明SUT對象和模擬對象:
var controllerUnderTest: ViewController!
var mockUserDefaults: MockUserDefaults!
在方法setup()中,創建SUT對象和模擬對象,然后把此模擬對象注入為該SUT的一個屬性:
controllerUnderTest = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as! ViewController!
mockUserDefaults = MockUserDefaults(suiteName: "testing")!
controllerUnderTest.defaults = mockUserDefaults
Release the SUT and the mock object in tearDown():
controllerUnderTest = nil
mockUserDefaults = nil
Replace testExample() with this:
// 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斷言的作用是:gameStyleChanged標志的值為0——在測試方法觸發分段控制開關之前。因此,如果then斷言也為真,那么將意味著方法set(_:forKey:)僅被正確地調用一次。
現在再次運行測試;應當可以成功。
在Xcode中進行UI測試
Xcode 7中引入了對UI測試的支持,使您可以通過記錄與UI的交互來創建UI測試。UI測試的工作方式是:通過查詢來查找一個應用程序的UI對象,進而合成事件,然后將這些事件發送給這些對象。其提供的API使您可以檢查一個用戶界面對象的屬性和狀態,以便把它們與預期的狀態進行比較。
現在,讓我們在BullsEye項目的測試導航器中添加一個新的UI測試目標。確保要被測試的目標是BullsEye,然后接受默認名稱BullsEyeUITests。
然后,在BullsEyeUITests類的頂部添加如下屬性︰
var app: XCUIApplication!
在方法setup()中,用以下代碼替換XCUIApplication().launch()語句︰
app = XCUIApplication()
app.launch()
把testExample()的名字更改為testGameStyleSwitch()。
然后,在testGameStyleSwitch()中按下回車鍵創建一個新的空行,并點擊編輯器窗口底部的紅色的Record按鈕,如圖所示。
當應用程序出現在模擬器中時,點擊控制游戲風格開關的滑動塊及頂部標簽。然后,單擊Xcode中的Record按鈕即可停止錄制。
現在,你在方法testGameStyleSwitch()中擁有以下三行代碼︰
let app = XCUIApplication()
app.buttons["Slide"].tap()
app.staticTexts["Get as close as you can to: "].tap()
如果還有其他的語句,則刪除它們。
第一行代碼的作用是復制你在setup()方法中創建的屬性;因為你還不需要點擊任何東西,所以也把這第一行刪除,還要刪除第2行與第3行末尾的“.tap()”。打開["Slide"]鄰近的小菜單并選擇
segmentedControls.buttons["Slide"]。
于是,你有了如下的代碼:
app.segmentedControls.buttons["Slide"]
app.staticTexts["Get as close as you can to: "]
進一步修改上述代碼,以便創建測試的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: "]
現在,你有了兩個按鈕和兩個可能的頂部標簽的名稱,再添加以下內容︰
// 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)
}
這段代碼將會檢測當選中或者點擊每個按鈕時是否存在正確的標簽。現在,運行測試——結果是所有斷言應該都成功。
性能測試
根據蘋果公司官方文檔
(https://developer.apple.com/library/prerelease/content/documentation/DeveloperTools/Conceptual/testing_with_xcode/chapters/04-writing_tests.html#//apple_ref/doc/uid/TP40014132-CH4-SW8)描述:一個性能測試需要使用你想要評估的一個代碼塊,并運行此代碼塊10次,期間收集平均執行時間和運行的標準偏差值。這些個別測量的平均值成為測試運行的一個值,然后把該值與一個基準值進行比較來評估成功或失敗。
寫一個性能測試還是非常簡單的︰你只需要把你想要測試的代碼放到measure()方法的閉包中即可。
為了實際體驗一下,請重新打開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()閉包末尾的圖標來觀看統計信息。
單擊“Set Baseline”(設置基準值)按鈕,然后再次運行性能測試并查看結果——結果有可能比基準值更好或更糟。你可以點擊Edit(編輯)按鈕幫助您將基準值重置為這個新的結果。
基準值在每個設備配置時存儲起來,所以你可以讓同一測試執行在若干臺不同的設備上,并使每臺設備保持一個不同的基準值——這要取決于處理器速度、內存等的具體配置情況。
任何時候只要你更改一個應用程序,都有可能影響正在測試的方法的性能;你可以再次運行性能測試來觀察當前值與基準值比較的結果。
代碼覆蓋
代碼覆蓋工具能夠告訴你應用程序中的哪些代碼實際上被您的測試運行過;這樣一來,你就可以知道應用程序代碼的哪些部分還沒有被測試。
【注意】在啟用代碼覆蓋功能時你是否應該運行性能測試呢?蘋果公司的文檔(https://developer.apple.com/library/prerelease/content/documentation/DeveloperTools/Conceptual/testing_with_xcode/chapters/07-code_coverage.html#//apple_ref/doc/uid/TP40014132-CH15-SW1)中是這樣描述的︰代碼覆蓋數據集合會導致性能的下降……以線性方式影響代碼的執行;因此,當啟用代碼覆蓋功能時程序的性能將會因不同的測試運行而有所差異。但是,當你對你的測試中的例程要求極其嚴格時你應該認真考慮是否要啟用代碼覆蓋支持。
為了啟用代碼覆蓋功能,你可以編輯一下你預先計劃的測試(Test)操作并勾選“Code Coverage”復選框︰
運行您的所有測試(按下組合鍵Command+U),然后打開報告導航器(按下組合鍵Command+8)。按執行時間先后選擇(By Time,見下圖)列表中最上面的一項,然后再選擇“Coverage”(覆蓋)選項卡。
你可以單擊如下圖展開的三角形圖標來觀察SearchViewController.swift文件中的函數列表︰
你可以把鼠標懸停在updateSearchResults(_:)方法附近的藍色的Coverage(覆蓋率)條上觀察到對應的覆蓋率為71.88%。
單擊該函數對應的箭頭按鈕來打開源文件,并定位到該函數。當你的鼠標移到右邊欄中的覆蓋率注釋上時,代碼段將突出顯示為綠色或紅色︰
覆蓋率注釋上的信息顯示出一個測試中命中每個代碼段的次數。注意,沒有被調用到的代碼段部分突出顯示為紅色。正如你所期望的,for循環運行3次,但沒有一次是沿著錯誤路徑執行的。為了提高此函數的代碼覆蓋率,你可以復制abbaData.json,然后修改它,使其會導致不同的錯誤——例如,將“results”更改為“result”來測試執行到打印語句print("Results key not found in dictionary")的情況。
100%覆蓋?
爭取實現100%的代碼覆蓋率你可知道應該付出怎樣的努力嗎?如果你使用谷歌搜索引擎搜索“100% unit test coverage”的話,你會搜索到有贊同的也有反對的等多種觀點,以及圍繞100%覆蓋率的大量爭論。其中,持反對看法的認為最后的10-15%并不重要——不值得為之付出努力;而持贊同看法的認為最后的10-15%極其重要——因為它很難測試。再使用谷歌搜索引擎搜索“hard to unit test bad design”可以找到頗有說服力的論據——無法驗證的代碼是一種更深層次的設計問題(https://www.toptal.com/qa/how-to-write-testable-code-and-why-it-matters)。進一步的思考可能導致的結論是測試驅動開發(http://qualitycoding.org/tdd-sample-archives/)是軟件開發過程中必須要走的路。
總結
本文中已經向你提供了為你的iOS工程編寫測試的多種工具。我希望你能夠通過本教程的學習樹立起足夠的信心來測試一切!
你可以從地址https://koenig-media.raywenderlich.com/uploads/2016/12/Finished-3.zip處下載本文中的完整的示例工程源碼。
最后,下面提供的一些資源可以供你作進一步學習測試使用:
既然通過本文學習你已經學會了為你的項目編寫測試,那么你下一步要了解的應當是自動化測試相關的主題。為此,你可以首先學習蘋果官方的基于Xcode Server和xcodebuild的自動測試過程(Automating the Test Process,https://developer.apple.com/library/content/documentation/DeveloperTools/Conceptual/testing_with_xcode/chapters/08-automation.html#//apple_ref/doc/uid/TP40014132-CH7-SW1),以及發表在Wikipedia上的相關連載文章(https://en.wikipedia.org/wiki/Continuous_delivery),來源于ThoughtWorks網站(https://www.thoughtworks.com/continuous-delivery)上的一位資深專家的文章。
使用Swift Playgrounds進行測試驅動開發(http://initwithstyle.net/2015/11/tdd-in-swift-playgrounds/)。你可以在Playgrounds環境下使用XCTestObservationCenter來運行XCTestCase單元測試。你可以在Playgrounds中開發你的工程代碼并進行測試,然后把二者都轉換成你的應用程序。
來自CMD+U協會(http://www.cmduconf.com/)的教程告訴你如何使用PivotalCoreKit(https://github.com/pivotal/PivotalCoreKit)來測試watchOS應用程序。
如果你已經編寫了一個應用程序,而只是沒有為它編寫測試,你可以參閱Michael Feathers的圖書《Working Effectively with Legacy Code》(https://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052/ref=sr_1_1?s=books&ie=UTF8&qid=1481511568&sr=1-1),因為不包含測試的代碼往往都是遺留下來的代碼!
Jon Reid的高質量編碼示例編程文章(http://qualitycoding.org/tdd-sample-archives/)也是你學習測試驅動開發的極好去處。