單元測試
什么是單元測試
本文中Demo:在這里
單元測試(unit testing),是指對軟件中的最小可測試單元進行檢查和驗證。
FIRST 原則
Fast:要快。畢竟調(diào)試bug時需要頻繁運行單元測試驗證結(jié)果,更快地測試就省去很多時間。
Independent / Isolated:隔離性。測試之間相互獨立。
Repeatable:可重復(fù)性。同一個測試,每次測試結(jié)果應(yīng)相同。
Self-validating:自我驗證。單元測試需要采用Asset函數(shù)等進行自驗證,即當(dāng)單元測試執(zhí)行完畢之后就可得知測試結(jié)果(成功 or 失敗),全程無需人工接入。
-
Timely:及時。等代碼穩(wěn)定運行再來補齊單元測試可能是低效的,先編寫測試,后編寫產(chǎn)品代碼才是最有效的方式。
要進行單元測試,前提是要保證代碼是“可測試的”。這就要求我們在代碼結(jié)構(gòu)設(shè)計開始階段,更好地去思考模塊劃分是否合理,解耦是否到位,如果能mock掉數(shù)據(jù)庫、網(wǎng)絡(luò)操作、UI等等,還是可以獨立工作的話,那他們之間的耦合程度肯定很低。
那如何松耦合,保證代碼可測試性呢?
class Dog {
func eat(foodName: String) {
print("吃\(foodName)")
}
}
class Person {
let dog = Dog()
let foodName = "骨頭"
func feed(){
//其他校驗內(nèi)容
dog.eat(foodName: foodName)
}
}
上面的代碼就不好測試,主要有以下幾個問題:
- dog,foodName不可控,都是由Person內(nèi)部自己管理,所以說耦合度很高
- 沒法快速驗證dog.eat這個方法是否成功調(diào)用
第一個問題通過依賴注入解決:
protocol Pet {
func eat(foodName: String)
}
class Dog: Pet {
func eat(foodName: String) {
print("吃\(foodName)")
}
}
class Person {
let pet: Pet
let foodName: String
init(pet: Pet, foodName: String) {
self.pet = pet
self.foodName = foodName
}
func feed(){
pet.eat(foodName: foodName)
}
通過依賴注入,外部去創(chuàng)建pet和foodName,無論你是養(yǎng)個狗,還是貓,是喂狗糧還是貓糧,都有外部決定,這樣就降低了耦合度。
第二個問題通過Mock解決,所謂Mock就是模擬出我們想要的內(nèi)容:
class MockPet: Pet {
var wasFeed = false
func eat(foodName: String) {
wasFeed = true
}
}
func testFeed() {
let cat = MockPet()
let james = Person(pet: cat, foodName: "小魚干")
james.feed()
XCTAssertTrue(cat.wasFeed, "james should have feed his cat")
}
就這樣,通過一個模擬類,我們就可以很方便地編寫asset,標(biāo)記eat方法已經(jīng)調(diào)用。
為什么要做單元測試
我們最開始學(xué)習(xí)程序編寫時,最喜歡干的事情就是編寫一段代碼,然后run起來,再點擊一系列按鈕之后到達(dá)相關(guān)界面去查看結(jié)果,如果不對就返回代碼檢查錯誤,進行各種調(diào)試修改,然后再次運行查看效果是否符合預(yù)期。這樣一次一次地等待編譯部署,啟動程序然后操作UI,再一直點到相應(yīng)的界面去驗證結(jié)果,難道不是在浪費生命嗎?
所以,單元測試必不可少!
通過單元測試,我們至少可以收獲以下好處:
在一個復(fù)雜的項目中添加某功能模塊時,可以快捷的進行針對性測試,而不用將整個項目 Run 起來。
可以放心修改、重構(gòu)業(yè)務(wù)代碼,而不用擔(dān)心修改某處代碼后帶來的副作用。(因為每次修改,都要保證測試用例能通過)
幫助反思模塊劃分的合理性,解耦是否到位。(如果一個單元測試寫得邏輯非常復(fù)雜、或者說一個函數(shù)復(fù)雜到無法寫單測,那就說明模塊的抽象有問題)
能提高代碼可維護性、可讀性。新加入團隊的成員,可以從單元測試入手,比文檔更容易被程序員接受。
保證代碼被測試,更容易及早發(fā)現(xiàn)問題,降低風(fēng)險。
總之,一句話,單元測試能提高代碼質(zhì)量和可維護性。
測試哪些內(nèi)容
單元測試側(cè)重的是邏輯測試和接口測試,一般來說,測試應(yīng)該包括:
公共類中的公開方法
網(wǎng)絡(luò)數(shù)據(jù)層
業(yè)務(wù)邏輯層
修復(fù) Bug 的測試
實際操作過程中,要自下而上進行單元測試。從最基礎(chǔ)的 Base 層,往上寫測試。確?;A(chǔ)的 Model,Manager 測試通過,才開始為 Controller 編寫測試,因為這部分業(yè)務(wù)是最復(fù)雜的,也是最容易改變的。
注意??:編寫單元測試需要注意的一點是責(zé)任分離。即你的測試只需要針對特定單元內(nèi)部的邏輯,至于其他模塊是否正確,是由該模塊的編寫者來負(fù)責(zé)測試的。
測試用例可以按以下三步執(zhí)行:
- Given:配置測試的初始狀態(tài)
- When:對要測試的目標(biāo)執(zhí)行代碼
- Then:對測試結(jié)果進行斷言(成功 or 失?。?/li>
這樣我們一眼就能看出這個 case 大體上是在干嘛。
func testExample1() throws {
// Given
let str = "welcome to the world"
// When
let headLine = vc.makeHeadline(string: str)
// Then
XCTAssertEqual(headLine, "Welcome To The World")
}
基本使用
如下圖,可新增相關(guān)測試target、class。

運行方式:
- Product ? Test or Command-U,跑所有測試用例
- 測試導(dǎo)航條右側(cè)箭頭
- 測試用例左側(cè)菱形按鈕

異步測試
異步測試通常是比較慢的,應(yīng)該單獨出一個target去專門進行測試。
下面我們用XCTestExpectation來測試異步操作:
func testValidRequestGetsHTTPStatusCode200() throws {
// given
let urlString =
"http://m.itdecent.cn/u/02d76422b530"
let url = URL(string: urlString)!
// 1
let promise = expectation(description: "Status code: 200")
// when
let dataTask = session.dataTask(with: url) { _, 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
wait(for: [promise], timeout: 5)
}
CMD+U運行,測試通過!
上面我們大致做了幾件事:
- 調(diào)用expectation()方法創(chuàng)建一個返回狀態(tài)碼為200的實例
- 運行異步請求,請求成功后調(diào)用fulfill()標(biāo)記這個expectation被完成
- 最后要調(diào)用wait方法,不然這次測試就會過早的結(jié)束,它會保證測試的運行直到當(dāng)所有expectation完成或者過時了才會停止測試。
但是上面的測試其實有一個問題,我們接下來換成一個無效的url:
let urlString =
"http://m.itdecent.cn/u/02d76422b530test"
再次運行,測試不通過,但是此次測試運行完timeout的時間才結(jié)束,原因就是我們在請求成功才調(diào)用fulfill(),那現(xiàn)在無效的url的請求就會一直持續(xù)到即將超時才被完成。
顯然,我們想要的結(jié)果是:一旦請求有響應(yīng)返回,無論是sucess還是error,都去fulfill這個expectation,然后我們直接斷言判斷結(jié)果即可。
func testApiCallCompletes() throws {
// given
// let urlString = "http://m.itdecent.cn/u/02d76422b530"
let urlString = "http://m.itdecent.cn/u/02d76422b530test"
let url = URL(string: urlString)!
let promise = expectation(description: "Completion handler invoked")
var statusCode: Int?
var responseError: Error?
// when
let dataTask = session.dataTask(with: url) { _, response, error in
statusCode = (response as? HTTPURLResponse)?.statusCode
responseError = error
promise.fulfill()
}
dataTask.resume()
wait(for: [promise], timeout: 5)
// then
XCTAssertNil(responseError)
XCTAssertEqual(statusCode, 200)
}
CMD+U運行,請求fail后,斷言fail,測試立馬結(jié)束,不用等timeout才結(jié)束。
Stub
實際工作中你會發(fā)現(xiàn),很多時候有些代碼是“無法測試”的,因為代碼之間存在較高的耦合程度,類與類之間有復(fù)雜的依賴性,而我們肯定不能依賴于一個沒有經(jīng)過測試的類去對另一個需要測試的類進行測試,因為這樣測試得到的結(jié)果,我們無法驗證它的正確性(不能排除測試成功,但是其實是因為未測試的依賴類恰好失敗了而恰巧得到的正確結(jié)果的可能性)。
class Reposity {
var bodyHelper = BodyHelper()
func saveResultWith(height: Float, weight: Float) {
let result = bodyHelper.getResultWith(height: height, weight: weight)
self.save(result: result)
}
}
比如上面,Reposity中的saveResultWith方法中依賴了BodyHelper的getResultWith方法的結(jié)果,這樣如果后續(xù)getResultWith中算法實現(xiàn)有所更改,會直接導(dǎo)致我們測試結(jié)果失敗。而我們更多的是關(guān)心save方法的正確性,不關(guān)心計算結(jié)果方法的細(xì)節(jié)。
為了解決這種問題,我們需要編寫一個樁程序,即Stub,它可以讓一個對象對某個方法返回我們預(yù)先定好的數(shù)據(jù)。
bodyHelper.stub(selector: #selector(bodyHelper.getResultWith(height:weight:)), andResult: mockResult, withArguments: height, weight)
想一下,如何使用XCTests去stub一個網(wǎng)絡(luò)請求?
創(chuàng)建一個Stub,URLSessionStub去模擬返回我們想要的數(shù)據(jù):
typealias DataTaskCompletionHandler = (Data?, URLResponse?, Error?) -> Void
protocol URLSessionProtocol {
func dataTask(
with url: URL,
completionHandler: @escaping DataTaskCompletionHandler
) -> URLSessionDataTask
}
extension URLSession: URLSessionProtocol { }
class URLSessionStub: URLSessionProtocol {
private let stubbedData: Data?
private let stubbedResponse: URLResponse?
private let stubbedError: Error?
public init(data: Data? = nil, response: URLResponse? = nil, error: Error? = nil) {
self.stubbedData = data
self.stubbedResponse = response
self.stubbedError = error
}
public func dataTask(
with url: URL,
completionHandler: @escaping DataTaskCompletionHandler
) -> URLSessionDataTask {
URLSessionDataTaskStub(
stubbedData: stubbedData,
stubbedResponse: stubbedResponse,
stubbedError: stubbedError,
completionHandler: completionHandler
)
}
}
class URLSessionDataTaskStub: URLSessionDataTask {
private let stubbedData: Data?
private let stubbedResponse: URLResponse?
private let stubbedError: Error?
private let completionHandler: DataTaskCompletionHandler?
init(
stubbedData: Data? = nil,
stubbedResponse: URLResponse? = nil,
stubbedError: Error? = nil,
completionHandler: DataTaskCompletionHandler? = nil
) {
self.stubbedData = stubbedData
self.stubbedResponse = stubbedResponse
self.stubbedError = stubbedError
self.completionHandler = completionHandler
}
override func resume() {
completionHandler?(stubbedData, stubbedResponse, stubbedError)
}
}
viewModel中請求方法如下:
class ViewModel: NSObject {
var session: URLSessionProtocol = URLSession.shared
func getData(url: URL, result: @escaping (Int) -> ()) {
let task = session.myDataTask(with: url) { data, response, error in
guard let data = data else { return }
guard let value = try? JSONDecoder().decode([Int].self, from: data).first else { return }
result(value)
}
task.resume()
}
}
測試用例如下:
func testStubData() {
// given
// 1
let stubbedData = "[1]".data(using: .utf8)
let str = "http://m.itdecent.cn/u/02d76422b530"
guard let url = URL(string: str) else { return }
let stubbedResponse = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil)
let urlSessionStub = URLSessionStub(
data: stubbedData,
response: stubbedResponse,
error: nil)
let promise = expectation(description: "Completion handler invoked")
// when
vm?.session = urlSessionStub
vm?.getData(url: url) { result in
XCTAssertEqual(result, 1)
promise.fulfill()
}
wait(for: [promise], timeout: 5)
}
Mock
mock是一種更復(fù)雜更智能的stub。更復(fù)雜更智能體現(xiàn)在,我們可以為創(chuàng)造的 mock 定義在某種輸入和方法調(diào)用下的輸出,甚至為 mock 設(shè)定期望。
mock 與 stub 最大的區(qū)別在于 stub 只是簡單的方法替換,一般不會新增對象,而mock 是對現(xiàn)有類的行為一種模擬(或是對現(xiàn)有接口實現(xiàn)的模擬),會新增模擬出一個對象,并遵循類的定義相應(yīng)某些方法。
如何UI測試

我們可以新建UI測試文件:

要想進行UI測試,我們需要一個關(guān)鍵的類XCUIApplication,創(chuàng)建并啟動它:
let app = XCUIApplication()
app.launch()
點擊xcode底部紅色的記錄按鈕,開啟記錄UI操作功能

測試app會被啟動,然后你在界面上進行操作,比如點擊segment、label,此時xcode會同步生成代碼記錄你操作的UI控件,再次點擊紅色的記錄按鈕去中止UI記錄:

可以看到buttons[First]和buttons[Second]右上角有下三角,點擊可以展開,選segmentedControls.buttons[First]和segmentedControls.buttons[Second],同時去掉tap

最終,我們拿到界面上的UI元素, cmd+u運行,測試通過!

如何性能測試
舉例:求第n個斐波那契數(shù),
0 1 1 2 3 ..N
這里舉例了兩種算法:
// o(2^n) 0 1 1 2 3 ..N
func fib1(_ n: Int) -> Int {
if n <= 1 {
return n
}
return fib1(n - 1) + fib1(n - 2)
}
// o(n)
func fib2(_ n: Int) -> Int {
if (n <= 1) {
return n
}
var first = 0
var second = 1
for _ in 0..<n - 1 {
let sum = first + second
first = second
second = sum
}
return second
}
我們就可以將兩種方法放到XCTest的measure方法的閉包中測試時間了,cmd+u運行,發(fā)現(xiàn)第二種算法耗時更少,算法更優(yōu)。
點擊measure方法左側(cè)灰色菱形展開,可以看到運行的一些指標(biāo)。

- Metric:時間作為性能的指標(biāo)
- Average:表示平均時間
- Baseline:表示你設(shè)置一個基線
- Result:是指平均時間和你是設(shè)置的基線進行比較后得出的結(jié)果,百分比表示的
- max STDDEV :表示標(biāo)準(zhǔn)偏差 10%。
點擊Edit,我們可以設(shè)置Baseline,ax STDDEV ,來設(shè)置覺得滿意的性能測試條件底部點擊1,2…可以看到每次運行的結(jié)果。
TDD-測試驅(qū)動開發(fā)
什么是TDD
TDD是測試驅(qū)動開發(fā)(Test-Driven Development)的英文簡稱,是一種流行的軟件編寫方式,是敏捷開發(fā)中的一項核心實踐和技術(shù),也是一種設(shè)計方法論。
在進行單元測試時,我們一般能想到的是先編寫業(yè)務(wù)代碼,然后再編寫相應(yīng)測試代碼,去驗證產(chǎn)品方法是否符合預(yù)期。而TDD的思想正好相反,TDD的思想是先根據(jù)需求或者接口情況編寫測試,然后再根據(jù)測試來編寫業(yè)務(wù)代碼。
典型的TDD遵循以下流程:

- 紅:先寫一個會fail的測試
- 綠:補上足夠的代碼以使測試通過
- Refactor:清理和優(yōu)化代碼
重復(fù)以上步驟直至你對所有用例滿意為止
再詳細(xì)點,測試驅(qū)動開發(fā)的基本過程如下:
- 明確當(dāng)前要完成的功能。記錄成一個 TODO 列表。
- 快速完成針對此功能的測試用例編寫。
- 測試代碼編譯不通過。
- 編寫對應(yīng)的功能代碼。
- 測試通過。
- 對代碼進行重構(gòu),并保證測試通過。
- 循環(huán)完成所有功能的開發(fā)。
簡單說來,TDD是一種通過進行許多由測試支持的小更改去完成功能開發(fā)的方法,它的基本步驟就是“測試失敗→編寫代碼努力讓測試通過→再大膽重構(gòu),優(yōu)化代碼”。
為什么要TDD
上面我們說了,一般我們都是先編寫所有代碼,然后在寫測試,或者干脆不編寫任何測試代碼,直接運行后,一頓點擊,到相關(guān)頁面去手動測試,查看效果。而TDD卻是先寫測試代碼,再寫業(yè)務(wù)代碼。
那TDD到底有什么好處呢?
打個比如,就像砌磚一樣,老師傅會先拉一條垂線,然后沿著線砌磚;而新手往往直接開工,砌完后再進行測量和修補。老師傅的做法就是TDD,總是先測試,再編碼,有了測試的準(zhǔn)繩,你可以有目的有方向地編碼;而且有了測試的保護,你可以放心對原有代碼進行重構(gòu),而不必?fù)?dān)心破壞邏輯。
從流程上看,TDD提供了確保測試良好的方法:
- 在編寫新的測試之前,所有其他以前的測試都必須通過。這確保了測試的可重復(fù)性:不只是運行正在進行的單個測試,而是不斷地運行所有測試。
- 重構(gòu)時,同時更新代碼和測試代碼。這可以確保測試用例能夠得到很好的維護。
- 通過并行迭代編寫代碼和測試,可以確保代碼是可測試的。如果在完成代碼后編寫測試,那么代碼很可能需要相當(dāng)多的重構(gòu)才能完成單元測試。
如何進行TDD開發(fā)
我們來實現(xiàn)一個小需求:一個句子中單詞首字母大寫轉(zhuǎn)換。
在 ViewController.swift 中,添加一個方法:
func makeHeadline(string: String) -> String {
return ""
}
Test1
在測試代碼如下:
func testExample1() throws {
let str = "welcome to the world"
let headLine = vc.makeHeadline(string: str)
XCTAssertEqual(headLine, "Welcome To The World")
}
很顯然,測試不通過。
我們改下方法實現(xiàn):
func makeHeadline(string: String) -> String {
return "Welcome To The World"
}
Command + U,Test1測試通過。
當(dāng)然,為了確保此次測試用例的結(jié)果不是偶然的,我們要多寫用例覆蓋到所有場景,來保證該功能確實是通過的。
Test2
測試用例2代碼:
func testExample2() throws {
let str = "here is another example"
let headLine = vc.makeHeadline(string: str)
XCTAssertEqual(headLine, "Here Is Another Example")
}
很顯然,測試又不通過。
我們再改下方法實現(xiàn):
func makeHeadline(string: String) -> String {
let words = string.components(separatedBy: " ")
var headline = ""
for var word in words {
let firstCharacter = word.remove(at: word.startIndex)
headline += "\(firstCharacter.uppercased())\(word) "
}
headline.removeLast()
return headline
}
Command + U,Test2測試通過。
重構(gòu)代碼
到這里我們的測試就算通過了,但有以下問題需要優(yōu)化:
- 測試用例描述的不太清楚
- makeHeadline 函數(shù)的實現(xiàn)中遍歷可以用上 Swift 里的高級功能,會更簡潔
可調(diào)整如下:
func testExample3() throws {
let inputString = "here is another example"
let expectedHeadline = "Here Is Another Example"
let result = vc.makeHeadline(string: inputString)
XCTAssertEqual(result, expectedHeadline)
}
func makeHeadline(string: String) -> String {
let words = string.components(separatedBy: " ")
let headline = words.map { word in
var word = word
let firstCharacter = word.remove(at: word.startIndex)
return "\(firstCharacter.uppercased())\(word)"
}.joined(separator: " ")
return headline
}
到此為止,我們就以TDD的開發(fā)方式實現(xiàn)了一個小功能。
缺點
代碼量增加,時間成本增加
編寫測試代碼就意味著代碼量的增加,所以感覺上花費的時間成本也就變大。
但是,往往開發(fā)的成本不僅僅是最開始編寫的第一個版本的代碼。它還包括隨著時間的推移,添加新功能、修改現(xiàn)有代碼、修復(fù)錯誤等等。從長遠(yuǎn)來看,遵循 TDD 比不遵循 TDD 花費的時間要少得多,因為它的代碼更易于維護,錯誤更少。
而且一旦你習(xí)慣了,TDD 會變得更快,只是剛剛開始用 TDD 可能需要更多的時間去熟悉。
更重要的是,如果在開發(fā)過程中發(fā)現(xiàn)了某個問題,那調(diào)試起來更容易,也能更快修復(fù)。而如果你在幾周后才發(fā)現(xiàn)該問題,你要浪費更多的時間去回憶相關(guān)功能,去定位問題。
而生產(chǎn)中的缺陷被發(fā)現(xiàn)的時間越滯后,對客戶的影響就越大,從而導(dǎo)致負(fù)面評論、失去信任和收入損失。
測試用例過于專業(yè),不便于團隊協(xié)作
BDD
小結(jié)
大致總結(jié)一下:
原理:是在開發(fā)功能代碼之前,先編寫單元測試用例代碼,測試代碼確定需要編寫什么產(chǎn)品代碼。
優(yōu)點:
- 可以保證代碼的質(zhì)量??梢詫ψ约旱乃枰臉I(yè)務(wù)功能的每一步設(shè)計進行驗證,并得到正確的結(jié)果,減少bug的出現(xiàn)的,特別對于復(fù)雜業(yè)務(wù)邏輯的項目,以小步慢走的方式,避免后期繁重的測試和維護工作。
- 可以放心對原有代碼進行重構(gòu),而不必?fù)?dān)心破壞邏輯,必要時候你還可以痛痛快快的并且滿懷信心的對代碼做一場大的變革。這樣我們的代碼變得干凈了,代碼有了更好的擴展性、可維護性以及易理解性。
缺點:
增加代碼量。測試代碼是系統(tǒng)代碼的兩倍或更多,但是同時節(jié)省了調(diào)試程序及挑錯時間。
原則:
- 獨立測試:不同代碼的測試應(yīng)該相互獨立,一個類對應(yīng)一個測試類,一個函數(shù)對應(yīng)一個測試函數(shù)。用例也應(yīng)各自獨立,每個用例不能使用其他用例的結(jié)果數(shù)據(jù),結(jié)果也不能依賴于用例執(zhí)行順序。 一個角色:開發(fā)過程包含多種工作,如:編寫測試代碼、編寫產(chǎn)品代碼、代碼重構(gòu)等。做不同的工作時,應(yīng)專注于當(dāng)前的角色,不要過多考慮其他方面的細(xì)節(jié)。
- 測試列表:代碼的功能點可能很多,并且需求可能是陸續(xù)出現(xiàn)的,任何階段想添加功能時,應(yīng)把相關(guān)功能點加到測試列表中,然后才能繼續(xù)手頭工作,避免疏漏。
- 測試驅(qū)動:即利用測試來驅(qū)動開發(fā),是TDD的核心。要實現(xiàn)某個功能,要編寫某個類或某個函數(shù),應(yīng)首先編寫測試代碼,明確這個類、這個函數(shù)如何使用,如何測試,然后在對其進行設(shè)計、編碼。
- 先寫斷言:編寫測試代碼時,應(yīng)該首先編寫判斷代碼功能的斷言語句,然后編寫必要的輔助語句。
- 可測試性:產(chǎn)品代碼設(shè)計、開發(fā)時應(yīng)盡可能提高可測試性,要符合單一職責(zé)。每個代碼單元的功能應(yīng)該比較單純,“各家自掃門前雪”,每個類、每個函數(shù)應(yīng)該只做它該做的事,不要弄成大雜燴。尤其是增加新功能時,不要為了圖一時之便,隨便在原有代碼中添加功能。
- 及時重構(gòu):對結(jié)構(gòu)不合理,重復(fù)的代碼,在測試通過后,應(yīng)及時進行重構(gòu)。
- 小步前進:軟件開發(fā)是復(fù)雜性非常高的工作,小步前進是降低復(fù)雜性的好辦法。
如何查看測試報告、覆蓋率

cmd+9,查看報告:

點擊方法右側(cè)箭頭,跳到代碼區(qū):

右側(cè)覆蓋率注釋顯示測試命中每個代碼段的次數(shù)。未調(diào)用的部分以紅色突出顯示。