Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

UI確認コストを削減!Roborazzi+CIによるVRT導入事例と運用効果

こんにちは!技術本部 Sansan Engineering Unit Mobile Applicationグループの桑原です。

さて、Androidアプリ開発に携わっていると、UIの変更は日常的に発生しますよね。そして、それに伴う確認作業は意外と手間がかかります。「レイアウトを修正したら意図しない部分まで影響が出てしまった」「文字サイズや表示サイズを変更したら表示が崩れた」といった経験をされた方も多いのではないでしょうか。

本記事では、私たちのチームがVisual Regression Test(VRT)を導入して約2ヶ月間運用した結果 得られた効果と、具体的な設定やCI連携についてご紹介します。

導入前の課題

私たちのチームでは、次のような課題を抱えていました。

  • 確認項目の多さによるチェック漏れ: 文字サイズ、表示サイズ、多言語対応など様々な条件下でUIを確認する必要があり、どうしてもチェック漏れが発生しがちでした。レイアウト変更時の確認不足が原因で、QAフェーズでの手戻りが発生することもありました。
  • 意図しない変更の検知の難しさ: 少しレイアウトを修正しただけでも、意図しない箇所に変更が及ぶことがあります。しかし、変更前の状態を正確に把握していないと、その差分に気づくのは困難です。

これらの課題を解決するため、私たちはVRTの導入を検討しました。

なぜスクリーンショットテスト(VRT)を選んだのか?

UIの品質を担保する方法はいくつかありますが、上記の課題に対して、次のようなアプローチを比較検討しました。

  1. 人力対応 (ルール徹底・チェックリスト等):
    • アプローチ: 確認項目を明文化し、チェックリストによって対応漏れを防ぐ。
    • メリット: 導入が手軽で、既存の開発フローにも組み込みやすい。
    • デメリット: 手作業の手間は依然として残り、人為的ミスや確認漏れを完全には防げない。また、意図しない変更を網羅的に検出することは困難。
  2. UI動作テスト (EspressoJetpack Compose Testing等):
    • アプローチ: UIの状態や動作をコードで検証し、自動化する。
    • メリット: 手動確認の工数を削減でき、リグレッションの防止に貢献。
    • デメリット: テストコードの作成・保守コストが高く、すべての画面や表示パターンを網羅するのは現実的ではない。また、見た目の変化すべてを検知するには限界がある。
  3. スクリーンショットテスト (VRT):
    • アプローチ: UIの見た目をスクリーンショットで記録し、差分を画像として比較・検出する。
    • メリット: 視覚的な差分を自動で検知でき、意図しない変更を効率よく発見可能。Compose Previewと連携することで、新たなテストコードの記述なしに多様な表示パターン(多言語、文字サイズなど)を手軽に網羅できる。
    • デメリット: 擬似環境のため、実機表示との差異が生じる場合がある。

これらを比較検討した結果、スクリーンショットテスト(VRT)の導入が最適であると判断しました。特に、Compose Previewとの高い親和性により、追加の実装コストを抑えながら「チェック漏れ」や「意図しない変更の検知」といった核心的な課題に対応できる点が、導入を決めた大きな理由です。

技術選定:Roborazzi と ComposablePreviewScanner

スクリーンショットテストを実現するライブラリはいくつかありますが、私たちはRoborazziComposablePreviewScannerの組み合わせを採用しました。その理由を簡単に紹介します。

  • Roborazziを選んだ理由:
    • Robolectricベース: RoborazziはRobolectricを利用しており、スクロールやクリックを含むインタラクションのテストにも対応しています。そのため、Paparazziよりも柔軟性が高く、実用上の選択肢として魅力がありました。
    • Compose公式機能の懸念点: Compose Preview Screenshot Testing は、Preview関数をscreenshotTestフォルダに配置する必要があり、開発体験の面で扱いにくさを感じました。また、まだ安定版ではないこともあり、今回は採用を見送る判断としました。
    • 導入リスクの低さ: 個人開発のOSSではありますが、テスト用ツールでプロダクトコードへの影響は限定的です。長期的なサポート保証がなくともプロジェクト本体には影響しないと判断しました。
  • ComposablePreviewScannerを選んだ理由:
    • 容易なパターン収集: Preview関数のメタデータに簡単にアクセスでき、Showkaseよりも少ない手間で様々なパターンのテストケースを実装できる点が魅力でした(Showkaseでは既存のPreview関数に対してprivateを外す、groupプロパティを付与する等の修正が必要になります)。
    • Roborazziとの相性: Roborazzi前提で利用する場合にComposablePreviewScannerは相性が良く、将来的に互換性が失われるリスクも低いと考えました。
    • 導入リスクの低さ: こちらも個人開発のOSSでリリースから1年未満と実績は少ないですが、テストツールであるため導入リスクは限定的だと判断しました。

