同一組 Swift 測試單獨執行時全部通過,放進雲端 Mac 的平行任務後卻偶爾失敗,通常不是機器不穩定,而是測試共用了目錄、偏好設定、資料庫、連接埠或隨機狀態。增加重試次數只能掩蓋污染。更可靠的做法,是讓每個測試都具備可識別、可清理且可重現的執行邊界。
先確認失敗是否來自共享狀態
先保留平行度,不要立即改成循序執行。分別以單一工作行程和多個工作行程執行相同目標,並為兩次執行產生獨立的結果套件:
set -o pipefail
xcodebuild test \
-scheme AppTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-parallel-testing-enabled NO \
-resultBundlePath Artifacts/serial.xcresult
xcodebuild test \
-scheme AppTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-parallel-testing-enabled YES \
-maximum-parallel-testing-workers 4 \
-resultBundlePath Artifacts/parallel.xcresult
如果單一行程執行穩定、平行執行卻失敗,應優先檢查以下資源,而不是先懷疑斷言:
| 共享資源 | 常見症狀 | 隔離方法 |
|---|---|---|
| 暫存目錄 | 檔案遭到覆寫、清理時已不存在 | 每個測試建立唯一子目錄 |
| UserDefaults | 讀取到其他測試案例寫入的值 | 使用獨立的 suiteName |
| SQLite 檔案 | 鎖定衝突、資料數量飄移 | 每個測試使用獨立資料庫 |
| 固定連接埠 | Address already in use | 繫結連接埠 0 |
| 全域亂數 | 順序改變後結果不同 | 明確保存隨機種子 |
循序執行能通過,只能證明共享狀態沒有被同時存取,不能證明測試已經正確隔離。
為每個測試分配獨立沙箱
不要讓所有測試案例都寫入 /tmp/app-tests。測試啟動時產生唯一識別碼,將檔案、資料庫和匯出結果全部放進該目錄,並在結束時清理。
import Foundation
import Testing
struct TestSandbox {
let id: String
let root: URL
init(name: String) throws {
id = "\(name)-\(UUID().uuidString)"
root = FileManager.default.temporaryDirectory
.appending(path: "ZoneMiniTests")
.appending(path: id)
try FileManager.default.createDirectory(
at: root,
withIntermediateDirectories: true
)
}
func preferences() throws -> UserDefaults {
guard let defaults = UserDefaults(suiteName: "tests.\(id)") else {
throw CocoaError(.fileWriteUnknown)
}
return defaults
}
func remove() throws {
try FileManager.default.removeItem(at: root)
UserDefaults.standard.removePersistentDomain(
forName: "tests.\(id)"
)
}
}
測試案例應透過相依性注入,將 root 傳給受測程式碼,避免正式環境程式碼自行讀取全域暫存路徑。清理動作應放在 defer 中,確保即使斷言失敗也能執行。如果需要保留失敗現場,可以依據環境變數略過刪除,並將沙箱路徑寫入測試附件。
不要以測試名稱作為唯一鍵
參數化測試可能以相同函式名稱平行執行多組輸入。僅使用函式名稱仍會發生衝突,應組合測試名稱、參數摘要和 UUID。若參數包含權杖或使用者資料,不要直接拼接到目錄名稱中,應先產生不可逆摘要。
隔離設定、資料庫與監聽連接埠
UserDefaults 的標準網域屬於行程層級的共享狀態。測試應注入專用執行個體,並在結束時刪除對應的持久網域。資料庫也應遵循相同原則:每個測試建立自己的檔案,遷移測試與一般讀寫測試不要重複使用同一份副本。
網路測試最容易因固定連接埠而產生假性失敗。測試伺服器應繫結連接埠 0,讓系統選擇可用連接埠,再將實際連接埠傳給用戶端。不要採用「先掃描可用連接埠、關閉掃描連線,再重新繫結」的做法;掃描與繫結之間存在競爭時間窗。
對於確實依賴全域單例,且短期內無法重構的測試,可以在局部範圍內循序執行:
import Testing
@Suite(.serialized)
struct LegacyDatabaseTests {
@Test
func migratesExistingStore() async throws {
// 測試實作
}
}
.serialized 應只包住舊有程式碼的範圍。如果將整個測試目標都設為循序執行,平行污染雖會消失,但執行時間和問題線索也會一併消失。
固定隨機種子,而不是反覆碰運氣
涉及亂序、重試退避或資料產生時,應從環境變數讀取種子。任務開始時只產生一次並加以記錄,所有測試行程都使用該值衍生各自的子種子。
let environment = ProcessInfo.processInfo.environment
let seed = UInt64(environment["TEST_SEED"] ?? "") ?? 20260806
子種子可以透過基礎種子與測試唯一鍵穩定計算。失敗報告至少應保留基礎種子、工作行程數、測試目標和執行命令。如此才能重現「特定輸入搭配特定平行度」的組合,而不是盲目重跑十次。
在 ZoneMini 的持續執行節點上,建議將結果套件寫入與任務編號對應的目錄,避免後一次任務覆蓋前一次:
RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
mkdir -p "Artifacts/$RUN_ID"
TEST_SEED=20260806 xcodebuild test \
-scheme AppTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-parallel-testing-enabled YES \
-maximum-parallel-testing-workers 4 \
-resultBundlePath "Artifacts/$RUN_ID/tests.xcresult"
透過壓力重複測試驗證修正結果
修正後不要只執行一次。先維持相同的種子和平行度連續執行,再更換種子以涵蓋不同輸入。每輪的結果套件都必須使用不同路徑,否則 xcodebuild 會因目標已存在而結束。
驗收時可以採用四項明確標準:
- 單一工作行程與四個工作行程得到相同的斷言結果。
- 每個測試案例只寫入自己的目錄、設定網域和資料庫。
- 監聽服務不使用硬編碼連接埠。
- 失敗日誌能夠定位種子、沙箱和結果套件。
最後再檢查清理策略。成功的任務可以刪除暫存沙箱;失敗的任務應保留必要的日誌和結果套件,但不要長期保留憑證、儲存庫權杖或未經去識別化處理的請求內容。平行測試穩定性的核心不是增加重試次數,而是讓每個測試案例只擁有自己的狀態,並讓每次失敗都留下足以重現問題的條件。
常見問題
Swift Testing 平行失敗時,應該先停用平行執行嗎?
不應把停用平行當成預設修正。先隔離暫存目錄、UserDefaults、資料庫與連接埠;只有無法安全平行的舊測試,才局部使用 .serialized。
如何重現只在 CI 發生的隨機失敗?
每次工作都保存隨機種子、平行工作數、測試目標與結果套件路徑,之後以相同種子和相同平行度重跑完整失敗情境。
平行測試共用固定連接埠會有什麼問題?
多個程序可能同時綁定相同連接埠,造成位址占用或連到其他測試的服務。應要求連接埠 0,並把系統分配的實際值交給客戶端。
為持續建置工作選擇獨享雲端 Mac
確認 M4、記憶體、儲存空間、節點與計費週期,再將固定建置環境接入現有工作流程。