PRでのUI差分可視化とリグレッション防止を実現する Compose Preview Screenshot Testing 導入と CI 自動化

こんにちは、Androidエンジニアの @syarihu です。

AndroidアプリのUI開発において、「UIの見た目が意図せず変わってしまった」という経験はないでしょうか。 特にリファクタリングや機能追加の際に、関係ないコンポーネントのレイアウトが崩れてしまうことは珍しくありません。

この問題を解決するために、Giftmall の Android アプリプロジェクトでは Android 公式のCompose Preview Screenshot Testing を導入しました。

本記事では、導入の経緯、セットアップ方法、GitHub Actions での自動化、そして実際の活用事例について紹介します。

実際のPRでの活用例

Compose Preview Screenshot Testing とは

Compose Preview Screenshot Testing は、Android Studio が提供する公式のスクリーンショットテストツールです。Jetpack Compose の @Preview アノテーションを活用し、UI コンポーネントのスクリーンショットを自動生成・検証できます。

2025年12月現在、このツールはまだ alpha 版のため今後のバージョンで APIや動作が変更される可能性があります。
本記事の内容も将来のバージョンでは異なる場合がありますので、公式ドキュメントも合わせてご確認ください。
本記事執筆時点(2025年12月)での最新バージョンは0.0.1-alpha11です(公式リリースノートで最新版を確認できます)。

主な特徴

  • @Preview との連携
    既存の Preview 関数をベースにテストを作成できます(テストとして実行するには @PreviewTest アノテーションの追加が必要です)。
  • 参照画像の自動生成
    update{Variant}ScreenshotTest タスクで参照画像を生成します。
  • 差分検出
    validate{Variant}ScreenshotTest タスクで UI 変更を自動検出します。
  • HTML レポート
    テスト結果を視覚的に確認できます。
  • パラメータのサポート
    Previewパラメータをそのまま使えるため、uiMode(ダークモード)や fontScalelocale など様々なパラメータでバリエーションテストが可能です。

Roborazzi との比較

スクリーンショットテストのツールとしては Roborazziも広く使われています。 設計思想が根本的に異なるため、プロジェクトの要件に応じて適切なツールを選択することが重要です。

設計思想の違い

両ツールの最大の違いは、その設計思想にあります。

Compose Preview Screenshot Testing は「静的なコンポーネントのカタログテスト」に最適化されています。
開発者が普段利用している @Preview をそのままテスト資産として再利用するというシンプルな設計で、Android Studio のプレビュー画面と同じ Layoutlib 技術を使用してレンダリングします。

Roborazzi は「動的なアプリケーションの状態テスト」までをカバーする、幅広いテストシナリオに対応するソリューションです。 Robolectric を基盤としており、Activity や Fragmentのライフサイクル、クリックやスクロールなどのユーザー操作をシミュレートしながらスクリーンショットを撮影できます。
また、ComposablePreviewScanner を併用することで、@Preview アノテーションが付いた関数を自動的にスキャンし、Roborazzi のテストとして実行することも可能です。Roborazzi公式でもroborazzi-compose-preview-scanner-supportというヘルパーライブラリが提供されています。

機能比較

観点 Compose Preview Screenshot Testing Roborazzi
設計思想 プレビューの検証に特化 アプリの包括的テスト
レンダリングエンジン Layoutlib Robolectric
テスト対象 @Preview のみ Activity, Fragment, View, @Preview(要 Scanner)
インタラクション 不可(パラメータで状態切り替え) 可能(クリック、スクロール、入力)
システム UI プレビュー範囲内のみ ダイアログ、トースト等も含む
導入コスト やや複雑(専用ソースセット、AGP 依存) 標準的(Gradle プラグイン + テストルール)
学習コスト 低い(@Preview の知識で OK) 中程度(API の習得が必要)
継続運用コスト 低い(Convention Plugin 化で 1 行、テストは @Preview 呼び出しのみ) 中程度(テストごとに API 呼び出し、@Preview 活用には Scanner 設定が必要)
公式サポート Android 公式 コミュニティ

どちらを選ぶべきか

Compose Preview Screenshot Testing が適しているケース

  • Compose の @Preview をそのままスクリーンショットとして保存・比較したい
  • デザインシステムやコンポーネントライブラリの一貫性を保ちたい
  • 学習コストの低さを重視したい
  • Android 公式ツールを使いたい

