Runnerエンジニアリング記事

simctlで再現可能なiOSクリップボード回帰テストを作る

simctlで再現可能なiOSクリップボード回帰テストを作る

iOSテストをリモートで実行する場合、クリップボード関連の機能は「手元では操作できるが、パイプラインでは検証できない」という盲点に陥りがちです。招待コードの取り込み、デバッグコマンドの貼り付け、複数行テキストの整形、アプリ内でのコピーはいずれもシステムクリップボードに依存しますが、一般的なユニットテストでは実際の入力経路を通りません。より確実なのは、ジョブごとに専用シミュレータを割り当て、simctl で固定テキストを注入し、UIテストからユーザーに見える貼り付け操作を実行する方法です。

まずテスト範囲を定義する

このフローは、プレーンテキスト、Unicode、空白文字、改行処理の検証に適しています。pbcopy を任意のバイナリデータを注入する手段として使うべきではありません。画像、ファイルURL、カスタムUTIについては、アプリ内のテストフィクスチャまたは専用のテストホストから提供します。

まず、実際に保護すべき動作を洗い出します。

自動化の目的はシステムクリップボードを直接読み取ることではなく、ユーザーがコピーまたは貼り付けを実行した後に、アプリが正しい結果を表示するかを検証することです。

ジョブごとにクリーンなシミュレータを用意する

並列ジョブで booted という曖昧なターゲットを共有してはいけません。2つのジョブが同時に pbcopy を実行すると、後から書き込まれた内容が先の内容を上書きします。固定のUDIDはスケジューラ層から渡し、DerivedDataと結果バンドルにはそれぞれ独立したディレクトリを割り当てます。

set -euo pipefail

: "${IOS_UDID:?IOS_UDID is required}"
JOB_ROOT="${PWD}/artifacts/${CI_JOB_ID:-local}"
mkdir -p "$JOB_ROOT"

xcrun simctl shutdown "$IOS_UDID" 2>/dev/null || true
xcrun simctl erase "$IOS_UDID"
xcrun simctl boot "$IOS_UDID"
xcrun simctl bootstatus "$IOS_UDID" -b

erase はアプリコンテナとシミュレータ設定を消去するため、再現性を重視する受け入れテストに適しています。準備時間を短縮するためにインストール済みアプリをパイプラインで再利用する場合でも、クリーンアップ設計を省略してはいけません。少なくともテスト対象アプリをアンインストールし、クリップボードを空にしたうえで、ランタイムバージョンとUDIDを記録します。

分離対象 推奨方法 共有した場合の典型的な問題
シミュレータ ジョブごとに1つのUDID クリップボードとアプリの状態が互いに上書きされる
DerivedData ジョブごとにディレクトリを分ける 同時書き込みによりビルド結果が不安定になる
結果バンドル 実行ごとに新しいパスを生成する 古い添付ファイルが今回の失敗記録に混入する
テキストフィクスチャ バージョン管理に含める 入力が変わってもテストコードの変更として現れない

監査可能なテキストフィクスチャを注入する

複雑なUnicode文字列をShell上で直接組み立てないでください。フィクスチャをUTF-8ファイルとして保存すれば、コードレビューで入力内容の変更を確認できます。

mkdir -p Tests/ClipboardFixtures

printf '第一行\n第二行:café\nemoji:🧪\n' \
  > Tests/ClipboardFixtures/multiline.txt

xcrun simctl pbcopy "$IOS_UDID" \
  < Tests/ClipboardFixtures/multiline.txt

フィクスチャでは通常入力と境界入力の両方を網羅しますが、すべてのケースを1つの長大なファイルに詰め込むべきではありません。1行、複数行、前後の空白、結合文字、空の内容は、それぞれ個別のケースとして管理することを推奨します。テスト失敗時のログには、フィクスチャのファイル名、バイト数、ダイジェストだけを記録します。機密データが含まれる可能性があるため、実際のクリップボード内容を無条件に出力してはいけません。

注入後、デバッグ時に一度だけ内容を読み出すことで、問題が注入側とアプリ側のどちらにあるかを確認できます。

xcrun simctl pbpaste "$IOS_UDID" > "$JOB_ROOT/clipboard-before.txt"
cmp Tests/ClipboardFixtures/multiline.txt \
  "$JOB_ROOT/clipboard-before.txt"

本番パイプラインでこの原文を長期間保存する必要はありません。一致を確認したらファイルを削除するか、shasum -a 256 の結果だけを保存します。

実際の操作経路から貼り付けとコピーを実行する

