CI 夜間工作偶爾會出現本機無法重現的 XCTest 失敗:輸入資料由隨機工廠產生,測試以隨機順序執行,而流水線又會在失敗後自動重試。第二次執行雖然轉綠,卻已無法回答第一次失敗時究竟用了哪些資料,以及之前跑過哪些測試案例。解決這類問題的關鍵不是增加重試次數,而是把每次執行變成能夠完整重播的實驗。
先定義可重播契約
隨機測試至少會受到三類變數影響:業務資料中的隨機值、測試案例的執行順序,以及模擬器與程序殘留的狀態。只記錄一個 SEED=42 並不足夠,因為程式碼提交、測試清單或地區設定變更後,相同種子也可能走上不同路徑。
每次工作開始時都應產生一份執行清單,並讓後續步驟只讀取這份清單。建議至少保存以下欄位:
| 欄位 | 範例 | 用途 |
|---|---|---|
| commit | Git 提交雜湊 | 固定受測程式碼 |
| seed | 無符號整數 | 重播隨機資料 |
| tests | 有序的測試識別碼清單 | 固定執行順序 |
| destination | 模擬器 UDID | 固定執行目標 |
| runtime | 系統與 Xcode 版本 | 識別工具鏈差異 |
| locale | zh_CN 或 en_US |
避免格式化差異 |
| result bundle | .xcresult 路徑 |
保存日誌與附件 |
「同一個測試又通過了」並不代表已成功重現。只有輸入、順序與環境完全相同,才能判斷缺陷是否仍然存在。
執行清單應與測試產物一起封存,不能只輸出到可能遭截斷的主控台日誌中。
為隨機資料提供穩定種子
Swift 的系統隨機來源適合正式程式碼,卻無法依指定種子重播。可以把隨機數產生器作為相依項目傳入資料工廠:測試使用可設定種子的實作,正式程式碼則繼續使用系統實作。
struct SplitMix64: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) {
state = seed
}
mutating func next() -> UInt64 {
state &+= 0x9E3779B97F4A7C15
var value = state
value = (value ^ (value >> 30)) &* 0xBF58476D1CE4E5B9
value = (value ^ (value >> 27)) &* 0x94D049BB133111EB
return value ^ (value >> 31)
}
}
func makeQuantity<R: RandomNumberGenerator>(using rng: inout R) -> Int {
Int.random(in: 1...20, using: &rng)
}
測試進入點應從環境變數讀取種子,若種子不存在就直接失敗,而不是悄悄退回系統隨機來源:
let rawSeed = ProcessInfo.processInfo.environment["TEST_SEED"]
let seed = rawSeed.flatMap(UInt64.init)
XCTAssertNotNil(seed, "TEST_SEED must be provided")
同一項工作中的所有隨機工廠都應共用明確的衍生規則。例如,以主種子加上穩定的測試案例識別碼計算子種子,避免某個測試多取一次隨機數後,改變整套測試的資料序列。
固定測試清單與執行順序
測試順序不應依賴檔案系統的走訪結果。先維護一份文字清單,每行放置一個完整識別碼,例如 AppTests/LoginTests/testExpiredSession。接著使用種子與識別碼的摘要進行穩定排序:
import hashlib
import os
seed = os.environ["TEST_SEED"]
with open("ci/tests.txt", encoding="utf-8") as source:
tests = [line.strip() for line in source if line.strip()]
def key(name):
value = f"{seed}:{name}".encode()
return hashlib.sha256(value).hexdigest()
for test in sorted(tests, key=key):
print(test)
如此既能涵蓋不同順序,也能在任何節點重建相同清單。執行前先把排序結果保存為 ordered-tests.txt,再逐項組裝 xcodebuild 參數:
set -euo pipefail
mkdir -p artifacts
python3 ci/order_tests.py > artifacts/ordered-tests.txt
args=()
while IFS= read -r test_id; do
args+=("-only-testing:${test_id}")
done < artifacts/ordered-tests.txt
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-destination "id=${SIMULATOR_UDID}" \
-resultBundlePath "artifacts/test-${TEST_SEED}.xcresult" \
"${args[@]}"
不要同時啟用另一套無法記錄的隨機排序機制,否則清單順序與實際執行順序又會彼此脫節。
隔離模擬器狀態,但不要抹除證據
重播前應使用專用模擬器,避免人工除錯與 CI 共用同一裝置。測試開始前可以關閉並清除指定裝置,但必須先確認 UDID 屬於本次工作,不能對共用裝置進行無差別清理。
xcrun simctl shutdown "$SIMULATOR_UDID" 2>/dev/null || true
xcrun simctl erase "$SIMULATOR_UDID"
xcrun simctl boot "$SIMULATOR_UDID"
xcrun simctl bootstatus "$SIMULATOR_UDID" -b
地區、語言、時區與啟動參數也應由測試進入點明確設定。若網路回應、目前時間或 UUID 會影響斷言,就應透過協定注入固定實作,而不是期待模擬器剛好處於相同狀態。
如果問題疑似由先前測試造成的狀態污染所引發,不要一開始就把每個測試案例拆到全新的模擬器中。先依原本順序重播並確認失敗,接著使用二分法縮短前置清單,找出最小的污染鏈。這樣才能區分是「隨機資料觸發」還是「先前狀態洩漏」。
在首次失敗時保存完整現場
失敗工作至少要保留 xcresult、有序測試清單、執行清單、標準輸出與程式碼提交。也應記錄 xcodebuild -version、sw_vers 和模擬器執行階段。對於敏感的環境變數,只記錄變數名稱及其是否存在,不保存實際值。
自動重試可以作為分類手段,但必須在首次失敗的現場完成封存後才執行。建議將結果分為三類:
相同契約下穩定失敗
若在相同提交、種子、順序與模擬器狀態下持續失敗,應優先檢查具確定性的業務邏輯與斷言。
變更順序後失敗
固定資料不變,僅調整前置測試就會改變結果時,應著重檢查單例、通知監聽器、資料庫、快取,以及未還原的全域設定。
相同契約下仍會漂移
繼續確認非同步工作是否真的結束、是否讀取了系統時間、網路回應是否尚未固定,以及測試是否呼叫了未經注入的隨機來源。
最終目標不是讓紅燈透過重試消失,而是讓每次失敗都附帶一條可執行的重播命令。做到這一點後,偶發問題就會從「機率事件」轉化為能夠縮減、比較與修正的輸入集合。
常見問題
只記錄隨機種子就能完整重現 XCTest 失敗嗎?
通常不行。還要固定程式碼提交、測試清單與順序、模擬器執行環境、地區設定和外部輸入,否則相同種子仍可能走向不同路徑。
測試失敗後應該立即自動重試嗎?
應先保存第一次失敗的 xcresult、種子與完整順序,再使用同一份重播契約獨立執行;一次重試通過不能證明問題已修復。
為什麼要在 Swift 程式碼中注入隨機數產生器?
測試可以換成固定種子的產生器並重播相同資料序列,而正式環境仍可繼續使用系統隨機來源。
為下一次建置準備獨享實體 Mac
依任務選擇機型、機房與租用期間。節點全年正常運作,實際可用狀態以控制台即時回傳為準。