Roborazzi が適しているケース

  • ボタンクリック後の状態変化など、インタラクションを含むテストをしたい
  • Activity や Fragment 全体のスクリーンショットを撮りたい
  • ダイアログやトーストを含む画面全体をキャプチャしたい
  • 既に Robolectric を使ったテスト基盤がある

Giftmall での選定理由

Giftmall の Android アプリプロジェクトでは、Compose Preview Screenshot Testing を採用しました。

主な理由は次のとおりです。

  • 要件がシンプル
    @Preview で確認している UI をそのままスクリーンショットとして保存し、リグレッションを検出したい」というシンプルな要件だったため、Compose Preview Screenshot Testingの設計思想と合致していました。
  • 学習コストが低い
    テストクラス内で @PreviewTest と @Preview を付けた関数から既存の Preview 関数を呼び出すだけでテストが書けます。Roborazziのように専用のテストクラスやキャプチャ API を学ぶ必要がありません。 また、Convention Plugin で設定を共通化すれば、各モジュールへの導入は1行で完了します。
  • プレビューとの一貫性
    開発時に Android Studio で確認している @Preview と同じものがテストされるため、「開発時の見た目」と「テストの見た目」の乖離がありません。

インタラクションテストや画面全体のキャプチャが必要になった場合は Roborazziの導入を検討しますが、現時点ではプレビューベースのテストで十分な品質を担保できています。

セットアップ方法

1. 依存関係の定義

libs.versions.toml に必要な依存関係を定義します。

# libs.versions.toml
[versions]
screenshotValidationApi = "0.0.1-alpha11"
guava = "33.4.0-jre"

[libraries]
screenshotValidationApi = { module = "androidx.compose.ui:ui-tooling-preview-screenshot-validation-api", version.ref = "screenshotValidationApi" }
guavaJre = { module = "com.google.guava:guava", version.ref = "guava" }

[plugins]
screenshotTest = { id = "com.android.compose.screenshot", version.ref = "screenshotValidationApi" }

なお、guava-jre を追加しないとスクリーンショットテストの実行時にエラーが発生するため、必ず追加してください。

上記のバージョンは本記事執筆時点のものです。新しいバージョンがリリースされた際には AGP(Android Gradle Plugin)や Compose のバージョンとの互換性を確認してください。
特に AGP のメジャーバージョンアップ時には、Compose Preview Screenshot Testingのバージョンも更新が必要になる場合があります。

2. Convention Plugin の作成

Giftmall の Android アプリプロジェクトでは、Convention Plugin を使ってスクリーンショットテストの設定を共通化しています。

// AndroidFeatureModuleScreenshotTestConventionPlugin.kt
class AndroidFeatureModuleScreenshotTestConventionPlugin : Plugin<Project> {
  override fun apply(target: Project) {
    with(target) {
      with(pluginManager) {
        apply("com.android.library")
        apply("org.jetbrains.kotlin.android")
        apply("com.android.compose.screenshot")  // スクリーンショットテストプラグイン
      }

      extensions.configure<LibraryExtension> {
        configureUnitTest(this)
        configureScreenshotTest(this)
      }
    }
  }
}

このプラグインでは、com.android.compose.screenshot プラグインを適用し、configureScreenshotTest関数でスクリーンショットテストの設定を行っています。

// AndroidUnitTestConfiguration.kt
internal fun Project.configureScreenshotTest(
  commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
  commonExtension.apply {
    @Suppress("UnstableApiUsage")
    experimentalProperties["android.experimental.enableScreenshotTest"] = true

    dependencies {
      add("screenshotTestImplementation", libs.findLibrary("jetpackComposeTooling").get())
      add("screenshotTestImplementation", libs.findLibrary("screenshotValidationApi").get())
      add("screenshotTestImplementation", libs.findLibrary("guavaJre").get())
    }
  }
}

experimentalProperties でスクリーンショットテスト機能を有効化し、必要な依存関係を screenshotTestImplementation で追加しています。

3. Convention Plugin の登録

作成した Convention Plugin を build-logic/convention/build.gradle.kts に登録します。

