iOS單元測試和UI測試全面解析

編寫測試可不是一項(xiàng)迷人的工作;然而,由于測試可以避免使你的寶貝應(yīng)用程序變成一塊充斥錯誤的大垃圾場,所以編寫測試又是一項(xiàng)非常有必要做的工作。如果你正在閱讀本文,那么你應(yīng)當(dāng)已經(jīng)知道你應(yīng)該為您的代碼和用戶界面編寫測試,只是不確定如何在Xcode中編寫測試。
也許你已經(jīng)開發(fā)出一個能夠工作的應(yīng)用程序,只是還沒有對它進(jìn)行測試;另一方面,當(dāng)您擴(kuò)展該應(yīng)用程序時(shí),你又想對其任何的更改進(jìn)行測試。也許你已經(jīng)寫了一些測試,但尚不能確定它們是否是正確的測試?;蛘?,你現(xiàn)在正在開發(fā)您的應(yīng)用程序,并且想隨著工作的進(jìn)展對之進(jìn)行測試。
本教程將向您全面展示如何使用Xcode中的測試導(dǎo)航器來測試應(yīng)用程序的模型和異步方法,以及如何通過使用代理(注stub,有的文章譯作“存根”)和模擬(mock)來模仿與庫或系統(tǒng)對象的交互,如何測試用戶界面和性能,以及如何使用代碼覆蓋工具。隨著文章的展開,你會不斷熟悉一些與測試相關(guān)的術(shù)語,到文章結(jié)尾時(shí)你會沉著地把依賴關(guān)系注入到你的被測系統(tǒng)(SUT,system under test)中!
測試,測試……
測試什么?
在寫任何測試之前,首先要明確最基本的問題︰你需要測試什么?如果你的目標(biāo)是擴(kuò)展一款現(xiàn)有的應(yīng)用程序,那么您應(yīng)該首先為您計(jì)劃更改的任何組件編寫測試。
更一般的情況下,你的測試應(yīng)包括如下一些內(nèi)容︰
核心功能︰模型類和方法及其與控制器的交互
最常見的用戶界面工作流
邊界條件
錯誤修復(fù)
當(dāng)務(wù)之急
首字母縮略詞FIRST描述了一套簡明有效的單元測試標(biāo)準(zhǔn)。這些標(biāo)準(zhǔn)是︰
Fast(快速)︰測試的運(yùn)行速度應(yīng)該很快,這樣一來人們就不會介意運(yùn)行它們。
Independent/Isolated(獨(dú)立/分離)︰一個測試不應(yīng)因另一個測試而進(jìn)行安裝或拆卸。
Repeatable(可重復(fù))︰每次運(yùn)行測試時(shí),您應(yīng)該獲得相同的結(jié)果。值得注意的是,外部數(shù)據(jù)提供者和并發(fā)問題可能會導(dǎo)致程序的間歇性故障。
Self-validating(自我驗(yàn)證)︰測試應(yīng)該能夠完全自動化進(jìn)行;輸出應(yīng)該要么是“pass”(即“通過”)要么是“fail”(即“失敗”),而不是提供給程序員一個解釋性的日志文件。
Timely(及時(shí))︰理想情況下,應(yīng)該只是在你編寫生產(chǎn)代碼之前編寫測試。

