工程實務

雲端 Mac 重現隨機測試失敗:固定種子與執行順序

雲端 Mac 重現隨機測試失敗:固定種子與執行順序

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 程式碼中注入隨機數產生器?

測試可以換成固定種子的產生器並重播相同資料序列,而正式環境仍可繼續使用系統隨機來源。

ArmCLI 雲端 Mac

為下一次建置準備獨享實體 Mac

依任務選擇機型、機房與租用期間。節點全年正常運作,實際可用狀態以控制台即時回傳為準。