Runnerエンジニアリング記事

クラウドMacで構築するiOS状態復元回帰テスト

クラウドMacで構築するiOS状態復元回帰テスト

ユーザーがクラウドMac上で未送信のフォームを編集している途中、資料を確認するため別のアプリへ切り替えたところ、数分後にプロセスがシステムによって終了したとします。アプリを再度開いたとき、最も深刻なのはクラッシュではありません。画面は正常に見えるのに下書きが消えている、ホーム画面へ戻される、あるいはすでに無効なオブジェクトが復元される、といった状態です。この種の問題は通常のユニットテストでは網羅しにくいため、独立したシミュレータ上でXCUITestを使い、ライフサイクル全体を再現する方法が適しています。

復元すべき状態を先に定義する

「前回の画面を復元する」という要件を一つにまとめて扱ってはいけません。テストを書き始める前に、状態を次の3種類に分類します。

状態の種類 例 再起動後に期待する動作
ビジネス状態 下書きテキスト、フィルタ条件、選択中の項目 プロダクトのルールに従って復元
ナビゲーション状態 現在の画面、タブ、リスト位置 引き続き有効な階層まで復元
一時状態 ローディング表示、ダイアログ、ワンタイムトークン 破棄して再計算

復元ロジックでは、通常のバックグラウンド移行と異常終了も区別する必要があります。前者ではより多くの画面情報を保持できますが、後者ではデータの整合性を優先すべきです。下書きがすでに削除されたオブジェクトに依存している場合、無効な画面を無理に再構築するのではなく、安全な画面へ戻して理由を説明する必要があります。

状態復元テストの目的は、すべてのピクセルを元の位置へ戻すことではありません。ユーザーが作業を継続でき、期限切れ、権限外、または送信不能なデータが表示されないことを保証することです。

各シナリオについて、「事前状態」「終了操作」「復元後のアサーション」「発生してはならない状態」の4項目を記述することを推奨します。これによりテストが失敗した際、永続化、ルーティング、データ検証のどこに問題があるのかをチームで判断できます。

自動化用に決定的なエントリーポイントを用意する

UIテストを前回の実行で残ったデータに依存させてはいけません。テスト専用の起動引数が指定された場合に固定フィクスチャを読み込み、データを独立したコンテナへ書き込むようにします。フィクスチャ名には、変更されやすいデータベースの主キーではなく、対象となるビジネスシナリオが分かる名前を使用します。

let arguments = ProcessInfo.processInfo.arguments
if arguments.contains("-ui-testing"),
   let index = arguments.firstIndex(of: "-fixture"),
   arguments.indices.contains(index + 1) {
    let fixture = arguments[index + 1]
    try TestFixtureLoader.load(named: fixture)
}

本番ビルドでは、このテスト用エントリーポイントを無視するか削除しなければなりません。より確実なのはコンパイル条件を使用し、ローダーを内部テスト構成だけに限定する方法です。テスト開始前には通知、キャッシュ、共有設定も消去し、「以前の下書きが偶然残っていた」ためにテストが誤って成功することを防ぎます。

Runnerの専有物理ノードで実行する場合でも、パイプラインごとに独立したシミュレータのデバイスセットを割り当てる必要があります。専有コンピューティングリソースを利用していても、テストデータが自動的に分離されるわけではありません。2つの並列ジョブが同じシミュレータを共有すれば、アプリコンテナを互いに上書きしてしまいます。

XCUITestでプロセス終了と再起動を再現する

次のテストでは、最初に下書きを作成し、アプリをバックグラウンドへ移行してプロセスを終了した後、再度起動します。主要なコントロールにはすべて安定したaccessibility identifierを設定し、ローカライズされた文言による要素検索を避けてください。

func testDraftRestoresAfterTermination() {
    let app = XCUIApplication()
    app.launchArguments = [
        "-ui-testing",
        "-fixture", "empty-project",
        "-reset-state", "YES"
    ]
    app.launch()

    app.buttons["project.create"].tap()
    let editor = app.textViews["draft.editor"]
    editor.tap()
    editor.typeText("release checklist")
    app.buttons["draft.save"].tap()

    XCUIDevice.shared.press(.home)
    app.terminate()

    app.launchArguments = [
        "-ui-testing",
        "-fixture", "empty-project"
    ]
    app.launch()

    XCTAssertTrue(app.navigationBars["draft.screen"].waitForExistence(timeout: 8))
    XCTAssertEqual(app.textViews["draft.editor"].value as? String,
                   "release checklist")
    XCTAssertTrue(app.staticTexts["restoration.completed"].exists)
}

同じテスト内の2回目の起動では、リセット引数を再び渡さないでください。検証対象の状態をテスト自身が削除してしまいます。また、エディタが存在することだけをアサートするのも不十分です。内容、ナビゲーションタイトル、選択中のオブジェクト、復元完了の表示もあわせて確認します。

