Macでランダム化されたSwiftテストの入力を再現するには、各テストケース用に新しいジェネレーターを作成し、明示的なシードを渡します。ジェネレーター、入力のマッピング、値を取り出す順序を固定し、失敗のたびにシードを記録します。

このガイドでは、合成した算術計算を検証する小さなローカルSwiftパッケージを作成します。Swift Testingの完全な例と、XCTestの代替例を紹介します。テストデータはすべてローカル計算で生成し、パッケージに外部依存関係はありません。

シードによって実際に何が再現可能になるのか?

変動の原因は2つあります。テストが生成する値と、テストランナーがテストをスケジュールする順序です。アプリケーションで定義したシードが制御するのは前者だけであり、しかも生成されるすべての値が対応するジェネレーターから取得される場合に限られます。Appleのドキュメントによると、通常のランダム操作では、デフォルトで自動的にシードが設定される SystemRandomNumberGenerator が使われます。ドキュメントに記載されたイニシャライザーはシードを受け取りません。SystemRandomNumberGenerator

Xcodeには、テスト間の依存関係を見つけるためのランダムな実行順序もあります。テストプランのドキュメントではアルファベット順またはランダムな実行順序が説明されていますが、その設定にユーザー指定の再現用シードがあるとは記載されていません。したがって、ここで使うサポートされた方法は、各テストから独立して使う入力ジェネレーターに明示的なシードを設定することです。テストランナーのスケジュールを再現するものではありません。テストをテストプランにまとめてコード評価を改善する

不安定なテストを変更する前に、次の判断表を確認してください。

実行ごとに何が変わるか 制御するもの
生成される数値入力 シード、ジェネレーターの実装、入力のマッピング、値を取り出す順序
別のテストの実行後に変わる結果 可変状態、テストごとの初期化
並行実行時だけ変わる結果 共有される可変リソース、重複する処理
ツールチェーンの更新後に変わる入力 入力マッピングの実装、記録済みの具体的な入力

Swiftでは RandomNumberGenerator を通じてカスタムジェネレーターを明示的にサポートしています。このプロトコルが要求するのは、next() を返す mutating 操作です。Swiftプロジェクトのソースドキュメントでは、シード可能なジェネレーターを用意する理由として、テスト用にシーケンスを再現できることが挙げられています。Random.swift

小さなローカルテストパッケージを作成する

XcodeがインストールされたMacと、利用可能なSwift 6以降のツールチェーンを用意します。ターミナルで選択中のコマンドラインツールチェーンを確認してから、新しいディレクトリを作成し、ライブラリパッケージを初期化します。

swift --version
mkdir SeedReplay
cd SeedReplay
swift package init --type library

Swiftプロジェクトの統合テストでは、swift package init --type library を使ったライブラリパッケージの作成を検証しています。Swift 6にはSwift Testingが組み込まれているため、この例では import Testing のためにパッケージ依存関係を追加する必要はありません。Swift Package Managerは、Swift TestingのテストとXCTestのテストがあれば、どちらも実行します。Swiftパッケージ初期化の統合テスト、Swift 6の発表

生成されたパッケージマニフェストとライブラリのソースはそのまま使います。生成されたサンプルテストファイルを、以下のテストファイルに置き換え、すべて Tests/SeedReplayTests に配置します。この例を自己完結させるため、算術関数はテスト用のサポートファイルに置きます。既存プロジェクトでは、実際にテストする関数を使ってください。

シードと入力生成の仕様を定義する

Tests/SeedReplayTests/ReplaySupport.swift を作成し、次の内容をすべて記述します。

import Foundation

// Local synthetic test-data generator.
// Mutable value state: give each test case its own instance.
// Its output sequence is part of this example's replay contract.
struct ReplayGenerator: 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)
    }
}

enum ReplayConfigurationError: Error {
    case invalidSeed(String)
}

func replaySeeds() throws -> [UInt64] {
    guard let text = ProcessInfo.processInfo.environment["TEST_SEED"] else {
        return [0, 42, 2026]
    }

    guard !text.isEmpty,
          text.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }),
          let seed = UInt64(text) else {
        throw ReplayConfigurationError.invalidSeed(text)
    }

    return [seed]
}

func generatedInputs(seed: UInt64) -> [Int] {
    var generator = ReplayGenerator(seed: seed)
    return (0..<256).map { _ in
        // Exactly one draw per input; retain the upper ten bits.
        Int(generator.next() >> 54)
    }
}

func triangularNumber(_ n: Int) -> Int {
    precondition((0...1023).contains(n))
    return n * (n + 1) / 2
}

