雲端 Mac 的 Swift Testing 平行測試隔離實務

雲端 Mac 的 Swift Testing 平行測試隔離實務

同一組 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 會因目標已存在而結束。

驗收時可以採用四項明確標準:

  1. 單一工作行程與四個工作行程得到相同的斷言結果。
  2. 每個測試案例只寫入自己的目錄、設定網域和資料庫。
  3. 監聽服務不使用硬編碼連接埠。
  4. 失敗日誌能夠定位種子、沙箱和結果套件。

最後再檢查清理策略。成功的任務可以刪除暫存沙箱;失敗的任務應保留必要的日誌和結果套件,但不要長期保留憑證、儲存庫權杖或未經去識別化處理的請求內容。平行測試穩定性的核心不是增加重試次數,而是讓每個測試案例只擁有自己的狀態,並讓每次失敗都留下足以重現問題的條件。

常見問題

Swift Testing 平行失敗時,應該先停用平行執行嗎?

不應把停用平行當成預設修正。先隔離暫存目錄、UserDefaults、資料庫與連接埠;只有無法安全平行的舊測試,才局部使用 .serialized。

如何重現只在 CI 發生的隨機失敗?

每次工作都保存隨機種子、平行工作數、測試目標與結果套件路徑,之後以相同種子和相同平行度重跑完整失敗情境。

平行測試共用固定連接埠會有什麼問題?

多個程序可能同時綁定相同連接埠,造成位址占用或連到其他測試的服務。應要求連接埠 0,並把系統分配的實際值交給客戶端。

專用建置時段

為持續建置工作選擇獨享雲端 Mac

確認 M4、記憶體、儲存空間、節點與計費週期,再將固定建置環境接入現有工作流程。

選擇配置並訂購