遵循上述FIRST原則進(jìn)行測試能夠確保您的測試明確而有用,而不致使之成為您的應(yīng)用程序中的路障。
開始
首先,請從網(wǎng)址https://koenig-media.raywenderlich.com/uploads/2016/12/Starters.zip處下載、解壓縮、打開并觀察本文提供的兩個初始示例工程BullsEye和HalfTunes。
注意,工程BullsEye基于文章https://www.raywenderlich.com/store/ios-apprentice中提供的一個樣本程序。我已經(jīng)把游戲邏輯提取到一個BullsEyeGame類中,并相應(yīng)地添加了另一種游戲風(fēng)格。
在游戲的右下角提供了一個分段的控制器組件,供用戶選擇游戲風(fēng)格︰或者是Slide類型,允許玩家移動滑塊組件以盡可能接近目標(biāo)值;或者是Type類型,允許玩家猜測滑塊到達(dá)的位置。控件相應(yīng)的動作代碼中還會將用戶選擇的游戲風(fēng)格存儲為該用戶的默認(rèn)設(shè)置。
另一個示例工程HalfTunes則來自于我們的另一個教程N(yùn)SURLSession(https://www.raywenderlich.com/110458/nsurlsession-tutorial-getting-started),現(xiàn)已被更新到Swift 3版本。用戶可以使用iTunes API查詢歌曲,然后下載并播放對應(yīng)的歌曲片段。
下面,讓我們正式開始測試!
Xcode中的單元測試
創(chuàng)建單元測試目標(biāo)
Xcode中的測試導(dǎo)航器(Test Navigator)為進(jìn)行程序測試提供了最容易使用的方式;你可以使用它創(chuàng)建測試目標(biāo)并在你的程序上運(yùn)行測試。
現(xiàn)在,請打開工程BullsEye并按下組合鍵Command+5來打開它的測試導(dǎo)航器。
然后,點(diǎn)擊左下方的+按鈕;之后,從菜單中選擇“New Unit Test Target…”命令,如圖所示。


在此,請直接使用默認(rèn)的名稱BullsEyeTests。當(dāng)測試包出現(xiàn)在測試導(dǎo)航器中時(shí),單擊它,從而在編輯器中打開它。如果BullsEyeTests不會自動出現(xiàn),你可以單擊其他導(dǎo)航器,然后再返回到當(dāng)前測試導(dǎo)航器即可。



注意到,模板導(dǎo)入了XCTest并定義了XCTestCase的一個子類BullsEyeTests,同時(shí)提供了setup()方法,tearDown()方法,還有系統(tǒng)默認(rèn)的示例測試方法。
歸納起來,共有三種辦法可以運(yùn)行測試類:

  1. 使用命令Product\Test或者Command-U;這將會運(yùn)行所有的測試類。
  2. 使用測試導(dǎo)航器中的箭頭命令。
  3. 也可以點(diǎn)擊代碼左邊緣上的鉆石按鈕。

另外,您還可以通過單擊測試導(dǎo)航器中或代碼左邊緣上的鉆石按鈕運(yùn)行單個測試方法。
建議你嘗試上面不同的方式來運(yùn)行測試,從而感受一下需要多長時(shí)間以及運(yùn)行測試看起來的樣子。當(dāng)前的樣本測試并不做任何事,所以它們的運(yùn)行速度會非???
當(dāng)所有測試都成功時(shí),鉆石按鈕會變綠,并在上面顯示對號標(biāo)記。你可以單擊testPerformanceExample()方法最后面的灰色鉆石按鈕來打開性能結(jié)果(Performance Result)小窗進(jìn)行觀察,參考下圖。


現(xiàn)在,我們并不需要函數(shù)testPerformanceExample();所以,把它刪除即可。
使用XCTAssert測試模型
首先,您將使用XCTAssert來測試BullsEye模型的一個核心功能︰一個BullsEyeGame對象能否正確計(jì)算出一個回合的得分?
為此,請?jiān)谖募﨎ullsEyeTests.swift中緊貼著導(dǎo)入語句下方添加下面這一行代碼︰

@testable import BullsEye 

這一行代碼使單元測試能夠訪問到BullsEye中的類和方法。
接下來,請?jiān)贐ullsEyeTests類的頂部添加下面的屬性:

var gameUnderTest: BullsEyeGame! 

然后,在setup()方法中在調(diào)用超類語句的下面啟動一個新的BullsEyeGame對象:

gameUnderTest = BullsEyeGame() 
 
gameUnderTest.startNewGame() 

上面的代碼將創(chuàng)建一個類級的SUT(System Under Test,測試系統(tǒng))對象。這樣一來,測試類中的所有測試都可以訪問該SUT對象的屬性和方法。
在這里,你還可以調(diào)用游戲的startNewGame方法——此方法只創(chuàng)建一個targetValue值。您的很多測試都將使用這個targetValue值,來測試程序能夠正確計(jì)算出游戲中的得分。
最后,切記在tearDown()方法中在調(diào)用超類前釋放掉你的SUT對象︰

gameUnderTest = nil 

