エンジニアリング実践

クラウドMacでランダムテスト失敗を再現する

クラウドMacでランダムテスト失敗を再現する

CI の夜間ジョブで、ローカルでは再現できない XCTest の失敗が発生することがあります。入力はランダムなファクトリで生成され、テストはランダムな順序で実行され、失敗するとパイプラインが自動的に再試行します。2 回目は成功しても、最初の失敗時にどのデータが使われ、その前にどのテストケースが実行されていたのかは、もはや確認できません。この種の問題を解決する鍵は、再試行回数を増やすことではなく、1 回の実行を完全に再生できる実験に変えることです。

再生契約を先に定義する

ランダムテストには、少なくとも 3 種類の変数が影響します。ビジネスデータのランダム値、テストケースの実行順序、そしてシミュレータやプロセスに残された状態です。SEED=42 だけを記録しても十分ではありません。コードのコミット、テスト一覧、ロケールなどが変われば、同じシードでも異なる実行経路に入る可能性があるためです。

各ジョブの開始時に実行マニフェストを生成し、後続のステップではそのマニフェストだけを参照するようにします。少なくとも、次のフィールドを保存することを推奨します。

フィールド 例 用途
commit Git コミットハッシュ テスト対象のコードを固定する
seed 符号なし整数 ランダムデータを再生する
tests 順序付きテスト識別子リスト 実行順序を固定する
destination シミュレータの UDID 実行先を固定する
runtime OS と 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")

同じジョブ内のすべてのランダムファクトリでは、明確な派生ルールを共有する必要があります。たとえば、マスターシードと安定したテストケース識別子から子シードを算出すれば、あるテストが乱数を 1 回余分に取得しただけで、テストスイート全体のデータ列が変わる事態を防げます。

テスト一覧と実行順序を固定する

テスト順序をファイルシステムの列挙結果に依存させてはいけません。まず、1 行につき 1 つの完全な識別子を記載したテキスト形式の一覧を管理します。たとえば 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、シミュレータのランタイムも記録します。機密性の高い環境変数については、変数名と存在の有無だけを記録し、実際の値は保存しません。

自動再試行は分類手段として利用できますが、必ず最初の失敗時の現場をアーカイブした後に実行します。結果は次の 3 種類に分けることを推奨します。

同じ契約で安定して失敗する

同じコミット、シード、順序、シミュレータ状態で継続的に失敗する場合は、決定的なビジネスロジックとアサーションを優先して確認します。

順序を変えると失敗する

データを固定したまま、前に実行するテストだけを変えると結果が変わる場合は、シングルトン、通知リスナー、データベース、キャッシュ、復元されていないグローバル設定を重点的に確認します。

同じ契約でも結果が変動する

非同期タスクが本当に終了しているか、システム時刻を読み取っていないか、ネットワークレスポンスが固定されているか、注入されていない乱数源をテストが呼び出していないかを引き続き確認します。

最終目標は、再試行によって赤い結果を消すことではなく、すべての失敗に実行可能な再生コマンドを添えることです。これが実現すれば、偶発的な問題は「確率的な事象」ではなく、縮小、比較、修正が可能な入力の集合になります。

よくある質問

乱数シードだけを保存すれば失敗を再現できますか?

通常は不十分です。コミット、テスト一覧と順序、シミュレータのランタイム、ロケール、外部入力も固定しなければ、同じシードでも異なる経路を通る可能性があります。

失敗したテストはすぐ自動再試行すべきですか?

最初の失敗時点のxcresult、シード、実行順を先に保存します。その後で同じ再生条件を使って再実行し、再試行成功を修正完了の根拠にはしません。

Swiftの乱数生成器を注入する利点は何ですか?

テスト時だけ固定シードの生成器へ差し替え、同じ入力列を再生成できます。本番では引き続きシステムの乱数生成器を使用できます。

ArmCLI クラウドMac

次のビルドに備えて専用物理Macを用意

タスクに合わせて機種、データセンター、利用期間を選択できます。ノードは365日継続稼働しており、実際の利用可能状況はコンソールのリアルタイム情報をご確認ください。