func referenceTotal(_ n: Int) -> Int {
    precondition((0...1023).contains(n))
    return (0...n).reduce(0, +)
}

この設定は、ゼロを含む10進数の UInt64 を受け取ります。TEST_SEED が設定されていない場合は、明示した3つのシードを使います。空、負数、数値以外、または範囲を超える値が指定された場合はエラーになります。シードの入力ミスで、別の実行が知らないうちに選ばれることはありません。

ジェネレーターでは、明示的なラップアラウンド演算を使います。生成される各入力には、1つの出力から上位10ビットを使い、値を 0 から 1023 の範囲に収めます。このマッピングはソースコード内で意図的に固定しています。算術計算は単純で、期待される結果の最大値は 523776 です。これは範囲を限定した合成入力のレシピであり、統計的なテスト網羅性を主張するものではありません。

ここで Int.random(in:using:) を避ける理由は何でしょうか。このAPIは指定したジェネレーターをサポートしており、ツールチェーンを固定して再現する場合に便利です。ただしAppleは、同じ基礎シーケンスを生成するジェネレーターを使っても、値の生成アルゴリズムはSwiftのバージョン間で変わる可能性があると明示的に警告しています。この例では小さなマッピングを自前で実装することで、その依存関係を取り除いています。Int.random(in:using:)

Swift Testing版を実行して再現する

Tests/SeedReplayTests/SeedReplayTests.swift を作成します。

import Testing

@Test(arguments: try replaySeeds())
func triangularTotals(seed: UInt64) {
    print("triangularTotals seed=\(seed) recipe=upper10-v1 count=256")

    for (index, n) in generatedInputs(seed: seed).enumerated() {
        let actual = triangularNumber(n)
        let expected = referenceTotal(n)

        #expect(
            actual == expected,
            "seed=\(seed) case=\(index) n=\(n) actual=\(actual) expected=\(expected)"
        )

        if actual != expected {
            return
        }
    }
}

@Test
func replayRecipeIsStable() {
    var generator = ReplayGenerator(seed: 0)
    let firstWord = generator.next()
    #expect(firstWord == 0xE220A8397B1DCDAF)

    let firstInputs = Array(generatedInputs(seed: 0).prefix(3))
    #expect(firstInputs == [904, 441, 27])
}

パラメーター化されたテストでは、各シードを別々のテストケースとして扱います。Appleのドキュメントによると、arguments: はコレクションの要素をテスト呼び出しに渡し、throwする式も指定できます。これにより、シードの選択がテストの構成に明確に表れます。明示的なループのインデックスを使えば、シードごとに生成された個々の入力も特定できます。パラメーター化テストの実装

デフォルトのシード一覧を実行した後、1つのシードを2回使って再現します。

swift test
TEST_SEED=42 swift test
TEST_SEED=42 swift test

ソースに変更がなければ、この2回の実行で同じ数値入力が生成されるはずです。シード 42 で最初に生成される3つの入力は、759、163、285 です。このテストでは、そのシードで256個の入力を検証します。独立した安定性テストでは、選択された算術テストのシードに関係なく、ジェネレーターとマッピングを検証するため、シードを常にゼロに固定しています。

提示した実装では、すべてのアサーションが成功する想定です。調査環境ではSwiftを利用できなかったため、Swiftファイルはコンパイルせずに内容を確認しました。独立したPythonの計算で、デフォルトの3つのシードについて768件すべての算術比較と、表示した先頭の値を確認しました。ただし、この確認はSwiftパッケージのコンパイルの代わりにはなりません。

意図的に計算を誤らせて再現手順を試す

診断の流れを確認するには、triangularNumber(_:) のreturn式を一時的に次のラベル付きコードに置き換えます。

// Temporary replacement inside triangularNumber(_:).
return n * n / 2

もう一度 TEST_SEED=42 swift test を実行します。最初に生成される入力は 759 です。変更後の式の結果は 288040、参照する合計値は 288420 です。アサーションメッセージには、シード 42、ケースインデックス 0、入力 759 が表示されます。最初に記録した不一致が見つかると、その呼び出しはすぐにreturnするため、診断対象が絞られます。

return n * (n + 1) / 2 を元に戻し、同じコマンドを再実行します。修正を評価する間はシードを変えないでください。生成された入力から有用な不具合が見つかったら、その具体的な入力も名前付きの回帰テストに保存してください。生成レシピが後から変わっても、その入力が何を示すかは変わりません。

XCTestにも同じライフサイクルを適用する