実装・運用の仕組み:RoborazziとCI連携のポイント

ここでは、実際にプロジェクトでどのようにRoborazziを設定し、CIと連携させているかについて、ポイントを抜粋してご紹介します。

なお、全体像を把握するには、RoborazziのREADMEや、DeNAさんがGithubに公開しているサンプル実装が非常に参考になります。

1. Roborazziの基本的な設定

まずは、build.gradleでRoborazziプラグインを有効化し、基本的な設定を行います。

android {
    // RoborazziのREADMEに従って設定
    testOptions {
        unitTests {
            setIncludeAndroidResources(true)
            all {
                it.systemProperties = [
                        "roborazzi.output.dir"           : rootProject.file("screenshots_output").absolutePath,
                        "robolectric.pixelCopyRenderMode": "hardware"
                ]
            }
        }
    }
}

roborazzi {
    // Compose Previewのスクリーンショットテスト生成機能を有効にする
    generateComposePreviewRobolectricTests.enable.set(true)

    // スクリーンショットの出力ディレクトリを指定
    outputDir.set(rootProject.file("screenshots_output"))

    // テスト対象のパッケージを指定
    generateComposePreviewRobolectricTests.packages.set([
        "com.yourcompany.feature",
    ])

    // 利用するカスタムテスタークラスを指定
    generateComposePreviewRobolectricTests.testerQualifiedClassName.set("com.yourcompany.screenshots.YourComposePreviewTester")
}

2. カスタムテスターの実装

ここが実装の核となります。ComposablePreviewScannerで収集したPreview情報に基づき、Robolectricの設定を動的に変更して多様なパターンのスクリーンショットを撮影します。

@OptIn(ExperimentalRoborazziApi::class)
class YourComposePreviewTester : ComposePreviewTester<AndroidPreviewInfo> {

    private val composeTestRule = createAndroidComposeRule<ComponentActivity>()

    override fun previews(): List<ComposablePreview<AndroidPreviewInfo>> {
        // Composable Preview Scannerを使ってプレビュー関数を収集
        return AndroidComposablePreviewScanner()
                // build.gradleで指定したパッケージをスキャン
                .scanPackageTrees(*options().scanOptions.packages.toTypedArray())
                // PrivateなPreview関数もテストに含める
                .includePrivatePreviews()
                // 特定のアノテーションを持つPreviewを対象に含める(後述)
                .includeAnnotationInfoForAllOf(MultiScreenPreviews::class.java)
                .getPreviews()
    }

    override fun test(preview: ComposablePreview<AndroidPreviewInfo>) {
        // スクリーンショットのファイル名を生成(ユニークにしないと上書きされるため注意)
        val previewScannerFileName = AndroidPreviewScreenshotIdBuilder(preview).build()
        val filePath = "$previewScannerFileName.png"

        // Previewのアノテーション情報をもとにRobolectricの設定を適用
        preview.applyToRobolectricConfiguration()

        // 設定変更を反映させるためにActivityを再生成
        composeTestRule.activityRule.scenario.recreate()

        // Preview関数を実行
        composeTestRule.setContent { preview() }

        // スクリーンショットを撮影
        composeTestRule.onRoot().captureRoboImage(filePath = filePath)
    }
}

// Robolectricの設定を適用する拡張関数
fun ComposablePreview<AndroidPreviewInfo>.applyToRobolectricConfiguration() {
    val preview = this

    // 画面高さの設定
    val isScreenPreview = preview.getAnnotation<MultiScreenPreviews>() != null
    val height = when {
        isScreenPreview -> 2000 // 特定のプレビューは、画面が見切れるリスクを考慮して高めに設定
        else -> 914 // Pixel7の高さ相当
    }
    // Widthや高さに基づき端末の画面サイズを適用
    if (preview.previewInfo.name == "SmallWidth_LargeFont") {
        RuntimeEnvironment.setQualifiers("w${preview.previewInfo.widthDp}dp-h${height}dp-normal-long-notround-any-420dpi-keyshidden-nonav")
    } else {
        RuntimeEnvironment.setQualifiers("w411dp-h${height}dp-normal-long-notround-any-420dpi-keyshidden-nonav")
    }

    // 言語設定を適用
    if (preview.previewInfo.locale.isNotEmpty()) {
        RuntimeEnvironment.setQualifiers("+${preview.previewInfo.locale}")
    }
    // 文字サイズを適用
    if (preview.previewInfo.fontScale != 1f) {
        RuntimeEnvironment.setFontScale(preview.previewInfo.fontScale)
    }
}