【注意】一種值得推薦的測試做法是在方法setup()中創(chuàng)建SUT對象并在tearDown()方法中釋放它,以確保每個測試都對應(yīng)一個徹底的清理。更多的有關(guān)細(xì)節(jié)討論,請參考Jon Reid的帖子http://qualitycoding.org/teardown/
現(xiàn)在,你已經(jīng)準(zhǔ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開頭,后面跟著的是對它要測試的內(nèi)容的說明。
一個推薦的做法是把測試方法格式化成given、when和then等幾部分︰

  1. 在given部分中,設(shè)置所需的任何值。在此示例中,您創(chuàng)建一個猜測值,以便可以指定它與targetValue值區(qū)別多大。
  2. 在when部分中,執(zhí)行被測試代碼——調(diào)用方法gameUnderTest.check(_:)。
  3. 在then部分中,斷言你期望的結(jié)果(在現(xiàn)在情況下,gameUnderTest.scoreRound的值是100-5):如果測試失敗則打印對應(yīng)的消息。

現(xiàn)在,你可以單擊測試導(dǎo)航器或者代碼左邊的鉆石圖標(biāo)按鈕運(yùn)行測試。你會注意到應(yīng)用程序?qū)⑦M(jìn)行構(gòu)建并運(yùn)行起來,最后鉆石圖標(biāo)將更改為一個綠色的對號標(biāo)記!
【注意】若要查看XCTestAssertions的完整列表,你可以在按下Command鍵的同時(shí)單擊代碼中的XCTAssertEqual打開文件XCTestAssertions.h。此外,你還可以參考蘋果官方網(wǎng)站提供的按類別提供的斷言列表
(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結(jié)構(gòu)來源于行為驅(qū)動測試(Behavior Driven Development,簡稱BDD)中的易于理解的行業(yè)術(shù)語。其實(shí),你還可以使用另外一些命名系統(tǒng),例如Arrange-Act-Assert和Assemble-Activate-Assert,等等。
調(diào)試一個測試
在BullsEyeGame工程中,我故意放置了一個錯誤?,F(xiàn)在,我們進(jìn)行測試,以便找到這個錯誤。為了觀察此錯誤導(dǎo)致的問題,請把testScoreIsComputed重新命名為testScoreIsComputedWhenGuessGTTarget,然后復(fù)制、粘貼并編輯它,從而創(chuàng)建另一個方法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值之間的區(qū)別仍然是5,因此分?jǐn)?shù)應(yīng)仍為95。
在斷點(diǎn)導(dǎo)航器中,添加一個測試失敗(Test Failure)斷點(diǎn);當(dāng)一個測試方法發(fā)出一個失敗的斷言時(shí)這將停止測試運(yùn)行。


現(xiàn)在運(yùn)行你的測試:它應(yīng)該在XCTAssertEqual一行停止,并出示一個測試錯誤。
然后,你可以在調(diào)試控制臺上觀察gameUnderTest和guess的輸出結(jié)果:


你應(yīng)該注意到:guess的值是-5,但scoreRound的值是105,而不是95!
為了進(jìn)一步分析,你可以使用通常的調(diào)試過程︰在when語句上設(shè)置一個斷點(diǎn),也在BullsEyeGame.swift文件上設(shè)置一個斷點(diǎn)——即在其中的方法check(:)上設(shè)置。然后,再次運(yùn)行測試,并以逐過程調(diào)試方式(即step-over)調(diào)試let語句來檢查應(yīng)用程序中的不同值

現(xiàn)在的問題是,差值是一個負(fù)數(shù);所以,得分是100-(-5)。解決方法是使用差異的絕對值即可。為此,在方法check(
:)中取消正確代碼前面的注釋,并刪除不正確的代碼即可。
刪除上面設(shè)置的兩個斷點(diǎn)并再一次運(yùn)行測試,以確認(rèn)上面代碼行現(xiàn)在已順利通過。
使用XCTestExpectation測試異步操作
到目前為止,你已經(jīng)學(xué)會了如何測試模型和調(diào)試測試失敗。接下來,讓我們繼續(xù)學(xué)習(xí)如何使用XCTestExpectation來測試網(wǎng)絡(luò)相關(guān)的操作。
首先,請打開HalfTunes項(xiàng)目。你會注意到,它使用URLSession來查詢iTunes API和下載歌曲樣本。假設(shè)您想修改它,以便使用AlamoFire進(jìn)行網(wǎng)絡(luò)操作。為了查看是否出現(xiàn)任何中斷情況,您應(yīng)為網(wǎng)絡(luò)操作編寫測試,并在更改代碼之前和之后運(yùn)行它們。
URLSession方法是異步執(zhí)行的︰它們會馬上返回,但只有運(yùn)行一段時(shí)間后才真正完成。為了測試異步方法,你應(yīng)使用XCTestExpectation使你的測試等待異步操作完成。
值得注意的是,異步測試通常很慢,所以你應(yīng)該把它們與你另外的一些運(yùn)行速度更快的單元測試分開。
從菜單“+”下選擇并運(yùn)行命令“New Unit Test Target…”,然后把目標(biāo)命名為HalfTunesSlowTests。然后,在import語句的下面導(dǎo)入HalfTunes程序:

