開始寫測試前必須先瞭解該平台用來寫測試的套件或框架,
例如 C# 的 MSTest、Java 的 JUnit、JavaScript 的 JsUnit,
當然在 iOS 平台上也有專屬的測試框架 XCTest。

XCTest

XCTest 是 Apple 官方出的測試框架,可以用來建立 Unit Testing、Performance Testing、UI Testing。

Unit Testing Bundle

Unit Testing Bundle 則是 Xcode 內建用來做單元測試的 Target。

初始化

  1. 新增 Target > iOS Unit Testing Bundle (在建立專案時其實也可以直接勾選 Include Tests)

  2. 命名 Target 名稱 > 完成安裝

  3. 通常需要測試的物件都在專案裡,所以 TestAppTests.swift 需要在最上面加上 @testable import TestApp

  4. 也可以自行新增測試檔案進行分類,建議是一個類別對一個測試檔案,不要把所有類別的測試都寫在同一個檔案

撰寫測試

Setup 和 Teardown

  • SetUp:在測試執行前做一些初始化的設定。
  • TearDown:測試結束後,在這裡清除資料或設定,確保不會留下任何可能影響後續測試的東西。

目前官方有提供很多種進階用法 Set Up and Tear Down State in Your Tests
例如初始化方式就有分同步或非同步,
或是提供 addTeardownBlock 來定義特定測試的 TearDown。

1
2
3
4
5
6
7
8
9
override func setUp() async throws {
// 非同步執行初始化設定,並且可以 throws error
}
override func setUpWithError() throws {
// 同步執行初始化設定,並且可以 throws error
}
override func setUp() {
// 同步執行初始化設定
}

XCTAssert

用來驗證結果是否如預期,有很多種判斷方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let result = true
let data = 100
let object: Date? = nil
// 判斷是否為True
XCTAssert(result)
// 判斷是否為false
XCTAssertFalse(result)
// 判斷是否相同
XCTAssertEqual(data, 100)
// 判斷是否不相同
XCTAssertNotEqual(data, 0)
// 判斷是否為空
XCTAssertNil(object)
// 判斷是否不為空
XCTAssertNotNil(object)
// 無條件失敗
XCTFail()

執行方式

xcode 的介面上就有三種按法可以跑測試,當然也可以用終端機跑測試。
第一種比較特別,是要設定過 Scheme,並且長按編譯按鈕,然後改成執行測試。
第二或三種比較單純,直接執行單一檔案或是單一測試。

單元測試命名

  • 必須以前綴 test 開頭、不帶參數、不返回值,是否用底線由團隊而定
  • 最好名稱能夠敘述測試的內容,或是包含預期的結果
1
2
3
4
test_NumberTool_IsPositive()
testNumberToolIsPositive()
testDataManagerGetDataFromServerStatusCode200()
testDataManagerGetDataFromServerStatusCode400()

如何把程式寫成可測試

  1. 乾淨的架構 ( MVC、MVVM、VIPER、Clean 等等架構,架構的細膩程度會直接影響能測試的程度 )
  2. 職責單一的物件
  3. 抽離檔案系統 ( UserDefault or File )、資料庫 ( DataBase )、遠端資料 ( Api )
    • 抽離的方式最常見的就是使用 Protocol 抽離實作,並且使用依賴注入 ( DI ) 換成假資料
    • 或是使用一些實作好 Mock 的套件,例如 MockUserDefaults

常見的測試案例

對 Model 測試

可測試 Model 的建構式或是方法。

Cake.swift
1
2
3
4
5
6
7
8
struct Cake{
let name: String
let price: Double
func getPrice(discount: Double) -> Double?{
guard discount <= 1.0 && discount >= 0.0 else { return nil }
return price * (1 - discount)
}
}
CakeTests.swift
1
2
3
4
5
func testCakeGetPrice() throws {
let cake = Cake(name: "Strawberry Cake", price: 120)
let result = cake.getPrice(discount: 0.2)
XCTAssertEqual(result, 96)
}

對 API 測試

針對 Server 呼叫 API 進行測試,確認回應物件是否如預期,
因為會依賴真實伺服器,所以不是單元測試而是整合測試。

官方針對非同步測試也有寫文章 - Testing Asynchronous Operations with Expectations
需要建立 XCTestExpectation 並使用 wait,才能確保非同步下測試會正常。

DataManagerTests.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func testDataManagerGetData() {

// 宣告expectation
let expect = expectation(description: "DataManager getData")
let dataManager = DataManager()
dataManager.getData() {
result in
switch result {
case .success(let data):
XCTAssert(true)
case .failure(let error):
XCTAssert(false)
}
// 完成結果
expect.fulfill()
}
// 等待非同步結果,timeout 時間為 30 秒
wait(for: [expect], timeout: 30.0)
}

對真實環境的抽離並測試

當遇到檔案系統、資料庫、遠端資料,可用 Protocol 抽離實作並依賴注入(DI)
利用假資料來進行完整的商業邏輯的測試。

下面有四步驟:

  1. 將呼叫遠端資料的邏輯都封裝在一個物件 CakeAPIProvider,好處是方便抽離
  2. 建立一個 protocol CakeProvider,把要抽離的方法都定義出來
  3. 真正在管理資料的 CakeManager 只要使用該 protocol 呼叫各種商業邏輯
CakeProvider.swift CakeAPIProvider.swift CakeManager.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
protocol CakeProvider{
func getData(completion: @escaping ((Result<Cake,Error>)->Void))
}

class CakeAPIProvider: CakeProvider{
func getData(completion: @escaping ((Result<Cake, Error>) -> Void)) {
guard let url = URL(string: "...") else { return }
AF.request(url).response{
response in
//completion(...)
}
}
}

class CakeManager{
var provider: CakeProvider = CakeAPIProvider()
func getCakePrice(completion: @escaping ((Result<Double, Error>) -> Void)){
provider.getData {
result in
switch result{
case .success(let cake):
completion(.success(cake.price))
case .failure(let error):
completion(.failure(error))
}
}
}
//func ...
//func ...
}
  1. 到時候要進行測試或抽換實作時,只要換 CakeManager 的 provider 即可!
CakeManagerTests.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class CakeMockProvider: CakeProvider{
func getData(completion: @escaping ((Result<Cake, Error>) -> Void)) {
completion(.success(Cake(name: "MockCake", price: 100)))
}
}

class CakeManagerTests: XCTestCase {
func testCakeManager() throws {
let cakeManager = CakeManager()
cakeManager.provider = CakeMockProvider() // 這裡進行抽換
cakeManager.getCakePrice {
result in
switch result{
case.success(let price):
XCTAssertEqual(price, 100)
case .failure(let error):
XCTFail()
}
}
}
}

測試覆蓋率 (Code Coverage)

測試覆蓋率可以用來看在跑測試的過程中,有多少比例的程式有執行到。

建立方式

編輯 Scheme > Test > 勾選 Code Coverage,
可以選擇特定 target,盡量選擇自己專案就好,不然會包含一些三方依賴。

查看結果

可以從 xcode 的活動紀錄裡看到 Coverage

追求 100% 的測試覆蓋率其實不太容易,
更何況前端很多是 UI 相關的程式碼,也不是那麼容易寫單元測試,
至少確認重要邏輯或是關鍵路徑已經被測試就可以了。