// build-logic/convention/build.gradle.kts
dependencies {
  // ... 他の依存関係 ...
  compileOnly(libs.compose.gradlePlugin)  // スクリーンショットテストプラグインに必要
}

gradlePlugin {
  plugins {
    // ... 他のプラグイン登録 ...

    register("androidFeatureModuleScreenshotTest") {
      id = "jp.co.giftmall.android.library.screenshotTest"
      implementationClass = "AndroidFeatureModuleScreenshotTestConventionPlugin"
    }
  }
}

dependencies ブロックに libs.compose.gradlePlugin を追加することで、Convention Plugin 内でスクリーンショットテストプラグインを使用できるようになります。

register でプラグインを登録し、id にはモジュールの build.gradle.kts で指定するプラグインIDを設定します。

また、settings.gradle.kts で build-logic を含めることで、プロジェクト全体で Convention Pluginを利用可能にします。

// settings.gradle.kts
pluginManagement {
  includeBuild("build-logic")
  // ...
}

includeBuild("build-logic") により、build-logic ディレクトリ内の Convention Plugin がプロジェクト全体から参照できるようになります。

4. モジュールへの適用

スクリーンショットテストを有効にしたいモジュールで、Convention Plugin を適用します。

// feature/product/build.gradle.kts
plugins {
  id("jp.co.giftmall.android.library.screenshotTest")
}

Convention Pluginを事前に作成・登録しておけば、この1行を追加するだけでスクリーンショットテストに必要な設定と依存関係がすべて適用されます。

5. テストの作成

src/screenshotTest/kotlin ディレクトリにテストクラスを作成します。

// ProductReviewItemReplierTest.kt
internal class ProductReviewItemReplierTest {

  @PreviewTest
  @Preview(
    name = "GiftmallShortComment",
    locale = "ja",
    uiMode = Configuration.UI_MODE_NIGHT_NO,
  )
  @Preview(
    name = "GiftmallShortCommentDark",
    locale = "ja",
    uiMode = Configuration.UI_MODE_NIGHT_YES,
    showBackground = true,
    backgroundColor = 0xC9000000,
  )
  @Composable
  fun GiftmallReplierShortCommentTest() {
    // 既存の Preview 関数を呼び出すだけ!
    ProductReviewItemReplierGiftmallShortCommentPreview()
  }
}

複数の @Preview アノテーションを付けることで、ライトモードとダークモードの両方を一度にテストできます。

Preview 関数の定義場所

Giftmall の Android アプリプロジェクトでは、テスト関数内で直接 Composable を記述するのではなく、ソースファイルに定義した Preview 関数を呼び出す形式にしています。

この形式にしている理由は次のとおりです。

  • 実際の Preview とスクリーンショットテストの Preview を同じにできる
    開発時に Android Studio で確認している Preview と同じものがテストされるため、不足している Previewパターンに気づきやすくなります。
  • 管理が一箇所で済む
    Preview 関数をソースファイルに集約することで、テストデータやバリエーションの管理が容易になります。
// ProductReviewItem.kt(ソースファイル)
@VisibleForTesting
@Preview
@Composable
internal fun ProductReviewItemReplierGiftmallShortCommentPreview() {
  ProductReviewItem(
    // テスト用のデータを設定
  )
}

テストから呼び出される Preview 関数には @VisibleForTestingアノテーションを付けて、テストからの呼び出しのみを想定していることを明示しています。 また、Preview関数のスコープは internal にしています。 Preview関数はプレビューのみの用途で作成するため本来は privateにしたいところですが、テストから参照できるようにしつつ影響範囲を最小限に抑えるため、モジュール内のみからアクセス可能なinternal を採用しています。

ダークモードの設定について

ダークモード用の @Preview では showBackground = truebackgroundColor = 0xC9000000 を指定しています。これらがないと背景が透明になり、ダークモードの見た目を正しくキャプチャできません。

テストクラスに関する注意点

通常の @Preview 関数はクラスに含める必要はありませんが、スクリーンショットテストではクラスに含めないと画像が生成されません。

また、クラス名がそのまま参照画像のサブディレクトリ名になります。

例えばProductReviewItemReplierTest クラスの場合、参照画像は次のパスに生成されます。