@testable import HalfTunes 

在此類中的所有測試都將使用默認(rèn)會話把請求發(fā)送到蘋果公司的服務(wù)器。所以,我們在方法setup()中聲明并創(chuàng)建一個sessionUnderTest對象,然后在方法tearDown()中釋放它:

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

接下來,使用TestExample()函數(shù)來替換您的異步測試︰

//異步測試時(shí):成功測試很快,失敗測試卻比較慢 
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的有效的查詢是否能夠返回狀態(tài)碼200。顯然,其中大部分代碼與你在上面應(yīng)用程序中所寫的一樣,只是增加了如下幾行︰

1.expectation(:)返回一個XCTestExpectation對象;此對象存儲在變量promise中。此對象的其他常用名字是expectation和future。另外,description參數(shù)描述了你期望發(fā)生的事情。
2.為了匹配description參數(shù),您需要在異步方法的完成處理程序的成功條件閉包中調(diào)用promise.fulfill()。
3.waitForExpectations(
:handler:)的作用是保持所有測試在運(yùn)行中,直到所有的期望得以實(shí)現(xiàn),或者timeout值指定的時(shí)間間隔結(jié)束——無論兩者哪一種早發(fā)生都行。
現(xiàn)在,再來運(yùn)行該測試。如果你已經(jīng)連接到互聯(lián)網(wǎng),則當(dāng)應(yīng)用程序在模擬器中加載后成功測試大約花費(fèi)一秒鐘時(shí)間。
使測試失敗更快一些
測試失敗會導(dǎo)致不少問題,但它未必花費(fèi)很多時(shí)間?,F(xiàn)在,我們來解決如何快速確定是否您的測試失敗的問題。
為了修改一下您的測試,從而導(dǎo)致異步操作時(shí)失敗,你只需要從下面的URL中刪除“itunes”一詞后面的s字母即可:

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

運(yùn)行上述測試時(shí)︰它會失敗,而且此測試會花費(fèi)所有指定的超時(shí)間隔時(shí)間!這是因?yàn)樗钠谕钦埱蟪晒Α窃谶@個位置調(diào)用了promise.fulfill()方法。既然請求失敗,那么測試僅當(dāng)在超過指定時(shí)限時(shí)才結(jié)束。
你可以使這個測試失敗更快一些——這只要通過改變它的期望值即可達(dá)到︰不是等待請求成功,而只需要等到異步方法的完成處理程序觸發(fā)即可。只要應(yīng)用程序接收到來自服務(wù)器端的響應(yīng)(或者是成功或者是失敗)這種情況就會發(fā)生;但是,這的確符合預(yù)期結(jié)果。然后,您的測試可以檢查請求是否成功。
為了查看這是如何工作的,您要創(chuàng)建一個新的測試。首先,修復(fù)此測試——這可以通過撤消上面的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) 
} 