ポイント:

  • createAndroidComposeRulerecreate() の組み合わせで、ロケールやフォントサイズなど、Activity再生成が必要な設定変更を確実に反映させています。
  • applyRobolectricConfiguration() がキモです。ここで各Preview関数に指定された引数(例えばlocalefontScaleなど)を読み取り、RobolectricのRuntimeEnvironmentを使ってテスト実行時のデバイス環境を動的に変更しています。 これにより、Preview定義から多言語、特定画面サイズなど様々なテストケースを自動生成することが可能になります。

3. Preview Annotationの工夫

多様なテストパターンを効率よく扱うために、複数の @Preview をまとめたカスタムアノテーションを定義しています。これにより、一つのComposableに付与するだけで複数パターンのスクリーンショットを生成できます。

@Preview(name = "En", locale = "en")
@Preview(name = "Ja", locale = "ja")
@Preview(name = "Zh", locale = "zh")
@Preview(
    name = "SmallWidth_LargeFont",
    locale = "ja",
    fontScale = 2f,
    widthDp = 320 // 狭い画面を再現
)
annotation class MultiScreenPreviews

ポイント:

  • @MultiScreenPreviews を付与するだけで、定義された4つのパターン(日本語、英語、中国語、幅狭・大フォント)のスクリーンショットが生成されます。
  • 4パターンに絞った理由は、組み合わせを増やしすぎるとテスト実行時間や生成画像数が膨大になるためです。主要な多言語と、レイアウト崩れが最も発生しやすい「画面幅が狭く文字サイズが大きいケース」のみに絞りました。なお、widthDp=320fontScale=2fは実際のPixelデバイス設定を参考に決定しています。

4. CI連携 (GitHub Actions)

CI上でスクリーンショットの「記録」と「比較」を自動化することで、Pull Request作成時に自動で差分検出できるようにしています。Roborazzi作者のtakahiromさんがGithubに公開しているCIサンプルコードを参考に、次のような仕様を満たすワークフローを作成しました。

  1. スクリーンショット保存ワークフロー
    • 目的: 基準となるスクリーンショット(いわゆる「正」の画像)を保存・更新する。
    • 実行タイミング: 開発用ブランチ(ベースブランチ)へのマージ時や、新しく開発用ブランチを作成した時。
    • 主な処理: ./gradlew recordRoborazziDebug を実行し、生成された画像をGitHub Artifactsに保存する。
    • ポイント: ベースブランチごとにArtifact名をユニークにして管理することで、各ブランチのマージ先(ベースブランチ)に応じた「変更前」の基準画像を用意できるようにしています。
  2. スクリーンショット比較ワークフロー
    • 目的: Pull Request上で変更によるUI差分を検知し、レビューを支援する。
    • 実行タイミング: Pull Request作成・更新時。
    • 主な処理: ベースブランチのArtifactsから「変更前」の画像をダウンロードし、./gradlew compareRoborazziDebug で現在の画像と比較。差分が検出された場合は、その差分画像を生成してPull Requestにコメント投稿する。
    • ポイント: 差分レポートでは実際の差分画像をコメント内にインライン表示し、ファイル名やカテゴリ(例: 「Ja」「文字サイズ大」など)で整理しています。これによりレビュアーはどのパターンでどんな変更が生じたか一目で確認できるよう工夫しています。

このように差分がPR上でコメントされます!

差分スクショsample

ワークフローで重要な部分のコードも抜粋してご紹介します。

Roborazziコマンドの実行

Gradleタスクを実行して、スクリーンショットの記録・比較を行います。必要な場合のみ実行されるように制御しています。

# スクリーンショット保存ワークフロー
- name: Record screenshot
  # 比較元のArtifactが存在しない場合や差分画像が存在する場合など特定条件下でのみ実行
  if: steps.check_artifact.outputs.artifact_exists == 'false' # ... 他の条件も含む ...
  run: ./gradlew recordRoborazziDebug --stacktrace --rerun-tasks --quiet