src/screenshotTestDebug/reference/jp/co/.../ProductReviewItemReplierTest/

Preview の name に関する注意点

通常の Preview では name = "Giftmall Short Comment" のように半角スペースを含む名前を付けることがありますが、スクリーンショットテストではname がファイル名の一部になるため、スペースを含むとファイル名にもスペースが入ってしまいます。

ファイル名にスペースが含まれると扱いづらいため、GiftmallShortComment のようにスペースなしの名前にしています。

ローカル開発での Docker 活用

スクリーンショットテストは実行環境の違いに影響を受けやすいです。macOS と Ubuntuで生成されたスクリーンショットは、見た目が同じでもバイナリレベルで差異が生じることがあります。

これは次の要因によるものです。

  • グラフィックスライブラリのバージョン差異
  • フォントレンダリングの違い
  • PNG 圧縮アルゴリズムの差異

GitHub Actions は ubuntu-latest 環境で実行されるため、ローカル(macOS)での開発時は Docker を使ってUbuntu 環境に合わせています。

# Makefile
docker_update_screenshot:
    sh scripts/screenshot/run_screenshot.sh update

docker_validate_screenshot:
    sh scripts/screenshot/run_screenshot.sh validate

シェルスクリプト内で Docker コンテナを起動しています。

# scripts/screenshot/run_screenshot.sh(抜粋)
docker run --rm \
  -e CI=true \
  -v "$ROOTDIR":/workspace \
  -v "$GRADLE_CACHE_DIR":/home/circleci/.gradle \
  -w /workspace \
  cimg/android:2025.11 \
  /bin/bash -c "cd /workspace && ./gradlew --stacktrace $TASK"

cimg/android:2025.11 は CircleCI が提供する Android 用 Docker イメージで、GitHub Actions の ubuntu-latest と互換性のある環境を再現できます。

Gradle キャッシュもマウントすることで、2回目以降の実行を高速化しています。

docker_update_screenshot で参照画像を更新し、docker_validate_screenshot で検証を実行します。

また、Convention Plugin で GitHub Actions 環境外での直接実行を防止しています。

// CI 環境外での直接実行を防止
afterEvaluate {
  tasks.matching { task ->
    task.name.matches(Regex("(update|validate).*ScreenshotTest$"))
  }.configureEach {
    doFirst {
      val isCI = System.getenv("CI")?.toBoolean() ?: false
      if (isCI.not()) {
        throw IllegalStateException(
          """
          |❌ Direct execution of screenshot tasks is not allowed outside CI environment.
          |Please use: make docker_update_screenshot
          """.trimMargin()
        )
      }
    }
  }
}

このコードは、CI 環境変数が設定されていない場合(ローカル環境)にスクリーンショットテストタスクの実行を拒否します。エラーメッセージで make docker_update_screenshot の使用を促すことで、開発者が誤って環境差異のある画像を生成することを防いでいます。

GitHub Actions での自動化

GitHub Actions でスクリーンショットテストを自動実行し、差分がある場合は PR に比較画像をコメントする仕組みを構築しました。 これにより、リファクタリングや機能追加の際に関係ないコンポーネントのレイアウトが崩れてしまった場合でも、PR上で即座に気づくことができます。

ワークフローの概要

ワークフローは大きく分けて4つのステップで構成されています。

  1. 変更検知
    スクリーンショットテスト対象モジュールが変更されたかチェック
  2. テスト実行
    validateDebugScreenshotTest でスクリーンショットを検証
  3. 比較画像生成
    テスト失敗時に比較画像を自動生成してコミット
  4. PR コメント
    比較画像付きのコメントを自動投稿

それぞれのステップについて詳しく説明します。

ステップ 1: 変更検知

すべての PR でスクリーンショットテストを実行すると時間がかかるため、スクリーンショットテストプラグインを使用しているモジュールのファイルが変更された場合のみ実行するようにしています。

変更検知スクリプト(check-screenshot-test-changes.sh)では、build.gradle.kts を解析してスクリーンショットテストプラグインを使用しているモジュールを特定し、それらのモジュール配下のファイルが変更されているかをチェックしています。関係ないモジュールの変更ではスキップされるため、無駄な実行を避けて効率化できます。

ステップ 2: テスト実行