上面代碼中最關(guān)鍵的一點(diǎn)是,只需輸入完成處理程序?qū)崿F(xiàn)的期望——這需要大約一秒鐘即會發(fā)生。如果請求失敗,那么斷言也會失敗。
現(xiàn)在再來運(yùn)行上面的測試︰它現(xiàn)在大約需要一秒鐘即會失敗;它的失敗是因?yàn)檎埱笫×?,而不是因?yàn)闇y試運(yùn)行超時(shí)。
修復(fù)上面的url,然后再一次運(yùn)行測試,以確認(rèn)它現(xiàn)在能夠成功通過測試。
偽造對象和交互
異步測試能夠給你信心——你的代碼會為一個異步API提供正確的輸入。你可能也想測試您的代碼能夠正常工作——當(dāng)它從URLSession接收輸入時(shí),或當(dāng)它正確更新了UserDefaults或者CloudKit數(shù)據(jù)庫時(shí)。
大多數(shù)應(yīng)用程序都會與系統(tǒng)或庫對象(你不能控制這些對象)進(jìn)行交互,而與這些對象的交互測試很可能是極其緩慢的,而且不可重復(fù)的——這正違反了文章開始時(shí)FIRST原則中的兩條。相反,你可以偽造這些交互——通過從代理(stub)中獲取輸入或更新模擬對象(Mock Object)來實(shí)現(xiàn)。
當(dāng)您的代碼依賴于一個系統(tǒng)或庫中的對象時(shí),通過上面?zhèn)卧斓霓k法可以創(chuàng)建一個假的對象來實(shí)現(xiàn)那一部分功能并把這種偽造注入到您的代碼中。喬恩·里德的依賴性注入技術(shù)文章(https://www.objc.io/issues/15-testing/dependency-injection/)中就介紹了好幾種方法來達(dá)到這一目的。


從代理(stub)中偽造輸入
在本節(jié)中的測試中,你將要檢查應(yīng)用程序的updateSearchResults(_:)方法能夠正確解析由會話下載的數(shù)據(jù)——通過檢查屬性searchResults.count的值是正確的來實(shí)現(xiàn)。SUT是視圖控制器;你要使用代理(stub)技術(shù)來偽裝一個會話和一些預(yù)先下載的數(shù)據(jù)。
為此,從“+”菜單下選擇命令“New Unit Test Target…”并命名它為HalfTunesFakeTests。然后,在import語句的下面導(dǎo)入HalfTunes程序:

@testable import HalfTunes 

接下來,聲明SUT,并在setup()方法中創(chuàng)建它,且在tearDown()方法中對之進(jìn)行釋放:

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(被測系統(tǒng))是視圖控制器,因?yàn)镠alfTunes工程中擁有大量的視圖控制器問題——所有的工作都是在文件searchviewcontroller.swift中完成的?!皩⒕W(wǎng)絡(luò)代碼移動到單獨(dú)的模塊”(詳見文章http://williamboles.me/networking-with-nsoperation-as-your-wingman/)將會減少這一問題,而且也使測試更為容易。
接下來,您將需要一些樣本JSON數(shù)據(jù),供您的偽造的會話提供給你的測試使用。只需要做一少部分工作即可;因此,請限制一下您的來自iTunes的下載結(jié)果——在URL字符串的后面添加一個限制串&limit=3:
https://itunes.apple.com/search?media=music&entity=song&term=abba&limit=3
復(fù)制此URL并把它粘貼到瀏覽器中。這將下載一個名為1.txt或類似的文件。你可以預(yù)覽一下它,以便確認(rèn)這是一個JSON格式的文件,然后重命名它為abbaData.json,并把該文件添加到HalfTunesFakeTests組中。
HalfTunes項(xiàng)目包含了支持文件DHURLSessionMock.swift。這個文件中定義了一個簡單的協(xié)議——DHURLSession,其提供的方法(代理)用于使用一個URL或URLRequest來創(chuàng)建一個數(shù)據(jù)任務(wù)。它還定義了符合該協(xié)議的URLSessionMock對象,該對象中提供的初始化器可以讓你使用你選擇的數(shù)據(jù)、響應(yīng)和誤差等來創(chuàng)造一個模擬URLSession對象。
現(xiàn)在,我們來構(gòu)建偽造的數(shù)據(jù)和響應(yīng),并創(chuàng)建偽造的會話對象;這些都實(shí)現(xiàn)于方法setup()中,相應(yīng)的代碼位于創(chuàng)建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 

【注意】您將直接在您的測試中使用偽造的會話,但是這將向你展示如何注入這種偽造的會話;這樣一來,你進(jìn)一步的測試可以調(diào)用使用視圖控制器defaultSession屬性的SUT方法。
現(xiàn)在,您可以編寫測試來檢查是否調(diào)用updateSearchResults(_:)方法能夠解析偽造的數(shù)據(jù)。為此,請把TestExample()方法替換為以下內(nèi)容︰

//使用DHURLSession協(xié)議和代理偽造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") 
} 