アプリには、ページ読み込み時に密かにクリップボードを読み取るのではなく、ユーザーが認識できる貼り付けコントロールを用意します。UIテストでそのコントロールをタップし、画面上の結果を検証します。これにより操作経路もカバーでき、バックグラウンドで読み取れただけの状態を機能の正常動作と誤認せずに済みます。

func testPasteMultilineFixture() {
    let app = XCUIApplication()
    app.launchArguments += ["-ui-testing"]
    app.launch()

    let pasteButton = app.buttons["clipboard.paste"]
    XCTAssertTrue(pasteButton.waitForExistence(timeout: 10))
    pasteButton.tap()

    let editor = app.textViews["clipboard.editor"]
    XCTAssertTrue(editor.waitForExistence(timeout: 5))
    XCTAssertEqual(
        editor.value as? String,
        "第一行\n第二行:café\nemoji:🧪"
    )
}

システムが貼り付け確認画面を表示する場合、テストでは内部APIで回避するのではなく、操作フローの一部として扱います。コントロールには安定したaccessibility identifierを設定し、ローカライズ後のボタンテキストに依存しないようにします。

コピー方向は逆の手順で検証できます。UIテストで固定内容を入力してコピーをタップし、テスト終了後に pbpaste で結果をエクスポートします。これにより、末尾の改行、リッチテキストからの不適切な変換、誤った空白除去を検出できます。

実行、証跡収集、トラブルシューティング

先にビルドを完了してからテストを実行すると、「コンパイル失敗」と「操作失敗」が同じログに混在するのを抑えられます。

xcodebuild build-for-testing \
  -scheme ClipboardApp \
  -destination "platform=iOS Simulator,id=$IOS_UDID" \
  -derivedDataPath "$JOB_ROOT/DerivedData"

xcodebuild test-without-building \
  -scheme ClipboardApp \
  -destination "platform=iOS Simulator,id=$IOS_UDID" \
  -derivedDataPath "$JOB_ROOT/DerivedData" \
  -resultBundlePath "$JOB_ROOT/ClipboardTests.xcresult"

失敗時は、UDIDが現在のジョブに属しているか、シミュレータの起動が完了しているか、フィクスチャのハッシュが一致しているか、アプリが実際に貼り付けコントロールをタップしたか、アサーションが最終的な画面状態を読み取っているか、の順に確認します。失敗した直後に安易に再実行してはいけません。再実行によって競合状態が隠れ、かえって障害発生時の情報を失う可能性があります。

ジョブ終了時のクリーンアップを設定しつつ、失敗時の xcresult、シミュレータログ、スクリーンショットは保存できます。クリップボードの原文はデフォルトで削除し、長さ、エンコーディング、ダイジェスト、フィクスチャ名だけを残します。テストがユーザーの入力し得る内容を扱う場合は、アーカイブ前に必ずログをマスキングします。

チェック項目をパイプラインに組み込む

安定したクリップボード回帰テストでは、少なくとも次の条件を満たす必要があります。

  1. UDID、DerivedData、結果ディレクトリがすべてジョブ単位で分離されている。
  2. シミュレータのランタイムバージョンが記録されている。
  3. テキストフィクスチャがUTF-8ファイルとしてバージョン管理されている。
  4. テストがユーザー操作を通じて貼り付けまたはコピーを実行している。
  5. 空のクリップボード、Unicode、改行にそれぞれ独立したテストケースがある。
  6. 失敗時に画面上の証跡を保存し、クリップボードの原文は長期間保持しない。
  7. ジョブ終了後にシミュレータ内の一時状態をクリーンアップする。

このテストの価値は、システムクリップボード自体を網羅することではなく、アプリとクリップボードの間の契約を、繰り返し検証できる入出力として定義することにあります。1回の失敗を特定のUDID、フィクスチャのダイジェスト、テスト手順、結果バンドルに対応付けられれば、リモートでの調査は人手による状況説明に依存しなくなります。

よくある質問

simctl pbcopyだけで画像のクリップボードテストもできますか?

汎用的なバイナリ注入手段としては使えません。テキストはpbcopyとpbpasteで検証し、画像、ファイルURL、独自UTIはアプリ内フィクスチャか専用テストホストから注入します。

並列CIジョブで同じiOSシミュレータを共有できますか?

共有は避けます。ジョブごとに別のUDID、DerivedData、結果ディレクトリを割り当て、クリップボード、アプリコンテナ、起動状態の上書きを防ぎます。

専用物理ノード

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

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

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