クラウドMacで行うiOS起動引数と環境変数の契約テスト

クラウドMacで行うiOS起動引数と環境変数の契約テスト

開発者のMacでは通るUIテストが、クラウドMacで数十回連続実行すると、ときどきオンボーディング画面で停止することがあります。よくある原因はシミュレータの性能ではなく、起動状態が契約として定義されていないことです。あるテストケースは -skipOnboarding を渡し、別のケースは環境変数を使い、さらに別のケースは永続データの消去を忘れている。単独ではどれも問題なく動作しても、並列実行や実行順序の変更によって互いの状態を汚染し始めます。解決策は、引数名、値の形式、状態のリセット、Releaseからの除外条件を、まとめてテスト可能なインターフェースとして扱うことです。

文字列を各所に散在させず、先に起動契約を定義する

起動引数はブール型のフラグに、環境変数は値を伴う設定に適しています。どちらも一元的に宣言し、テストターゲットとアプリターゲットで異なる表記が使われないようにします。次のパーサーはDebugビルドでのみ有効になり、値が未指定または空の場合にも明確なデフォルト動作を取ります。

#if DEBUG
enum UITestLaunchContract {
    static let arguments = ProcessInfo.processInfo.arguments
    static let environment = ProcessInfo.processInfo.environment

    static var enabled: Bool {
        arguments.contains("-ui-testing")
    }

    static var resetState: Bool {
        arguments.contains("-reset-state")
    }

    static var fixture: String? {
        guard enabled else { return nil }
        return environment["UITEST_FIXTURE"]?.trimmingCharacters(
            in: .whitespacesAndNewlines
        ).nonEmpty
    }
}

private extension String {
    var nonEmpty: String? {
        isEmpty ? nil : self
    }
}
#endif

契約では、少なくとも4つの点を明確にする必要があります。フラグの名前、値の取得元、不正な値の処理方法、テスト以外の起動時に何が起きるかです。「値が渡されていない」状態を暗黙的に特定の業務状態と同一視してはいけません。また、アクセス資格情報、署名用データ、トークンを環境変数に入れないでください。これらはプロセス情報、テストレポート、失敗ログに現れる可能性があります。

テスト用エントリーポイントの目的は、再現可能な状態を作ることであり、業務上の検証を回避することではありません。公開インターフェースから実行できるアサーションは引き続き公開インターフェースを使用し、起動契約にはフィクスチャの初期化と残存データの消去だけを含めます。

アプリ起動の早い段階で状態を分離する

状態のリセットは、ユーザー設定の読み込み、データベースコンテナの作成、初期画面の決定より前に完了させる必要があります。先にUIを構築してから非同期で消去すると、テストは古い状態と新しい状態を同時に観測することになり、再現しにくい競合が発生します。

テストが所有するデータだけを消去する

境界のないディレクトリ削除を実行するのではなく、UIテスト専用のsuiteを使用します。データベースもテスト専用コンテナに配置し、永続化スタックを作成する前に削除します。

#if DEBUG
func prepareUITestState() throws {
    guard UITestLaunchContract.enabled else { return }

    if UITestLaunchContract.resetState {
        let defaults = UserDefaults(suiteName: "com.example.app.uitests")
        defaults?.removePersistentDomain(
            forName: "com.example.app.uitests"
        )

        let storeURL = FileManager.default.temporaryDirectory
            .appendingPathComponent("UITestStore.sqlite")
        if FileManager.default.fileExists(atPath: storeURL.path) {
            try FileManager.default.removeItem(at: storeURL)
        }
    }
}
#endif

消去関数は繰り返し実行できるようにします。対象が存在しない場合はエラーにせず、削除に失敗した場合は、不完全に消去された状態で続行するのではなく、テストの起動を直ちに中止します。フィクスチャ名にも、たとえば empty-projectthree-items のような許可リストによるマッピングを使用し、環境変数を任意のファイルパスとして直接扱わないようにします。

XCTestで起動パラメータを一元的に組み立てる

テスト側では、単一の起動ファクトリだけを呼び出します。このファクトリが既存プロセスの終了、固定引数の設定、フィクスチャの検証を担い、各テストメソッドでは launchArguments を個別に変更しません。

final class AppLauncher {
    static func launch(fixture: String) -> XCUIApplication {
        let allowed = ["empty-project", "three-items"]
        precondition(allowed.contains(fixture))

        let app = XCUIApplication()
        if app.state != .notRunning {
            app.terminate()
        }

        app.launchArguments = [
            "-ui-testing",
            "-reset-state",
            "-disable-animations"
        ]
        app.launchEnvironment = [
            "UITEST_FIXTURE": fixture,
            "LC_ALL": "en_US_POSIX"
        ]
        app.launch()
        return app
    }
}

launchArguments は配列全体を一度に代入し、共有アプリインスタンスに対して繰り返し append しないようにします。全体を代入すれば、再実行時に前のテストケースが残したフラグを引き継がずに済みます。2つの異なる状態が必要な場合は、実行中に環境変数を変更するのではなく、プロセスを終了してからファクトリを再度呼び出します。プロセスの起動後、ProcessInfo がテストスクリプトの新しい値で再初期化されることはありません。