注意,你仍然要以異步方式來編寫這個測試,因?yàn)榇?stub)假裝自己是一個異步的方法。
上面代碼中,when斷言的作用是:在數(shù)據(jù)任務(wù)運(yùn)行之前searchResults的值應(yīng)當(dāng)是空的——這應(yīng)該是真實(shí)情況,因?yàn)槟趕etup()方法中創(chuàng)建了一個全新的SUT。
偽造的數(shù)據(jù)包含了提供給三個跟蹤(Track)對象使用的JSON數(shù)據(jù);所以,then斷言的作用是:視圖控制器的searchResults數(shù)組應(yīng)當(dāng)包含三項(xiàng)。
再次運(yùn)行該測試。這次應(yīng)該成功,而且速度很快,因?yàn)椴淮嬖谌魏握鎸?shí)的網(wǎng)絡(luò)連接!
偽造對模擬對象的更新
以前的測試使用代理從假對象提供輸入。接下來,你可以使用一個模擬對象來測試你的代碼可以正確更新UserDefaults。
重新打開BullsEye項(xiàng)目。注意到,該應(yīng)用程序提供了兩種游戲風(fēng)格:用戶可以選擇移動滑塊來匹配目標(biāo)值或從滑塊位置猜測目標(biāo)值。借助于界面右下角的分段控制開關(guān)可以切換游戲風(fēng)格并更新用戶默認(rèn)的游戲風(fēng)格。
你要編寫的下一個測試將檢查應(yīng)用程序能夠正確地更新用戶默認(rèn)的游戲風(fēng)格數(shù)據(jù)。
在測試導(dǎo)航器中,點(diǎn)擊命令“New Unit Test Target…”,并命名為BullsEyeMockTests。然后,在導(dǎo)入語句下面添加以下內(nèi)容:

@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標(biāo)志的值加1。通常你會看到類似的測試中是設(shè)置一個布爾變量,但是在此我們使用一個整數(shù)值加1,這可以進(jìn)一步增加你的靈活控制——例如你的測試可以檢查該方法僅被正確地調(diào)用一次。
在BullsEyeMockTests類中聲明SUT對象和模擬對象:

var controllerUnderTest: ViewController! 
var mockUserDefaults: MockUserDefaults! 

在方法setup()中,創(chuàng)建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標(biāo)志的值為0——在測試方法觸發(fā)分段控制開關(guān)之前。因此,如果then斷言也為真,那么將意味著方法set(_:forKey:)僅被正確地調(diào)用一次。
現(xiàn)在再次運(yùn)行測試;應(yīng)當(dāng)可以成功。
在Xcode中進(jìn)行UI測試
Xcode 7中引入了對UI測試的支持,使您可以通過記錄與UI的交互來創(chuàng)建UI測試。UI測試的工作方式是:通過查詢來查找一個應(yīng)用程序的UI對象,進(jìn)而合成事件,然后將這些事件發(fā)送給這些對象。其提供的API使您可以檢查一個用戶界面對象的屬性和狀態(tài),以便把它們與預(yù)期的狀態(tài)進(jìn)行比較。
現(xiàn)在,讓我們在BullsEye項(xiàng)目的測試導(dǎo)航器中添加一個新的UI測試目標(biāo)。確保要被測試的目標(biāo)是BullsEye,然后接受默認(rèn)名稱BullsEyeUITests。
然后,在BullsEyeUITests類的頂部添加如下屬性︰

var app: XCUIApplication! 

在方法setup()中,用以下代碼替換XCUIApplication().launch()語句︰

app = XCUIApplication() 
 
app.launch() 

把testExample()的名字更改為testGameStyleSwitch()。
然后,在testGameStyleSwitch()中按下回車鍵創(chuàng)建一個新的空行,并點(diǎn)擊編輯器窗口底部的紅色的Record按鈕,如圖所示。


當(dāng)應(yīng)用程序出現(xiàn)在模擬器中時(shí),點(diǎn)擊控制游戲風(fēng)格開關(guān)的滑動塊及頂部標(biāo)簽。然后,單擊Xcode中的Record按鈕即可停止錄制。
現(xiàn)在,你在方法testGameStyleSwitch()中擁有以下三行代碼︰

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

如果還有其他的語句,則刪除它們。
第一行代碼的作用是復(fù)制你在setup()方法中創(chuàng)建的屬性;因?yàn)槟氵€不需要點(diǎn)擊任何東西,所以也把這第一行刪除,還要刪除第2行與第3行末尾的“.tap()”。打開["Slide"]鄰近的小菜單并選擇
segmentedControls.buttons["Slide"]。
于是,你有了如下的代碼:

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

進(jìn)一步修改上述代碼,以便創(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)在,你有了兩個按鈕和兩個可能的頂部標(biāo)簽的名稱,再添加以下內(nèi)容︰

// 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) 
 
} 