ubuntu-latest 環境で validateDebugScreenshotTest を実行してスクリーンショットを検証します。参照画像と実際の出力を比較し、差分があればテストが失敗します。

ステップ 3: 比較画像生成

テストが失敗した場合、次の処理を自動で行います。

  1. updateDebugScreenshotTest で最新の actual 画像を生成
  2. ImageMagick で expected と actual の diff 画像を生成
  3. 比較画像(_actual.png, _diff.png)を PR のブランチにコミット・push

ImageMagick の環境について

diff 画像の生成には ImageMagick を使用しています。ImageMagick は GitHub Actions の ubuntu-22.04 には標準搭載されていますが、ubuntu-24.04 からは削除されているため、ubuntu-latest(現在は 24.04)で使用する場合は自前でインストールする必要があります。

Giftmall の Android アプリプロジェクトでは、Docker を使って ImageMagick を実行しています。

# .github/docker/imagemagick/Dockerfile
FROM ubuntu:24.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && \
    apt-get install -y imagemagick && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /workspace

この Docker イメージを GitHub Actions 内でビルドして使用することで、ubuntu-latest でも安定して ImageMagick を利用できます。

比較画像の自動コミットについて

比較画像のコミットには [skip ci] を付けています。

git commit -m "[skip ci] Add screenshot test comparison images"

[skip ci] を付けると、このコミットでは他のワークフロー(ビルドやテストなど)がトリガーされません。これは意図した動作です。理由は次のとおりです。

  • ビルドやテストなどが実行されていない状態(成功していない状態)では PR をマージできない
  • 開発者が修正コミットを push すればワークフローは再実行される
  • 比較画像の追加だけで required checks を再実行する必要がない

つまり、比較画像のコミットは「差分の可視化」のためだけのものであり、ワークフローの再実行は開発者の修正コミットに任せる設計になっています。

ステップ 4: PR コメント

比較画像付きのコメントを自動投稿します。テストが失敗すると、次のような比較画像付きコメントが PR に投稿されます。

Expected(期待値)、Actual(実際の出力)、Diff(差分)の3カラムで変更点が一目で分かります。

比較画像の永続化について

GitHub の PR コメントには画像を直接アップロードする API がないため、比較画像は PR のブランチにコミットして push しています。コメント内ではコミットハッシュを含む URL で画像を参照することで、PR がマージされなくてもクローズされても画像が消えずに残るようにしています。

# コミットハッシュを含む画像URL の例
<https://github.com/owner/repo/blob/{commit-hash}/path/to/image.png?raw=true>

この方式により、後から PR を見返したときも比較画像を確認できます。

必要な permissions

ワークフローを動作させるには、次の権限が必要です。

permissions:
  contents: write      # 比較画像のコミット・push に必要
  pull-requests: write # PR へのコメントに必要

contents: write は比較画像を PR のブランチにコミット・push するために、pull-requests: write は PR にコメントを投稿するために必要です。

比較画像のクリーンアップ

テスト失敗時に生成される比較画像(*_actual.png, *_diff.png)は、手動でクリーンアップする必要があります。これらの画像が残っている限り、参照画像を更新してもテストは失敗し続けます。

