BookaMac エンジニアリングノート

クラウド Mac で iOS 視覚回帰ゲートを構築する

クラウド Mac で iOS 視覚回帰ゲートを構築する

同じ iOS UI コードでも、開発端末では正常に見えていたのに、マージ後にはボタンの欠け、Dynamic Type によるレイアウトの圧迫、ダークモードでの色の不一致といった問題が発生することがあります。視覚回帰ゲートの目的は単体テストを置き換えることではありません。固定した描画条件のもとで、「今回のコミットによって、ユーザーが実際に目にするピクセルが変化したか」という具体的な問いに答えるためのものです。

クラウド Mac はこの種のタスクを継続的に実行するのに適しています。ただし、Simulator の状態、テストデータ、比較ルールをすべてバージョン管理に含める必要があります。「正しく見える」スクリーンショットを数枚保存するだけでは、安定したゲートにはなりません。

安定したスクリーンショットマトリクスを先に定義する

最初から「すべての画面を網羅する」ことを目指してはいけません。まずはログイン前のホーム画面、主要な一覧、詳細画面、空状態、エラー状態など、価値の高い画面を選びます。そのうえで、各画面について次の条件を固定します。

条件 推奨する固定値 変更時の対応
デバイス 明確に指定した 1 つの Simulator モデル 独立した基準ディレクトリを新規作成
システム 利用するランタイムを指定 基準画像を再レビュー
外観 ライトとダークを個別に実行 異なる外観同士は比較しない
言語 対象言語ごとに個別に撮影 ファイル名に言語コードを含める
文字サイズ デフォルトサイズから開始 大きな文字サイズは別のテストグループにする
データ ローカルフィクスチャまたはテスト API ランダムな内容への依存を禁止

基準画像のパスには Snapshots/<runtime>/<device>/<locale>/<appearance>/ のような構成を使用できます。ディレクトリ階層はやや深くなりますが、失敗時にどの環境で比較されたかをすぐ確認でき、異なるシステム間の描画差を製品の回帰と誤認するのを防げます。

基準画像は永久に正しい答えではありません。レビュー済みの UI 契約であり、更新する場合は必ずコード変更とあわせてレビューする必要があります。

Simulator を固定し、既存状態を繰り返し使わない

手動操作を重ねた 1 台の Simulator を長期間使い回すと、権限ダイアログ、キーボード設定、キャッシュ、テストアカウントなどが残ります。より確実なのは、視覚テスト専用のデバイスを割り当て、実行のたびにシャットダウン、消去、再起動する方法です。

#!/usr/bin/env bash
set -euo pipefail

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

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

xcrun simctl status_bar "$DEVICE_UDID" override \
  --time 09:41 \
  --batteryState charged \
  --batteryLevel 100 \
  --wifiBars 3 \
  --cellularBars 4

rm -rf TestResults/Visual.xcresult
xcodebuild test \
  -workspace Example.xcworkspace \
  -scheme ExampleVisualTests \
  -destination "id=$DEVICE_UDID" \
  -resultBundlePath TestResults/Visual.xcresult

rm -rf TestResults/Attachments
xcrun xcresulttool export attachments \
  --path TestResults/Visual.xcresult \
  --output-path TestResults/Attachments

現在起動中の booted デバイスに依存してはいけません。並列タスクによって複数の Simulator が同時に起動する可能性があります。UDID を明示的に渡すことで、ステータスバーの上書き、インストール、テストが確実に同じデバイス上で実行されます。

観測可能な状態を使ってスクリーンショットを取得する

視覚テストで特に多い誤りは、アプリを起動してから固定で 2 秒待つことです。マシンの負荷やネットワーク状況によって、2 秒では長すぎる場合もあれば、足りない場合もあります。XCUITest では、「画面が比較可能な状態になった」ことを示すアクセシビリティ要素を待機するべきです。

func capture(_ name: String, readyIdentifier: String) {
    let app = XCUIApplication()
    app.launchArguments = ["-VisualTestMode", "1"]
    app.launch()

    let ready = app.descendants(matching: .any)[readyIdentifier]
    XCTAssertTrue(ready.waitForExistence(timeout: 15))

    let image = XCUIScreen.main.screenshot()
    let attachment = XCTAttachment(screenshot: image)
    attachment.name = name
    attachment.lifetime = .keepAlways
    add(attachment)
}