這段代碼將會檢測當(dāng)選中或者點(diǎn)擊每個按鈕時(shí)是否存在正確的標(biāo)簽?,F(xiàn)在,運(yùn)行測試——結(jié)果是所有斷言應(yīng)該都成功。
性能測試
根據(jù)蘋果公司官方文檔
(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)描述:一個性能測試需要使用你想要評估的一個代碼塊,并運(yùn)行此代碼塊10次,期間收集平均執(zhí)行時(shí)間和運(yùn)行的標(biāo)準(zhǔn)偏差值。這些個別測量的平均值成為測試運(yùn)行的一個值,然后把該值與一個基準(zhǔn)值進(jìn)行比較來評估成功或失敗。
寫一個性能測試還是非常簡單的︰你只需要把你想要測試的代碼放到measure()方法的閉包中即可。
為了實(shí)際體驗(yàn)一下,請重新打開HalfTunes項(xiàng)目,然后在HalfTunesFakeTests類中使用下面的測試,從而替換掉系統(tǒng)默認(rèn)生成的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)在,請運(yùn)行上面的測試,然后單擊measure()閉包末尾的圖標(biāo)來觀看統(tǒng)計(jì)信息。


單擊“Set Baseline”(設(shè)置基準(zhǔn)值)按鈕,然后再次運(yùn)行性能測試并查看結(jié)果——結(jié)果有可能比基準(zhǔn)值更好或更糟。你可以點(diǎn)擊Edit(編輯)按鈕幫助您將基準(zhǔn)值重置為這個新的結(jié)果。
基準(zhǔn)值在每個設(shè)備配置時(shí)存儲起來,所以你可以讓同一測試執(zhí)行在若干臺不同的設(shè)備上,并使每臺設(shè)備保持一個不同的基準(zhǔn)值——這要取決于處理器速度、內(nèi)存等的具體配置情況。
任何時(shí)候只要你更改一個應(yīng)用程序,都有可能影響正在測試的方法的性能;你可以再次運(yùn)行性能測試來觀察當(dāng)前值與基準(zhǔn)值比較的結(jié)果。
代碼覆蓋
代碼覆蓋工具能夠告訴你應(yīng)用程序中的哪些代碼實(shí)際上被您的測試運(yùn)行過;這樣一來,你就可以知道應(yīng)用程序代碼的哪些部分還沒有被測試。
【注意】在啟用代碼覆蓋功能時(shí)你是否應(yīng)該運(yùn)行性能測試呢?蘋果公司的文檔(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)中是這樣描述的︰代碼覆蓋數(shù)據(jù)集合會導(dǎo)致性能的下降……以線性方式影響代碼的執(zhí)行;因此,當(dāng)啟用代碼覆蓋功能時(shí)程序的性能將會因不同的測試運(yùn)行而有所差異。但是,當(dāng)你對你的測試中的例程要求極其嚴(yán)格時(shí)你應(yīng)該認(rèn)真考慮是否要啟用代碼覆蓋支持。
為了啟用代碼覆蓋功能,你可以編輯一下你預(yù)先計(jì)劃的測試(Test)操作并勾選“Code Coverage”復(fù)選框︰

運(yùn)行您的所有測試(按下組合鍵Command+U),然后打開報(bào)告導(dǎo)航器(按下組合鍵Command+8)。按執(zhí)行時(shí)間先后選擇(By Time,見下圖)列表中最上面的一項(xiàng),然后再選擇“Coverage”(覆蓋)選項(xiàng)卡。

你可以單擊如下圖展開的三角形圖標(biāo)來觀察SearchViewController.swift文件中的函數(shù)列表︰

你可以把鼠標(biāo)懸停在updateSearchResults(_:)方法附近的藍(lán)色的Coverage(覆蓋率)條上觀察到對應(yīng)的覆蓋率為71.88%。
單擊該函數(shù)對應(yīng)的箭頭按鈕來打開源文件,并定位到該函數(shù)。當(dāng)你的鼠標(biāo)移到右邊欄中的覆蓋率注釋上時(shí),代碼段將突出顯示為綠色或紅色︰