クリーンアップの流れは次のとおりです。

  1. PR コメントで差分を確認し、意図した変更であれば参照画像を更新(make docker_update_screenshot
  2. 比較画像(_actual.png, _diff.png)を削除
  3. 変更をコミット・push

比較画像が残っている状態でテストが成功すると、クリーンアップを促すリマインダーコメントが表示されます。

比較画像を削除してテストが成功すると、リマインダーコメントは自動で削除されます。

テスト成功時

すべてのテストが成功すると、シンプルな成功メッセージが表示されます。

機能開発での活用

スクリーンショットテストはテストだけでなく、機能開発の効率化にも活用できます。

PR での UI 確認

新しい UI コンポーネントを追加する際、スクリーンショットテストの参照画像を PR の body に埋め込むことで、レビュアーが実機を起動せずに UI を確認できます。

次は実際の PR での活用例です。

参照画像は Git にコミットされているため、PR の body から直接参照できます。

これにより、次のようなメリットがあります。

  • レビュー効率の向上
    実機やエミュレータを起動せずに UI を確認できます。
  • ダークモード対応の確認
    ライトモード/ダークモード両方を一覧で比較できます。
  • 様々なバリエーションの確認
    長いテキスト、空のデータなど様々なケースを視覚的に確認できます。

AIの活用

スクリーンショットテストの作成や PR のドキュメント作成は、AI(Claude Code や GitHub Copilotなど)に任せることで大幅に効率化できます。

テストクラスの自動生成

既存の @Preview 関数があれば、AI にテストクラスの生成を依頼できます。

  • ソースファイルにある Preview 関数を指定してスクリーンショットテストの作成を依頼
  • ライトモードとダークモードの両方のテストケースを自動生成
  • テストクラスの命名規則やディレクトリ構造も指示すれば統一可能
  • 「Preview の name にはスペースを含めないでください」と指示すれば、ファイル名の問題も回避可能

テストの雛形作成を AI に任せることで、開発者はテストケースの選定やレビューに集中できます。

比較テーブルの自動生成

PR の body に埋め込むスクリーンショットの比較テーブルも、次の2つの情報があれば AI に生成させることができます。

  • スクリーンショットテストが生成した参照画像のパス
  • 参照画像をコミットしたコミットハッシュ

手作業で URL を組み立てる必要がなく、PR 作成の効率が大幅に向上します。

スクリーンショットテストのベストプラクティス

Android 公式ドキュメントでは、スクリーンショットテストのベストプラクティスとして「スクリーンショットテストは最小限に抑える」ことが推奨されています。

スクリーンショットテストを最小限に抑える

スクリーンショットテストでは、リグレッションに対するフィードバックとカバレッジを最大化しながら、テスト数を最小限に抑えることが重要です。

すべての組み合わせをテストしない

例えば、カスタムボタンをライトモード/ダークモードの2パターンと、3つのフォントサイズでテストする場合、単純に考えると 2 × 3 = 6 通りの組み合わせが必要になります。

しかし、すべての組み合わせをテストする必要はありません。ユニークなフィードバックが得られるスクリーンショットを選択することが重要です。

例えば、次のように考えます。

  • ダークモードのテストは、色の確認が主目的 → フォントサイズは1種類で十分
  • フォントサイズのテストは、レイアウト崩れの確認が主目的 → ライトモードのみで十分

このように考えると、6通りではなく3〜4通りのテストで十分なカバレッジを得られる可能性があります。

スクリーンショットテストが適しているケース

スクリーンショットテストは次のような検証に効果的です。

  • 視覚的な要素の一括検証
    色、マージン、サイズ、フォントなどを同時に検証できます。
  • 異なる画面サイズでの視覚的リグレッション検出
    複数のデバイスサイズでの表示確認に有効です。
  • 特定の条件下でのコンポーネントの動作
    エラー状態、空のデータなど、特定の状態の見た目を確認できます。

Giftmall での実践

Giftmall の Android アプリプロジェクトでは、次のような方針でテストを最小限に抑えています。

  • ライトモード/ダークモードは両方テスト
    色の問題は見逃しやすいため、両モードをテストしています。
  • フォントサイズは標準のみ
    フォントサイズによるレイアウト崩れは、ほとんどのケースで標準サイズのテストで検出できます。
  • 状態のバリエーションは代表的なものに絞る
    すべての状態をテストするのではなく、視覚的に異なる代表的な状態のみをテストしています。

この方針により、テストの数を抑えつつ、十分なカバレッジを維持しています。

まとめ

Compose Preview Screenshot Testing を導入することで、次のようなメリットが得られます。

  • UI のリグレッション防止
    意図しない UI 変更を自動検出できます。
  • シンプルな運用
    既存の @Preview 関数を呼び出すテスト関数に @PreviewTest を追加するだけです。
  • GitHub Actions での自動化
    PR で自動的に差分を検出・可視化できます。
  • 開発効率の向上
    PR での UI レビューが容易になります。

Roborazzi と比較して運用がシンプルで、Android 公式ツールという安心感もあります。 2025年12月現在ではまだ alpha 版ではありますが、テストコードなのでプロダクションに影響を与えることなく試せますし、運用もシンプルです。 スクリーンショットテストの導入を検討されている方は、試してみてはいかがでしょうか。

参考リンク