テストモードではカルーセル、スケルトンアニメーション、点滅するカーソルを無効にし、固定の日付、ユーザー名、一覧データを注入します。固定するのは入力であり、テスト対象の UI を別実装に置き換えるわけではありません。画面がリクエスト完了に依存する場合は、スリープ時間をさらに増やすのではなく、読み込み完了を示す安定した識別子を UI に公開します。

フォントとアニメーションを制御する

フォントはシステム提供のもの、またはアプリに同梱したものを使用し、手動で一度だけインストールしたフォントには依存しないようにします。アニメーションは起動引数で無効化でき、撮影前にはスクロールが停止していることも確認する必要があります。常に変化する動画、地図、タイマーには、決定論的なテストフィクスチャを優先して使用します。マスクを使うのは、制御できないごく小さな領域に限ります。

説明可能なピクセルしきい値を設定する

PNG ファイルのバイト列が異なっていても、見た目まで異なるとは限りません。cmp で直接判定すると、エンコード情報の差に影響されやすくなります。比較ツールでは、まず両方の画像を同じサイズと sRGB ピクセル形式にデコードし、その後に各ピクセルのチャンネル差を計算する必要があります。

次の 2 条件を併用することを推奨します。

  1. チャンネルごとの最大許容差。軽微なアンチエイリアスの変化などを許容します。
  2. 許容差を超えたピクセルの割合。画像全体のごく小さな割合を超えないようにします。

平均差だけでは、局所的で重大な問題が見過ごされます。たとえば、ボタンが 1 つ消えても、画像全体に占める面積はわずかかもしれません。比較ツールは差分を強調した画像も出力し、しきい値を超えたピクセル数、バウンディングボックス、使用したしきい値を記録する必要があります。重要な確認画面には厳格なルールを適用し、影や複雑なグラデーションを含む画面には個別の設定を用意できます。ただし、テストを「緑」にするためだけにグローバルなしきい値を広げ続けてはいけません。

失敗を再確認可能な証拠として保存する

失敗するたびに、少なくとも基準画像、実際の画像、差分画像、.xcresult を保存します。あわせて、コミット識別子、Xcode のバージョン、システムランタイム、デバイスモデル、言語、外観も記録します。ファイル名はテストスイート、画面状態、環境から構成し、並列タスクによる相互上書きを防ぎます。

調査時は、まず画像サイズとランタイムが変わっていないかを確認し、次にテストデータが安定しているかを調べ、その後に差分のバウンディングボックスを確認します。差分が画面全体に広がっている場合は、フォント、外観、色空間のずれが原因であることが一般的です。差分が 1 つのコントロールに集中している場合に限り、レイアウト変更の可能性が高くなります。

基準画像の更新も独立した手順で行う必要があります。候補画像を生成し、人が差分を確認し、変更意図を確認してから置き換えます。テストタスクが失敗時に基準画像を自動上書きしてはいけません。そうすると、本物の回帰が次回の実行でそのまま受け入れられてしまいます。

完成したゲートには、環境を再構築できること、しきい値を説明できること、失敗を再現できること、という 3 つの特徴が必要です。この 3 点を満たして初めて、スクリーンショットテストは、ときどき人手で整理しなければならない画像置き場ではなく、エンジニアリング上の検査になります。

よくある質問

すべての画素が完全一致した場合だけ合格にすべきですか?

通常は推奨しません。色チャンネルごとの差と、しきい値を超えた画素の割合を併用し、カーソルやアニメーションなど避けられない領域だけを小さく除外します。

同じコミットでも Simulator ごとに画像が変わるのはなぜですか?

端末サイズ、システムのバージョン、フォント、言語、タイムゾーン、表示倍率、ステータスバーが描画に影響します。基準画像は環境の組み合わせごとに管理します。

失敗時に保存すべき成果物は何ですか?

基準画像、実画像、差分画像、テスト結果バンドル、コミット識別子、Simulator の機種とシステムバージョンをまとめて保存します。

専有物理 Mac mini

検証済みのワークフローを常時稼働するクラウドMacへ

用途に合わせてBookaMac M4またはBookaMac M4 Proを選び、注文時にリージョン、契約期間、ストレージの追加オプションをご確認ください。

レンタルプランを選ぶ