Xcode Cloud でスクリーンショットテスティング
はじめに
先日の記事で Prefire を使った SwiftUI のスナップショットテスト自動生成について紹介しました。ローカルでは快適に動いていたのですが、これを Xcode Cloud の CI 環境で動かそうとしたところ、いくつかのハマりどころがありました。
この記事はその備忘録です。Xcode Cloud でスナップショットテストを実行し、テスト失敗時に Before / After / Difference の三列画像比較を PR にコメントする仕組みを構築するまでに遭遇した問題と解決策をまとめています。
全体のアーキテクチャ
最終的に 2 ステージ構成に落ち着きました。
Stage 1 の Xcode Cloud では xcresult バンドルからスナップショット画像を抽出し、Cloudflare R2 に一時アップロードします。そのあと gh workflow run で Stage 2 の GitHub Actions ワークフローをトリガーします。
Stage 2 の GitHub Actions では R2 から画像をダウンロードし、GitHub にアップロードしたうえで PR に三列比較テーブルを投稿します。
なぜ 2 ステージなのかというと、Xcode Cloud の環境から GitHub への画像アップロードが直接行うことが Xcode Cloud の実行時間を考慮すると難しいためです。Cloudflare R2 を一時ストレージとして中継することで、この制約を回避しています。
Prefire と swift-snapshot-testing のセットアップ
まず .prefire.yml で対象モジュールやデバイスを設定します。
test_configuration:
target: AppFeature
sources:
- ${PACKAGE_DIR}/Sources/AppFeature
- ${PACKAGE_DIR}/Sources/SharedUI
- ${PACKAGE_DIR}/Sources/ListFeature
- ${PACKAGE_DIR}/Sources/DetailFeature
imports:
- AppFeature
- SharedUI
- ListFeature
- DetailFeature
snapshot_devices:
- iPhone 17 Pro Max
template_file_path: PreviewTestsTemplate.stencil
template_file_path にカスタム Stencil テンプレートを指定しているのがポイントです。このテンプレートで snapshotDirectory や precision を制御します。
Package.swift のテストターゲットは以下のように定義しています。
.testTarget(
name: "SnapshotTests",
dependencies: [
"AppFeature",
"SharedUI",
.product(name: "Prefire", package: "Prefire"),
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
],
resources: [
.copy("__Snapshots__"),
],
plugins: [
.plugin(name: "PrefireTestsPlugin", package: "Prefire"),
]
)
resources に .copy("__Snapshots__") を追加しています。この理由は次のセクションで説明します。
Xcode Cloud での参照画像の扱い
ここが一番ハマったところです。
swift-snapshot-testing はデフォルトでテストファイルと同じディレクトリの __Snapshots__/ フォルダに参照画像を読み書きします。ローカル環境ではこれで問題ないのですが、Xcode Cloud ではビルドとテスト実行が異なる環境で行われるため、テストランナーからソースコードにアクセスできません。
この制約について Using Swift snapshot testing with Xcode Cloud では以下のように説明されています。
Xcode Cloud is designed so that building and executing the tests are two discrete steps that happen in different environments, and the test runner does not have access to the source code, except the ci_scripts folder.
また swift-snapshot-testing の Discussion #553 でも同様の問題が報告されており、#file で解決されるパスがテスト実行環境では無効になることが指摘されています。
そこで、SPM のリソースコピー機能を採用しました。.copy("__Snapshots__") で参照画像を Bundle.module のリソースとしてバンドルに含め、カスタム Stencil テンプレート内で参照先を切り替えるようにしています。
// PreviewTestsTemplate.stencil 内の snapshotDirectory 解決ロジック
private var snapshotDirectory: String {
let filePath = "{{ argument.file }}"
let fileURL = URL(fileURLWithPath: filePath)
let sourceDir = fileURL.deletingLastPathComponent()
.appendingPathComponent("__Snapshots__")
.appendingPathComponent(fileURL.deletingPathExtension().lastPathComponent)
.path
if FileManager.default.fileExists(atPath: sourceDir) {
return sourceDir
}
// Fallback: use Bundle.module resources (for CI environments)
return Bundle.module.resourceURL!
.appendingPathComponent("__Snapshots__")
.appendingPathComponent(fileURL.deletingPathExtension().lastPathComponent)
.path
}
まずソースディレクトリ側の __Snapshots__/ を探し、存在すればそちらを使います。これはローカル開発時のパスです。見つからなければ Bundle.module のリソースにフォールバックします。これにより、ローカルでは従来どおりの挙動を維持しつつ、Xcode Cloud でも参照画像を読み込めるようになりました。
2 ステージの連携
Stage 1 (Xcode Cloud) から Stage 2 (GitHub Actions) への受け渡しには Cloudflare R2 と grouped.json というメタデータファイルを使っています。
{
"pr_number": "7",
"commit_sha": "abc1234",
"repo": "owner/repo",
"snapshots": [
{
"name": "RepositoryDetailView",
"reference": "reference_0_xxx.png",
"failure": "failure_0_xxx.png",
"difference": "difference_0_xxx.png"
}
]
}
grouped.json と画像ファイルをまとめて R2 にアップロードし、gh workflow run の workflow_dispatch inputs は PR 番号、commit SHA、リポジトリ名の 3 つだけに絞っています。詳細情報は R2 側の grouped.json から取得する設計です。
workflow_dispatch の inputs に大量のデータを詰め込むと可読性が下がり、文字数制限にも引っかかる可能性があるため、メタデータは外部ストレージに分離するのがよいと思います。
PR コメントの生成
Stage 2 では画像を GitHub にアップロードし、Before (Reference) / After (Failure) / Difference の三列 Markdown テーブルを組み立てて PR にコメントします。
既存コメントの重複投稿を防ぐため、HTML コメントマーカー <!-- snapshot-failure-review --> を埋め込んでいます。同じマーカーを持つコメントが既にあれば更新し、なければ新規作成します。
実際に出力される PR コメントは以下のようになります。

まとめ
Xcode Cloud 単体では GitHub に画像をアップロードできないので、Cloudflare R2 を挟んだ 2 ステージ構成にしています。
試行錯誤は多かったですが、一度構築してしまえば PR 上で UI の変更を視覚的に確認できるようになります。Xcode Cloud でスナップショットテストの導入を考えている方の参考になれば幸いです。