# スクリーンショット比較ワークフロー
- name: Compare screenshot test
  # 比較元となる過去のスクリーンショットArtifactが正常にダウンロードできた場合のみ実行
  if: steps.check-downloaded-files.outputs.previous_screenshot_found == 'true'
  run: ./gradlew compareRoborazziDebug --stacktrace

GitHub Artifactsによる画像の受け渡し

actions/upload-artifactdawidd6/action-download-artifact を利用して、ワークフロー間でスクリーンショット画像を共有しています。

# スクリーンショット保存ワークフロー: 生成した画像をArtifactsへアップロード
- name: Upload screenshots
  uses: actions/upload-artifact@v4
  with:
    # ブランチ名などから動的に決定したユニークなArtifact名
    name: ${{ steps.determine-artifact-name.outputs.artifact_name }}
    path: screenshots_output    # Roborazziが出力した画像フォルダ
    retention-days: 90          # 保存期間(90日)
    overwrite: true             # 同名Artifactがあれば上書き

# スクリーンショット比較ワークフロー: 前回保存した画像をArtifactsからダウンロード
- name: Download previous screenshots
  uses: dawidd6/action-download-artifact@v8
  id: download_artifact
  with:
    # PRのベースブランチに対応するArtifact名を指定
    name: ${{ steps.determine-artifact-name.outputs.artifact_name }}
    workflow: android_store_screenshots.yml   # 画像を保存したワークフロー名(任意指定)
    search_artifacts: true                    # 上記nameに一致するArtifactを検索
    path: screenshots_output                  # ダウンロード先フォルダ

導入効果と振り返り

実際にVRTを導入し、約2ヶ月運用してみた結果、次のような効果が得られました。

  • 意図しないレイアウト崩れへの安心感: VRTが自動でUIの差分を検知してくれるため、「いつの間にか画面表示が崩れていた」という不安から解放されました。
  • レビュー効率の向上: フォントサイズ特大など手動では見落としがちなケースも自動チェックされるため、レイアウト崩れの早期発見につながりました。また、開発者が手動でスクショを撮る手間がなくなり、レビュアーは自動投稿される差分レポートで変更点を一目で把握できます。その結果、レビューの質と速度が向上したと実感しています。
  • チーム内へのスムーズな浸透: Pull Requestを作成すれば自動で実行される仕組みのため、開発者の負担を増やすことなく自然に運用が定着しました。チームメンバーからも「レビューが楽になった」と好評で、抵抗感なく受け入れられています。

一方で、運用する中で今後の課題も見えてきました。

  • テスト実行時間: CI環境でキャッシュが効かない場合、スクリーンショットテストに12〜15分程度かかることがあります。実行時間の短縮は今後の改善ポイントです。
  • XMLレイアウトへの対応: 現在、VRTの恩恵を受けられるのはComposeで実装された画面のみです。 XML画面への対応は今後の課題ですが、Compose化を推進する良い動機付けにもなっています。
  • AndroidView 利用時の注意点とワークアラウンド: Compose内でAndroidView(androidx.appcompat.widget.SearchView をラップしたUIなど)を使用している場合、Robolectric環境下でスクリーンショットが撮れずテストが失敗するケースがありました。この対策として、スクリーンショットテスト実行時(Robolectric環境下)であるかを判定し、問題となるAndroidViewを一時的に簡易なComposeコンポーネントに置き換えることで、テストの安定性を確保しています。
// Robolectric環境下かを判定するフラグ例
private val runOnScreenShotPreview: Boolean by lazy {
    try {
        Class.forName("org.robolectric.Robolectric")
        true
    } catch (err: ClassNotFoundException) {
        false
    }
}

最後に

Roborazziを用いたVRTの導入は、UIの品質担保と開発・レビュー効率の向上に大きく貢献しました。 初期設定こそ必要ですが、一度仕組みを構築すれば、その後の運用コストはほとんどかかりません。 私たちのチームにとっては、非常にコストパフォーマンスの高い取り組みとなりました。

「UIの確認漏れや意図しない変更に悩まされている」「レビューの負担を軽減したい」と考えている開発チームには、VRTの導入を検討してみてはいかがでしょうか。本記事がその一助となれば幸いです。

Sansan技術本部では中途・新卒の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。

© Sansan, Inc.