XCTestを使うターゲットでは ReplaySupport.swift をそのまま使い、Swift Testingのファイルの代わりに次の内容を含むテストファイルを使います。ファイル名は Tests/SeedReplayTests/SeedReplayXCTests.swift にします。

import XCTest

final class SeedReplayXCTests: XCTestCase {
    func testTriangularTotals() throws {
        for seed in try replaySeeds() {
            print("testTriangularTotals seed=\(seed) recipe=upper10-v1 count=256")

            for (index, n) in generatedInputs(seed: seed).enumerated() {
                let actual = triangularNumber(n)
                let expected = referenceTotal(n)

                XCTAssertEqual(
                    actual,
                    expected,
                    "seed=\(seed) case=\(index) n=\(n)"
                )

                if actual != expected {
                    return
                }
            }
        }
    }

    func testReplayRecipeIsStable() {
        var generator = ReplayGenerator(seed: 0)
        XCTAssertEqual(generator.next(), 0xE220A8397B1DCDAF)
        XCTAssertEqual(
            Array(generatedInputs(seed: 0).prefix(3)),
            [904, 441, 27]
        )
    }
}

ターミナルで実行するコマンドは同じです。generatedInputs(seed:) を呼び出すたびに、新しいジェネレーターが作られるため、別のテストが同じランダムストリームから値を取り出すことはありません。テスト後にジェネレーターの状態を消去する必要もありません。

既存のXCTestフィクスチャでジェネレーターをプロパティとして保持している場合は、各テストメソッドの前にリセットしてください。Appleのドキュメントでは、テストごとの初期化にはインスタンスのセットアップメソッドを、クラスで共有する状態にはクラスのセットアップを使うと説明されています。テストを独立して再現するには、可変なランダムストリームをテストごとの初期化で用意します。クラッシュ後にティアダウンが実行される保証はないため、次のテストの状態をティアダウンで準備する方法は信頼できません。テストの状態をセットアップ・ティアダウンする

Xcodeのスケジュールと入力の再現を分ける

Xcodeからこのパッケージを実行するには、Package.swift を開きます。テストプランを使うプロジェクトでは、Product > Scheme > Edit Test Plan を選び、該当する構成を選択します。次に Environment Variables で TEST_SEED を追加し、10進数の値 42 を指定します。Product > Test で実行し、テスト出力に表示されたシードを確認します。Appleのドキュメントでは、テストプランの環境設定と、テストの実行前にアクティブなプランを選択する方法が説明されています。テストをテストプランにまとめてコード評価を改善する

ターミナルとXcodeを切り替えるたびに、表示されたシードを確認してください。ターミナルの単一コマンドに割り当てた環境変数は、Xcodeのテストプランの設定にはなりません。ログ行を見れば、テストプロセスが実際にどの設定を読み込んだかが分かります。

Swift Testingでは、デフォルトでテストが並行実行されます。.serialized 特性を使うと、パラメーター化されたテスト内のケース、またはスイート内のテストを直列化できますが、無関係なテストには適用されません。Appleのドキュメントには、並列化を全体的に無効にする swift test --no-parallel も記載されています。どちらの制御にも、入力シードを設定する機能はありません。この例では各ケースが個別のジェネレーターと数値入力を持つため、直列化する必要はありません。テストを直列または並列に実行する

生成された入力が同じなのに、単独なら成功し他のテストと一緒だと失敗する場合は、共有状態を調べてください。直列実行は処理の重複を調べるのに役立ちますが、シードでは別のテストの可変オブジェクトをリセットできず、並行実行全体のスケジュールも再現できません。

失敗したシードと一緒に何を保存するか

入力シーケンスを再構成できるよう、次の情報を保存します。

  • テスト名と10進数のシード。
  • この例では upper10-v1 で示されるジェネレーターとマッピングのバージョン。
  • アサーションメッセージに表示されたケースインデックスと具体的な入力。
  • ソースのリビジョン、Swiftのバージョン、選択したXcodeのバージョン。
  • 入力数、およびランダム値を取り出す方法の変更。

この例でループの前にジェネレーターの値を1つ取り出すと、その後の入力がすべてずれます。シフト量を変えるとマッピングが変わります。入力数を増やした場合、ジェネレーターとマッピングが変わらなければ、既存の入力列は先頭部分として保持されます。これらはいずれも、このコードで定めた再現性の仕様から生じる結果です。

日常的な実行用に少数の固定シードを用意し、調査では別の明示的なシードも試して、不具合が見つかったら具体的な回帰入力を保存します。役立つ再現では、同じ値と既知の初期状態から始めます。シードが信頼できるのは、その消費方法をテストで制御しているからです。

出典