契約違反をパイプラインの失敗として扱う

コードレビューだけでは、古い引数の残存を防ぎきれません。小規模なユニットテストを追加して、引数名が一意であること、すべてのフィクスチャを解析できることを確認できます。さらに、UIスモークテストで3つの経路を検証します。引数なしで正常に起動すること、有効なフィクスチャで想定した初期画面に進むこと、無効なフィクスチャでは明示的に失敗することです。

クラウドMacで実行する前に、scheme、テストプラン、シミュレータ識別子を固定します。

set -euo pipefail

: "${SIMULATOR_ID:?SIMULATOR_ID is required}"

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App \
  -testPlan CI \
  -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \
  -resultBundlePath artifacts/LaunchContract.xcresult

パイプラインでは、「もう一度実行する」ことで最初の失敗を隠してはいけません。初回失敗時の結果バンドルをそのまま保存し、再試行は診断手順としてのみ行い、新しい出力パスを使用します。そうしないと、2回目の成功によって、最も価値のある起動時の証拠が上書きされます。

Releaseのコンパイル条件を確認する

テスト用エントリーポイントに #if DEBUG を使用した後は、Releaseに DEBUG やカスタムのテスト条件が誤って含まれていないことも確認します。

set -euo pipefail

settings="$(
  xcodebuild \
    -workspace App.xcworkspace \
    -scheme App \
    -configuration Release \
    -showBuildSettings
)"

if printf '%s\n' "$settings" |
  grep -E 'SWIFT_ACTIVE_COMPILATION_CONDITIONS.*(DEBUG|UI_TESTING)'; then
  echo "Unexpected test compilation condition in Release" >&2
  exit 1
fi

この確認は最終的に解決されたビルド設定を対象とするため、プロジェクトファイルだけを調べるより確実です。チームで複数のxcconfigを使用している場合は、リリース可能な各configurationに対して個別に実行します。

並列実行と失敗時の証拠を扱う

テストを並列実行する場合、各workerに固有のデータ識別子が必要です。パイプラインで機密性のない UITEST_RUN_ID を生成し、それを基に一時ディレクトリとsuite名を構成できます。複数のworkerで固定のデータベースファイルを共有してはいけません。テスト完了後は、現在の実行識別子に対応するディレクトリだけを削除し、あるジョブが別のジョブの証拠を消去しないようにします。

失敗時の証拠として、少なくとも起動引数の許可リスト、フィクスチャ名、テストメソッド、シミュレータ識別子、結果バンドルのパスを保存します。環境変数の辞書全体をログへ出力してはいけません。パイプラインから注入された機密値が含まれている可能性があるためです。許可されたキーだけを記録し、値は型に応じてマスキングすることを推奨します。

調査手順は、次のように固定できます。

  1. 起動前にアプリが実際に終了しているか確認する。
  2. 引数が全体として代入され、重複キーがないことを確認する。
  3. 状態の消去がデータベースとルートUIの初期化より先に行われているか確認する。
  4. 失敗したテストケースと、その直前のテストケースが使用したデータディレクトリを比較する。
  5. 同じ結果バンドルを使って、クラッシュ、アサーション、スクリーンショットのタイムラインを確認する。
  6. テスト引数を一切付けずにコールドスタートを1回実行し、通常の起動経路に影響がないことを確認する。

リリース前の最小受け入れチェックリスト

変更をマージする前に、次の条件をすべて満たす必要があります。引数名が一元的に定義されていること、値を伴う設定に許可リストがあること、テストデータが独立したコンテナに置かれていること、消去処理を繰り返し実行できること、起動のたびに引数全体が上書きされること、並列workerがディレクトリを共有しないこと、失敗時の結果バンドルが再試行で上書きされないこと、Releaseのコンパイル条件にテスト用フラグが含まれないこと、引数なしのコールドスタートで通常フローに進めることです。

この制約の価値は、単に起動ファクトリを1つ追加することではありません。「アプリがどの状態で起動するか」というテストスクリプト内の暗黙的な取り決めを、アプリとパイプラインの両方で検証できるインターフェースへ変えることにあります。状態を記述し、拒否し、消去できるようになって初めて、クラウドMac上での順序変更、並列実行、長期的な反復実行から比較可能な結果を得られます。

よくある質問

UIテストごとに起動引数を直接追加してはいけないのはなぜですか?

引数名の表記揺れ、順序依存、古いフラグの残留が起きやすいためです。引数名、値形式、既定動作を一つの型で定義し、アプリ側とテスト側で共有します。

テスト用フックがReleaseビルドに入っていないことをどう確認しますか?

フックをDEBUG条件内に限定し、CIでReleaseのコンパイル条件とビルド設定を検査します。最後にテスト引数なしのコールド起動も実行します。

クラウドMac

検証済みのワークフローを専有物理ノードで実行

タスクに合わせて M4 または M4 Pro、対象ノード、課金期間を選択し、注文前に構成と追加オプションを確認してください。

構成を選んで注文する