覆蓋率注釋上的信息顯示出一個測試中命中每個代碼段的次數(shù)。注意,沒有被調(diào)用到的代碼段部分突出顯示為紅色。正如你所期望的,for循環(huán)運(yùn)行3次,但沒有一次是沿著錯誤路徑執(zhí)行的。為了提高此函數(shù)的代碼覆蓋率,你可以復(fù)制abbaData.json,然后修改它,使其會導(dǎo)致不同的錯誤——例如,將“results”更改為“result”來測試執(zhí)行到打印語句print("Results key not found in dictionary")的情況。
100%覆蓋?
爭取實(shí)現(xiàn)100%的代碼覆蓋率你可知道應(yīng)該付出怎樣的努力嗎?如果你使用谷歌搜索引擎搜索“100% unit test coverage”的話,你會搜索到有贊同的也有反對的等多種觀點(diǎn),以及圍繞100%覆蓋率的大量爭論。其中,持反對看法的認(rèn)為最后的10-15%并不重要——不值得為之付出努力;而持贊同看法的認(rèn)為最后的10-15%極其重要——因?yàn)樗茈y測試。再使用谷歌搜索引擎搜索“hard to unit test bad design”可以找到頗有說服力的論據(jù)——無法驗(yàn)證的代碼是一種更深層次的設(shè)計(jì)問題(https://www.toptal.com/qa/how-to-write-testable-code-and-why-it-matters)。進(jìn)一步的思考可能導(dǎo)致的結(jié)論是測試驅(qū)動開發(fā)(http://qualitycoding.org/tdd-sample-archives/)是軟件開發(fā)過程中必須要走的路。
總結(jié)
本文中已經(jīng)向你提供了為你的iOS工程編寫測試的多種工具。我希望你能夠通過本教程的學(xué)習(xí)樹立起足夠的信心來測試一切!
你可以從地址https://koenig-media.raywenderlich.com/uploads/2016/12/Finished-3.zip處下載本文中的完整的示例工程源碼。
最后,下面提供的一些資源可以供你作進(jìn)一步學(xué)習(xí)測試使用:
既然通過本文學(xué)習(xí)你已經(jīng)學(xué)會了為你的項(xiàng)目編寫測試,那么你下一步要了解的應(yīng)當(dāng)是自動化測試相關(guān)的主題。為此,你可以首先學(xué)習(xí)蘋果官方的基于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),以及發(fā)表在Wikipedia上的相關(guān)連載文章(https://en.wikipedia.org/wiki/Continuous_delivery),來源于ThoughtWorks網(wǎng)站(https://www.thoughtworks.com/continuous-delivery)上的一位資深專家的文章。
使用Swift Playgrounds進(jìn)行測試驅(qū)動開發(fā)(http://initwithstyle.net/2015/11/tdd-in-swift-playgrounds/)。你可以在Playgrounds環(huán)境下使用XCTestObservationCenter來運(yùn)行XCTestCase單元測試。你可以在Playgrounds中開發(fā)你的工程代碼并進(jìn)行測試,然后把二者都轉(zhuǎn)換成你的應(yīng)用程序。
來自CMD+U協(xié)會(http://www.cmduconf.com/)的教程告訴你如何使用PivotalCoreKit(https://github.com/pivotal/PivotalCoreKit)來測試watchOS應(yīng)用程序。
如果你已經(jīng)編寫了一個應(yīng)用程序,而只是沒有為它編寫測試,你可以參閱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),因?yàn)椴话瑴y試的代碼往往都是遺留下來的代碼!
Jon Reid的高質(zhì)量編碼示例編程文章(http://qualitycoding.org/tdd-sample-archives/)也是你學(xué)習(xí)測試驅(qū)動開發(fā)的極好去處。

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

相關(guān)閱讀更多精彩內(nèi)容

  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 136,506評論 19 139
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,733評論 25 709
  • 怎么樣花錢最開心 1.目的 從這篇文章得到我賺錢的目的就是運(yùn)用錢去換取一些能給我?guī)砜鞓返臇|西,根據(jù)三腦原理-我們...
    思遠(yuǎn)同學(xué)閱讀 210評論 2 0
  • 文/懶橘子 1.相識 對“小蠻腰”的印象始于6年前,一個初夏的下午,《中國國...
    懶橘子閱讀 747評論 2 4
  • 【人物:A(女)B(男)】 【 關(guān)系:不詳】 【地點(diǎn):遙遠(yuǎn)】 【時(shí)間:深夜】 A:好想喝酒,一醉方休。 B:又怎么...
    何鵬在簡書閱讀 890評論 0 3

友情鏈接更多精彩內(nèi)容