N予備校 Android アプリでスクリーンショットテストを導入した話

#

[N予備校 Android](https://play.google.com/store/apps/details?id=nico.ed.nnn.zane&hl=ja&gl=US&pli=1) チームでテックリードをしている鎌田です。
[N予備校 Android](https://play.google.com/store/apps/details?id=nico.ed.nnn.zane&hl=ja&gl=US&pli=1) チームでは、スクリーンショットテストを導入しました。
本記事では、どのようにスクリーンショットテストを導入したか、背景と手順をまとめます。
スクリーンショットテストの概要については、他のスクリーンショットテストに関する記事でも説明されているため、[参考 URL](#参考-url) を参照してください。

## 背景

PR を作成する際、UI に変更がある場合は修正前後の画面キャプチャを貼り付けるようにしていました。
ですが、都度画面キャプチャを自前で用意しなければいけないのが面倒なのと、ぱっと見ではわかりづらいデザイン変更の場合だと画面キャプチャからでもわかりづらいことがありました。
そこで、DroidKaigi 2023 アプリのように PR 上で差分のある UI に対してのみスクリーンショットの差分を提示することで、UI に変更がないことを証明でき、かつ画面キャプチャが不要になり手間が減るのではと考えました。
参考：[Adding tests to BookmarkScreenTest by hiesiea · Pull Request #932 · DroidKaigi/conference-app-2023](https://github.com/DroidKaigi/conference-app-2023/pull/932)

## 前提

Jetpack Compose を使用していること。
Jetpack Compose を使用していない UI については今回の方法ではできませんのでご承知おきください。
また、本アプリではマルチモジュールを採用しております。
モジュール構成図は以下の通りです。

![module_architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93963/c2b56536-1b18-04b6-ddd5-4f64befdd0e3.png)

## 環境

[Roborazzi](https://github.com/takahirom/roborazzi)、[Showkase](https://github.com/airbnb/Showkase)、[GitHub Actions](https://github.co.jp/features/actions) を使って構築しました。
Roborazzi を採用した理由としては、類似ライブラリである [Paparazzi](https://github.com/cashapp/paparazzi) と比較して他社での採用事例が多く、スクロールできるなどの独自機能もあるためです。
Showkase を採用した理由としては、Preview 関数で生成した Composable をスクリーンショットテストにそのまま使用でき、コード削減できると考えたためです。
GitHub Actions を採用した理由としては、Roborazzi を実行するのに GitHub Actions の方が[サンプルコード](https://github.com/takahirom/roborazzi-compare-on-github-comment-sample)もあり実装しやすそうだったためです。
また、極力 DroidKaigi 2023 アプリのような形の実行形式を目指していたためです。

## 実装

### ビルド周りの設定

まず、ルートの `build.gradle.kts` に対して以下の設定を追加します。

```kotlin:build.gradle.kts
plugins {
    ...
    id("com.google.devtools.ksp") version "1.8.10-1.0.9" apply false
    id("io.github.takahirom.roborazzi") version "1.10.1" apply false
}
```

次に、`app` モジュールの `build.gradle.kts` に対して以下の設定を追加します。
本アプリでは、上述の通り Showkase を利用してスクリーンショットテストを実装したかったため、`app` モジュールにのみスクリーンショットテストを作成する方針で進めました。

```kotlin:app/build.gradle.kts
plugins {
    ...
    id("io.github.takahirom.roborazzi")
}
...
dependencies {
    ...
    testImplementation("io.github.takahirom.roborazzi:roborazzi:1.10.1")
    testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.10.1")
}
```

次に、Composable を実装している各モジュール（モジュール構成図で言うところの UI モジュール）の `build.gradle.kts` に対して以下の設定を追加します。

```kotlin:build.gradle.kts
plugins {
    ...
    id("com.google.devtools.ksp")
}
...
dependencies {
    ...
    debugImplementation("com.airbnb.android:showkase:1.0.2")
    implementation("com.airbnb.android:showkase-annotation:1.0.2")
    kspDebug("com.airbnb.android:showkase-processor:1.0.2")
}

// private な Preview 関数は無視するようにし、対象を絞れるようにする
ksp {
    arg("skipPrivatePreviews", "true")
}
```

本アプリでは、すぐに見た目で確認できるよう、基本的に実装した Composable に対して必ず Preview 関数を作成しています。
ですが、全ての Preview 関数を対象としようとすると OutOfMemoryError が発生してテストに失敗してしまうので、対象を絞る必要がありました。
そのため、private な Preview 関数は無視するようにし、対象を絞れるようにしました。

```kotlin:Sample.kt
// private が付いていると Showkase 側で無視するようになるのでスクリーンショットテストの対象にならない
@Preview
@Composable
private fun SamplePreview() {
    Sample(
        ...
    )
}
 
// private を外すことで Showkase が認識できるようになるのでスクリーンショットテストの対象になる
@Preview
@Composable
fun SampleForScreenshotTestPreview() {
    Sample(
        ...
    )
}
```

### スクリーンショットテストのコードを追加

まず、Preview 関数で生成した Composable を参照できるよう、`ShowkaseRootModule` インタフェースの実装を `app` モジュールに定義します。

```kotlin:ShowkaseRootModule.kt
@ShowkaseRoot
class ShowkaseRootModule : ShowkaseRootModule
```

次に、スクリーンショットテストを行う `PreviewTest` を実装します。

```kotlin:PreviewTest.kt
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(
    qualifiers = RobolectricDeviceQualifiers.Pixel7Pro,
)
class PreviewTest(
    private val showkaseBrowserComponent: ShowkaseBrowserComponent,
) {
    @Test
    fun previewScreenshot() {
        val filePath =
            DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + "/" + showkaseBrowserComponent.componentKey + ".png"
        captureRoboImage(
            filePath,
        ) {
            showkaseBrowserComponent.component()
        }
    }

    companion object {

        @ParameterizedRobolectricTestRunner.Parameters
        @JvmStatic
        fun components(): Iterable<Array<Any?>> {
            return Showkase.getMetadata().componentList.map { showkaseBrowserComponent ->
                arrayOf(showkaseBrowserComponent)
            }
        }
    }
}
```

できる限り画面サイズの大きいデバイスで確認したいと思い、`@Config` アノテーションでデバイスは Pixel7Pro を指定しています。
`Showkase.getMetadata().componentList` に Showkase で抽出した Preview 関数群の情報が格納されています。
それを `previewScreenshot` の中で `showkaseBrowserComponent` として受け取り、使用しています。

次に、Android Studio 上で `PreviewTest` を実行する場合、`gradle.properties` に以下の設定を追加します。

```properties:gradle.properties
roborazzi.test.record=true
```

### GitHub のワークフローを追加

以下のサンプルコードをほぼ流用した形で実装しました。

- [CompareScreenshot.yml](https://github.com/takahirom/roborazzi-compare-on-github-comment-sample/blob/main/.github/workflows/CompareScreenshot.yml)
  - 比較したスクリーンショットを記録するためのワークフロー
- [CompareScreenshotComment.yml](https://github.com/takahirom/roborazzi-compare-on-github-comment-sample/blob/main/.github/workflows/CompareScreenshotComment.yml)
  - CompareScreenshot.yml の結果を該当 PR にコメントとして出力するためのワークフロー
- [StoreScreenshot.yml](https://github.com/takahirom/roborazzi-compare-on-github-comment-sample/blob/main/.github/workflows/StoreScreenshot.yml)
  - スクリーンショットを記録するためのワークフロー

ただし、上記のサンプルコードは**デフォルトブランチへ向けた PR のみ**を想定しており、それ以外だとうまく動作しないので注意してください。
また、GitHub Actions のリソースは有限であり、プランによっては他リポジトリとリソースを共有しているケースもあります。
その場合、スクリーンショットテストでリソースを食い潰さないよう、他チームとの調整が必要です。

## 実装結果

実行結果は以下の通りです。
例えばパディングを調整したときに、以下のように赤く差分が表示されるようになりました。

![result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93963/8727a24b-6477-7466-95d3-7b801ad59103.png)

また、Compose 関連のライブラリがアップデートされたときなど、直接 UI の変更を行なっていない場合にも変更があったとわかるようになりました。

## まとめ

本記事では、[N予備校 Android](https://play.google.com/store/apps/details?id=nico.ed.nnn.zane&hl=ja&gl=US&pli=1) チームでスクリーンショットテストを導入したので、背景と手順をまとめました。
スクリーンショットテストを導入したことで、PR 上で差分のある UI に対してのみスクリーンショットの差分が提示されるようになり、UI に変更があったかどうかがわかるようになりました。
特に、Compose 関連のライブラリがアップデートされたときなど、直接 UI の変更を行なっていない場合にも変更があったとわかるようになったのが大きかったです。
まだ一部の画面しか対象にしていないのと、画面キャプチャを用意する・しないについての基準が決まっていないなどあるため、そこは今後検討していきたいです。

## 参考 URL

- [AndroidのスクリーンショットテストでのRoborazziの導入について](https://zenn.dev/aldagram_tech/articles/d6d7e272628b9b)
- [スタディサプリ小学・中学講座でRoborazziを導入しました - スタディサプリ Product Team Blog](https://blog.studysapuri.jp/entry/2023/10/05/introduce-roborazzi)
- [Roborazziでスクリーンショットテストを導入した話 - Galapagos Tech Blog](https://techblog.glpgs.com/entry/2023/10/27/123422)