3種類の起動経路を個別にテストする

少なくとも次の3つの独立したテストケースを用意します。

  1. バックグラウンドから直接フォアグラウンドへ戻し、短時間の離脱で画面全体が再構築されないことを検証する。
  2. 状態を保存してからプロセスを終了し、再起動後も作業を継続できることを検証する。
  3. 無効なオブジェクトを作成してから再起動し、アプリが安全にフォールバックして無効なルートを消去することを検証する。

各テストケースでは1種類のライフサイクルだけを検証します。十数ステップを含む長いテストにまとめるよりも、失敗箇所を直接特定できます。

クラウドMac上でシミュレータと実行ディレクトリを分離する

自動化ジョブでは、ログインユーザーのデフォルトディレクトリにあるシミュレータを使用せず、一時的なデバイスセットを明示的に作成します。ジョブ終了後にデバイスセットを削除すれば、アプリコンテナと残存スナップショットも同時にクリーンアップできます。

set -euo pipefail

DEVICE_SET="$RUNNER_TEMP/CoreSimulator"
RESULTS="$RUNNER_TEMP/StateRestoration.xcresult"
mkdir -p "$DEVICE_SET"

xcrun simctl --set "$DEVICE_SET" create \
  "State-Restore-iPhone" \
  "com.apple.CoreSimulator.SimDeviceType.iPhone-16" \
  "com.apple.CoreSimulator.SimRuntime.iOS-18-0"

UDID="$(xcrun simctl --set "$DEVICE_SET" list devices \
  available -j | /usr/bin/python3 scripts/first_device.py)"

xcrun simctl --set "$DEVICE_SET" boot "$UDID"

xcodebuild test \
  -workspace Example.xcworkspace \
  -scheme ExampleUITests \
  -destination "platform=iOS Simulator,id=$UDID" \
  -resultBundlePath "$RESULTS"

xcrun simctl --set "$DEVICE_SET" shutdown "$UDID" || true
rm -rf "$DEVICE_SET"

例にあるランタイムとデバイスタイプは、ノードにインストール済みのバージョンへ置き換える必要があります。事前にxcrun simctl list runtimesとxcrun simctl list devicetypesを実行して確認してください。スクリプトで暗黙的に「最新」のランタイムを選択してはいけません。Xcodeの更新後に、スクリーンショット、システムダイアログ、復元動作がまとめて変化する可能性があります。

失敗時の証拠を診断可能な結果に変える

状態復元の失敗は通常、2回目の起動後に発生するため、復元前後の証拠を残す必要があります。重要な操作の後にスクリーンショットを添付し、テストで使用したフィクスチャ名、シミュレータ識別子、アプリのバージョン、復元フェーズをXCTest attachmentへ記録することを推奨します。失敗時には.xcresultを保存し、成功したジョブについてはチームの方針に応じて保存期間を短縮できます。

調査手順は次の順序に固定できます。

  1. 1回目の起動で状態が実際に書き込まれたことを確認する。
  2. バックグラウンド移行時にアトミックな保存が完了したか確認する。
  3. 2回目の起動でクリーンアップ処理が再実行されていないことを確認する。
  4. 永続化データのバージョン移行が成功したか確認する。
  5. 最後に、復元されたオブジェクトをルーターが受け入れているか確認する。

下書きは復元されているのにホーム画面へ戻る場合、問題はナビゲーションの再構築にある可能性が高いでしょう。画面は正しいのにフィールドが空の場合は、永続化のタイミングとストレージキーを確認します。並列ジョブでのみ失敗する場合は、シミュレータ、結果ディレクトリ、フィクスチャが本当に分離されているかを優先的に確認してください。

これらのシナリオをマージゲートに組み込めば、状態復元の検証で人が何度もアプリを切り替える必要はなくなります。永続化モデル、Sceneライフサイクル、ナビゲーション構造を変更するたびに、パイプラインが同じ入力を使って、中断した場所からユーザーが引き続き作業できるかを検証できます。

よくある質問

普段の開発で使うシミュレータを状態復元テストに流用できますか?

推奨しません。自動化専用のシミュレータまたはデバイスセットを用意し、独立した各シナリオの前にアプリデータを既知の状態へ戻してください。

アプリが再起動できることだけを確認しても不十分なのはなぜですか?

再起動の成功は直ちにクラッシュしないことしか示しません。下書き、画面階層、選択対象、復元通知に加え、機密性のある一時データが残っていないことも検証する必要があります。

専用物理ノード

クラウド上のM4 Macで次の作業を実行

Runner M4またはRunner M4 Plusを選び、プロジェクトに合わせてノード、利用期間、ストレージの追加オプションを指定できます。各注文には専用の物理マシンが割り当てられ、仮想マシンではありません。

クラウドMacを今すぐレンタル