若要讓 XCTest 測試案例單獨執行及作為測試套件執行時都能通過,請為每個測試提供專屬的可變測試夾具,並將它注入受測程式碼。若必須維持共用單例,請在每個測試前重設它,並在測試的整個生命週期中避免重疊存取。
本指南以一項 Mac 開發工作為例:修正 Xcode 中單獨執行時通過、一起執行時卻失敗的版面配置計算測試。範例只使用合成的整數寬度和本機算術,不需要 App 視窗、服務模擬物件、檔案儲存空間或網路連線。
為什麼測試單獨執行時會通過,放進測試套件卻失敗?
單獨執行測試時,可能會遇到單例的初始狀態。在測試套件中,先前的測試可能已經修改了它。對該單例建立新的參照,仍然指向同一個可變物件。
Swift 區分執行個體屬性和型別屬性:每個執行個體都有自己的執行個體屬性,而型別屬性只有一份共用副本。儲存型別屬性會在首次存取時初始化一次。因此,將 SpacingCalculator.shared 指派給測試屬性,並不會建立新的計算器。請參閱 Swift 專案的屬性文件。
假設有一個合成資料列,內含寬度為 [10, 20, 30] 的項目。其總寬度是項目寬度總和,再加上項目之間的間距。預設間距為 2 時,結果是 64;間距為 8 時,結果是 76。
假設一項測試在未設定計算器的情況下檢查預設寬度,而另一項測試將共用間距改為 8。下表說明依序執行時的情況,每一列開始時都使用間距為 2 的全新計算器。這些是示意計算,並非實際記錄的 Xcode 測試結果。
| 執行方式 | 未重設的共用計算器 | 每項測試使用全新計算器 |
|---|---|---|
| 單獨執行預設測試 | 64:通過 |
64:通過 |
| 單獨執行寬間距測試 | 76:通過 |
76:通過 |
| 先預設測試,再寬間距測試 | 兩者皆通過 | 兩者皆通過 |
| 先寬間距測試,再預設測試 | 預設測試取得 76:失敗 |
兩者皆通過 |
這提供了一項實用的診斷方式:檢查是哪項測試修改了失敗斷言所使用的狀態。調整測試名稱以取得有利的執行順序,並不能消除這項相依性。
哪個 XCTest 生命週期方法應該負責建立測試夾具?
對每項測試都需要的可變狀態,請使用執行個體設定方法。Apple 說明,執行個體設定會在每個測試方法之前執行,而類別層級設定則在測試類別之前執行一次。因此,在類別層級設定中建立的可變測試夾具,不會在各方法之間重新建立初始狀態。請參閱在測試中設定及清除狀態。
依照要執行的工作選擇設定方法:同步且不會拋出錯誤的測試夾具建構適用於 setUp();會拋出錯誤的建構適用於 setUpWithError();非同步準備則適用於 setUp() async throws。使用拆解方法完成目前測試的清理工作。Apple 明確建議不要依賴拆解方法來準備後續測試,因為若發生當機,拆解方法可能無法執行。同一份生命週期文件也說明了這些界線。
測試夾具的選擇取決於擁有權和執行範圍:
| 情況 | 合適的做法 | 檢查條件 |
|---|---|---|
| 測試可以建構相依項 | 每項測試建立並注入一個執行個體 | 消費端必須使用注入的執行個體 |
| 測試必須測試實際的單例 | 每項測試前建立完整的初始狀態 | 沒有重疊執行的測試會修改該單例 |
| 共用設定不可變 | 重複使用不可變值 | 參照的物件也必須保持不變 |
| 測試會啟動非同步工作 | 在測試結束前等待工作完成 | 之後不得有未完成的工作修改測試夾具 |
對這個計算器而言,第一個選項最簡單。正式程式碼可以保留共用預設值,同時讓測試明確選擇彼此獨立的執行個體。
建立由個別測試擁有的 XCTest 測試夾具
使用已設有 macOS 單元測試目標的 Mac 和 Xcode。將下列完整範例作為一個 Swift 檔案加入該測試目標。範例自帶計算器、消費端和測試,因此不需要匯入 App 模組。使用 Swift 6 工具鏈,並將目標的預設 Actor 隔離設定為 nonisolated。
此計算器是 Actor,能明確界定其可變狀態的並行存取邊界。Swift Actor 限制對其可變狀態的存取,而從 Actor 外部呼叫時會使用 await。這些規則記載於 Swift 專案的並行處理章節。即使使用 Actor,仍需要讓每個測試夾具彼此獨立。
import XCTest
actor SpacingCalculator {
static let shared = SpacingCalculator()
private var gap = 2
func setGap(_ value: Int) {
precondition(value >= 0)
gap = value
}
func width(of itemWidths: [Int]) -> Int {
precondition(itemWidths.allSatisfy { $0 >= 0 })
let gapCount = max(itemWidths.count - 1, 0)
return itemWidths.reduce(0, +) + gapCount * gap
}
}
struct RowLayout {
let calculator: SpacingCalculator
init(calculator: SpacingCalculator = .shared) {
self.calculator = calculator
}
func width(of itemWidths: [Int]) async -> Int {
await calculator.width(of: itemWidths)
}
}
final class RowLayoutTests: XCTestCase {
private var calculator: SpacingCalculator?
override func setUp() {
super.setUp()
calculator = SpacingCalculator()
}
override func tearDown() {
calculator = nil
super.tearDown()
}
func testDefaultGap() async throws {
let fixture = try XCTUnwrap(calculator)
let layout = RowLayout(calculator: fixture)
let actual = await layout.width(of: [10, 20, 30])
XCTAssertEqual(actual, 64)
}
func testWideGap() async throws {
let fixture = try XCTUnwrap(calculator)
await fixture.setGap(8)
let layout = RowLayout(calculator: fixture)
let actual = await layout.width(of: [10, 20, 30])
XCTAssertEqual(actual, 76)
}
func testEmptyRow() async throws {
let fixture = try XCTUnwrap(calculator)
let layout = RowLayout(calculator: fixture)
let actual = await layout.width(of: [])
XCTAssertEqual(actual, 0)
}
func testSingleItem() async throws {
let fixture = try XCTUnwrap(calculator)
let layout = RowLayout(calculator: fixture)
let actual = await layout.width(of: [10])
XCTAssertEqual(actual, 10)
}
}
關鍵在於這一行:calculator = SpacingCalculator()。每次設定都會建立一個間距為 2 的獨立 Actor。接著,每項測試都會將該 Actor 傳給 RowLayout。若在這些測試中呼叫 RowLayout(),就會選用 .shared 並略過測試夾具。
範例刻意允許直接建構。如果現有單例的初始化器是私有的,可以新增內部建構途徑,或將其可變計算狀態抽取為可獨立建構的相依項。透過 App 模組既有的可測試匯入設定,可以讓測試目標使用內部初始化器;不必讓單例參照可由全域替換。
此算術也定義了兩種實用的邊界情況:空資料列的寬度為 0,而只有一個項目時則沒有項目間距。範例接受足以避免整數溢位的非負整數;負值違反前置條件,會終止執行,而不會產生斷言失敗。
已在本機使用確定性的 Python 計算檢查預期算術,以及兩種雙測試執行順序。Swift 範例已檢視,但因本研究環境沒有 Swift 工具鏈,未進行編譯或執行。請在 Xcode 中執行,確認它們能在你的目標中編譯及執行。
什麼時候重設單例就足夠?
若測試確實需要共用執行個體,而且能還原測試可見的全部狀態,重設可作為可行的過渡方式。請在每項測試前建立初始狀態,包括測試假設為預設值的值。只重設目前失敗測試所修改的屬性,可能會讓其他相依性未被發現。
對這個計算器而言,可以在非同步設定中選取 SpacingCalculator.shared,並在每個方法前等待 setGap(2)。只有在沒有重疊的呼叫者於斷言前修改間距時,這才能建立算術初始狀態。若測試夾具日後加入快取結果或其他設定,其初始狀態操作也必須涵蓋這些值。
Actor 不會讓單項測試在多次等待呼叫期間獨占它。例如,測試 A 可以將共用間距設為 2,測試 B 可以將間距設為 8,接著測試 A 使用 8 計算寬度。每項 Actor 操作都可以正確執行,但整體測試假設仍會失效。這源自 Swift 所記載的Actor 執行與暫停規則。
選擇以重設方式隔離之前,請找出所有可能存取共用物件的測試和背景工作。如果無法在準備、執行和清理期間確保獨占使用,請改為注入獨立狀態。事後還原快照,無法阻止其他呼叫者在測試期間看到暫時變更。
如何將測試夾具套用至 Swift Testing?
Swift Testing 會為每個執行個體測試函式初始化不同的套件執行個體。因此,建構計算器的執行個體屬性不需要覆寫 XCTest 設定方法,就能提供相同的擁有權模式。若屬性從 .shared 初始化,它仍會參照單例。Apple 在使用套件型別組織測試函式中說明了執行個體生命週期。
在同一個測試目標中已有前述 SpacingCalculator 和 RowLayout 定義一次的情況下,以下是完整的 Swift Testing 附加測試套件。它需要包含 Testing 模組的工具鏈,例如 Xcode 16 或更新版本。其預設 Actor 隔離也應設為 nonisolated。
import Testing
struct RowLayoutSwiftTests {
let calculator = SpacingCalculator()
@Test func defaultGap() async {
let layout = RowLayout(calculator: calculator)
let actual = await layout.width(of: [10, 20, 30])
#expect(actual == 64)
}
@Test func wideGap() async {
await calculator.setGap(8)
let layout = RowLayout(calculator: calculator)
let actual = await layout.width(of: [10, 20, 30])
#expect(actual == 76)
}
}
若要進行更複雜的準備工作,Swift Testing 支援套件初始化器,包括非同步或會拋出錯誤的初始化器。Apple 的將 XCTest 測試遷移至 Swift Testing說明了如何以此取代 XCTest 設定方法。這個測試夾具只擁有本機計算狀態,也不會啟動背景工作,因此不需要明確清理。
Swift Testing 預設會平行執行測試。對套件套用 .serialized,會讓其中的測試和巢狀套件依序執行,但不會阻止無關測試與其同時執行。對一般非參數化測試函式套用此特性則不會生效。這些限制明確記載於依序或平行執行測試。
因此,將兩個依賴單例的套件分別放在 .serialized 註記之下,並不能讓它們彼此互斥。若所有競爭使用者都屬於同一個範圍,則共同的序列化父套件可以協調其子項目。對這些計算測試而言,使用全新測試夾具可避免協調需求。
在 Xcode 中確認個別測試和測試套件的結果
在整個驗證過程中,使用相同的方案、測試計畫和 Mac 目的地。Apple 在執行測試及解讀結果中說明了如何執行個別方法、測試套件和目前的測試計畫。
- 使用編輯器側邊欄的播放控制項,逐一執行四個 XCTest 方法。依序預期結果為
64、76、0和10。 - 使用
RowLayoutTests類別宣告旁的播放控制項執行該類別。四個方法應該一起通過。 - 選擇
Product > Test以執行目前的測試計畫。確認這些測試確實已執行,而非遭略過或排除。 - 若已加入 Swift Testing 範例,也請個別及一起執行其中兩個函式。
若個別測試通過,但完整測試計畫失敗,請追蹤失敗消費端使用的物件。檢查是否有遺漏注入、呼叫 .shared 的輔助函式,或在原始測試結束後仍持續執行的非同步工作。若測試單獨執行時失敗,請先檢查其輸入和預期計算結果,再判斷是否與測試套件的執行順序有關。
完成後的測試夾具應透過目前測試的建構路徑,明確呈現其初始狀態。對這些版面配置計算而言,每項測試都擁有一個計算器,明確將它提供給消費端,並等待其工作完成。如此一來,預期結果就不會受到相鄰測試執行順序的影響。
來源
- 在測試中設定及清除狀態 — Apple
- 屬性 — Swift 程式語言,Swift 專案
- 並行處理 — Swift 程式語言,Swift 專案
- 使用套件型別組織測試函式 — Apple
- 將 XCTest 測試遷移至 Swift Testing — Apple
- 依序或平行執行測試 — Apple
- 執行測試及解讀結果 — Apple