クラウド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. 失敗ログからシード、サンドボックス、結果バンドルを特定できる。

最後にクリーンアップ方針も確認してください。成功したジョブでは一時サンドボックスを削除できます。失敗したジョブでは必要なログと結果バンドルを残す一方で、認証情報、リポジトリトークン、マスキングされていないリクエスト内容を長期間保存してはいけません。並列テストを安定させる鍵はリトライ回数を増やすことではなく、各テストケースに固有の状態だけを持たせ、失敗のたびに再現に十分な条件を残すことです。

よくある質問

並列テストが失敗したら、最初に並列実行を無効にすべきですか?

既定の対処にはしません。ディレクトリ、UserDefaults、データベース、ポートをテスト単位で分離し、並列化できない既存テストだけに .serialized を適用します。

CIでだけ起きるランダムな失敗を再現するにはどうしますか?

実行ごとの乱数シード、並列ワーカー数、テスト先、xcresultの保存先を記録し、同じシードと並列度で失敗時の条件全体を再実行します。

並列テストで固定ポートを使うと何が起きますか?

複数プロセスが同じポートを確保したり、別テストのサーバーへ接続したりします。サーバーはポート0を要求し、割り当てられた番号をクライアントへ渡します。

専用ビルドスロット

継続的なビルドタスクに専用クラウドMacを選択

M4、メモリ、ストレージ、ノード、請求期間を確認し、固定ビルド環境を既存のパイプラインに接続します。

構成を選択して注文