👬

AndroidのスクリーンショットテストでのRoborazziの導入について

こんにちは!アルダグラム アプリチームです。

先日アルダグラムのAndroidアプリでスクリーンショットテストを導入したので、導入の経緯や方法について紹介したいと思います。

スクリーンショットテストとは

テスト中に画面のキャプチャを取得し、それを事前に取得しておいた期待する画面のキャプチャと比較することで検証を行うテスト手法になります。

例えば、以下のような Composable のテストの UI テストを行いたい場合、

@Composable
fun HelloWorld() {
    Column {
        Text(text = "Hello world!")
    }
}

既存のComposeの UI テストでは以下のように文字列が表示されていることなどを assert で検証することが一般的です。

@Test
fun testHelloWorld() {
    composeTestRule.setContent {
        MyAppTheme {
            HelloWorld()
        }
    }
    composeTestRule.onNodeWithText("Hello World!").assertIsDisplayed()
}

ただ、この方法だと以下のような改善点がありました。

  1. Instrumented test (実機やエミュレータ上で実行するテスト)が必要になるので、CIで繰り返し実行するときに端末を準備する手間がかかる。また、エミュレータのセットアップの失敗が発生し、テストが不安定な場合がある
  2. 特定の部分を assert で検証しているので、それと違う箇所の変更が検出できない。例えば、意図しない padding が追加されていた場合にもテストが通ってしまう

上記の問題を解決するためにスクリーンショットテストの導入を検討していました。Google がオープンソースとして開発している Now in Android のアプリでも既存の Compose の UI テストをスクリーンショットテストとして置き換える案が出ているようです。(https://github.com/android/nowinandroid/pull/919)

スクリーンショットテストのライブラリの選定

結論としては、アルダグラムでは @takahirom さんが開発している Roborazzi を導入することにしました。

スクリーンショットテスト用のライブラリを大きく分けると以下の2つに分類できます。

  1. Instrumented test としてエミュレータ上などで実行するライブラリ

    1. Shot
    2. dropshots

    エミュレータや実機が必要なライブラリでは、より実機に近い描画ができるという利点がありつつも(例えばここで紹介されているように、後述するPaparazziでは Elevation や Animation 後の描画がうまく描画されていない)CI上のエミュレータのセットアップが不安定になる場合があるという、欠点もあります。

  2. JVM 上で実行するライブラリ

    1. Paparazzi
      1. Android Studio で xmlのレイアウトや Compose のプレビューを描画するのに使用する Layoutlib を使用して描画している。
    2. Roborazzi
      1. Robolectric Native Graphics を使用して描画している。
      2. Hilt やコンポーネントに対してのスクロール、クリックなどの動作もサポートされている

    Paparazzi では Layoutlib を本来のAndroid Studio上でのプレビューを作成する以外の用途で使用しているのに対し、Roborazzi では Robolectric Native Graphics をそもそもサポートされている方法で使用しています。また、Roborazzi の GitHub リポジトリ でも言及されているように Hilt を使用した依存の注入や、コンポーネントに対してのスクロールやクリックなどの動作も Roborazzi の利点になります。

    後発のライブラリになるので、GitHub のスター数は Paparazzi の方が多いものの、上記のような利点からアルダグラムでは Roborazzi を使用することを決定しました。

スクリーンショットテストの導入

Roborazziのページでも紹介されていますが、ここでは実際にアルダグラムでの導入方法を紹介します。

依存の追加

dependencies {
    ...
    testImplementation "androidx.compose.ui:ui-test-junit4:${rootProject.ext.composeVersion}"
    testImplementation "org.robolectric:robolectric:${rootProject.ext.robolectricVersion}"
    testImplementation "io.github.takahirom.roborazzi:roborazzi:${rootProject.ext.roborazziVersion}"
    ...
}

アルダグラムでは Compose の画面に対して導入しているため、Compose のテスト用依存を追加しています。

Roborazzi Gradle Plugin の追加

root の build.gradle

buildscript {
  dependencies {
    ...
    classpath "io.github.takahirom.roborazzi:roborazzi-gradle-plugin:[version]"
    ...
  }
}

module の build.gradle

plugins {
    ...
    id("io.github.takahirom.roborazzi")
    ...
}

この Gradle plugin を追加することで、以下のようなRoborazzi用の gradle taskが追加されます。

  • ./gradlew recordRoborazziDebug
    • スクリーンショットを保存して、 build/outputs/roborazzi 以下のディレクトリに保存
  • ./gradlew compareRoborazziDebug
    • build/outputs/roborazzi 以下のディレクトリに保存されているスクリーンショットをと、テストから生成されるスクリーンショットを比較する。差分があった場合は ***_compare.png の名前で差分の画像を生成
  • ./gradlew verifyRoborazziDebug
    • 保存されているスクリーンショットと、テストから生成されるスクリーンショットを比較して、差分があった場合はテスト失敗とする
  • ./gradlew verifyAndRecordRoborazziDebug
    • 保存されているスクリーンショットをと、テストから生成されるスクリーンショットを比較する。差分があった場合は生成されたスクリーンショットを新しい比較元のスクリーンショットとする

gradle.properties

roborazzi.test.record=true

この行を追加しておくことで、./gradlew testDebugUnitTest のようにユニットテストの実行時にもスクリーンショット保存を行うようになります。(./gradlew recordRoborazziDebug のように専用の gradle task を実行しなくてもよくなる)

スクリーンショット保存のテストを追加

@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel7Pro)
class ComposeScreenShotTests {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()
    private val roborazziOptions = RoborazziOptions(
        compareOptions = RoborazziOptions.CompareOptions(changeThreshold = 0F),
    )

    ...

    @Test
    fun checkSettingsScreen() {
        checkComposeScreen(
            screenName = "SettingsScreen",
            content = {
                SettingsScreen(
                    uiState = SettingsUiState(
                        isJpUser = true,
                        isBlackboardMenuVisible = true,
                        isEditPasswordMenuVisible = true,
                        appVersion = "1.0.0"
                    ),
                    onMenuItemClicked = {})
            }
        )
    }

    ...

    private fun checkComposeScreen(
        screenName: String,
        content: @Composable () -> Unit
    ) {
        composeTestRule.setContent {
            KannaTheme {
                Surface {
                    content()
                }
            }
        }

        composeTestRule.onRoot().captureRoboImage(
            filePath = "$DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH/$screenName.png",
            roborazziOptions = roborazziOptions
        )
}

スクリーンショットの保存は captureRoboImage() を呼び出すことで行えます。Theme の適用などの共通の処理を checkComposeScreen という名前で切り出し、Composeの画面毎に checkSettingsScreen などのテストから呼び出すようにしています。

ここまで行うことで、 スクリーンショットの保存が行えるようになりました。例えば ./gradlew testDebugUnitTest を実行することで、 build/outputs/roborazzi/ のディレクトリに SettingsScreen.png のファイルが出力されています。

その後に ./gradlew compareRoborazziDebug を実行すると、スクリーンショットに何も差分がないので、 **_compare.png の画像は生成されません。

試しに SettingsScreen の Composable 関数にわざと padding を追加してから ./gradlew compareRoborazziDebug を実行してみます。

@Composable
fun SettingsScreen(uiState: SettingsUiState, onMenuItemClicked: (String) -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .padding(start = 4.dp) // この padding をわざと追加
    ) {
     ...
    }
)

今度は build/outputs/roborazzi/SettingsScreen_compare.png として以下のような画像が出力されていたので、期待通り画面の変更があった場合に差分が出力されていることが確認できます。

あとはこれを CI 上に組み込んでプルリクエスト毎に期待した変更がなされているか確認します。

GitHub Actions のワークフローに組み込む

これでスクリーンショットを保存する準備ができたので、後はこれを CI に組み込んでいきます。

ここで比較元の画像 (例: build/outputs/roborazzi/SettingsScreen.png) を保存する場所を考える必要が出てきます。

他のプロジェクトの導入例を調べた限りだと以下のどちらかの方法で解決しているようです。

  1. Now in Androidのようにリポジトリ内に画像をコミットする方法
    1. 利点 : リポジトリの一部になるので、保存期間の制限がない
    2. 欠点 : リポジトリのサイズが増えてしまうので、チェックアウト時に時間がかかる
  2. DroidKaigi 2023 アプリのように GitHub Actionsのアーティファクトとしてアップロードする方法
    1. 利点 : リポジトリに画像が含まれないので、チェックアウト時のサイズが増えない
    2. 欠点 : アーティファクトの保存期間が最大90日なので、古いプルリクエストの差分は確認できない

スクリーンショット差分は主に開発中に検証するものなので、アルダグラムでは2の方法をとることにしました。

比較元画像の保存

テスト中に生成されたスクリーンショットの画像を次回のスクリーンショットテストの比較元とするために、ユニットテスト用のワークフローに以下の処理を追加しています。

.github/workflows/unit-test-android.yml

on:
  push:

jobs:
  unit_test_android:
    steps:

...
    - uses: actions/upload-artifact@v3
      if: ${{ always() }}
      with:
        name: golden-screenshot
        path: |
          **/build/outputs/roborazzi
        retention-days: 14
...

差分があった場合にプルリクエストにコメントする

Roborazziのリポジトリで言及されているように、画像に差分があった場合はプルリクエストでコメントしてくれると、素早く差分に気づくことができます。

そのようなワークフローを実現するために takahirom さんが作成してくれたサンプルプロジェクトを参考にして、以下のようにプルリクエストにコメントしてくれるワークフローも作成しています。(重要な部分だけ抜き出しているので、全行はサンプルプロジェクトを参照してください)

.github/workflows/screenshot-test-android.yml
on:
  pull_request:

jobs:
  comment_screenshot_comparison:
    ...
    permissions:
      actions: read # for downloading artifacts
      contents: write # for pushing screenshot-diff to companion branch
      pull-requests: write # for creating a comment on pull requests
    steps:
      ...

      ## ここでユニットテストのワークフローのアーティファクトから比較元画像をダウンロードしている
      - uses: dawidd6/action-download-artifact@v2
        continue-on-error: true
        with:
          name: golden-screenshot
          workflow: unit-test-android.yml
          branch: ${{ github.event.pull_request.base.ref }}
      - name: Run compare screenshot
        run: |
          ./gradlew compareRoborazziDevDebug
      ## 差分がある画像があったかチェックする
      - id: check-if-there-are-valid-files
        name: Check if there are valid files
     ...
      ## 差分があった場合は companionブランチを作成し、**_compare.png の画像をpushする準備をする
      - id: switch-companion-branch
        if: steps.check-if-there-are-valid-files.outputs.exist_valid_files == 'true'
        env:
          BRANCH_NAME: companion_${{ github.head_ref }}
        run: |
          ...
      ## companion ブランチに **_compare.png の画像をpushする
      - id: push-screenshot-diff
        if: steps.check-if-there-are-valid-files.outputs.exist_valid_files == 'true'
        run: |
          ...

      ## 差分があった画像について、プルリクエストのコメントにレポートを表示
      - id: generate-diff-reports
        name: Generate diff reports
        if: steps.check-if-there-are-valid-files.outputs.exist_valid_files == 'true'
        env:
          BRANCH_NAME: companion_${{ github.head_ref }}
          ...
        run: |
          ...
      - name: Find Comment
        uses: peter-evans/find-comment@v2
        id: fc
        if: steps.generate-diff-reports.outputs.reports != ''
        with:
          issue-number: ${{ github.event.number }}
          comment-author: 'github-actions[bot]'
          body-includes: 'data-meta="snapshot-diff-report"'

      - name: Add or update comment on PR
        uses: peter-evans/create-or-update-comment@v3
        if: steps.generate-diff-reports.outputs.reports != ''
        with:
          comment-id: ${{ steps.fc.outputs.comment-id }}
          issue-number: ${{ github.event.number }}
          body: ${{ steps.generate-diff-reports.outputs.reports }}
          edit-mode: replace

      - name: Cleanup outdated companion branches
        ...
        ## 古い companion_** branchの削除
        ...

やりたいこととしては、ここで説明されているようにプルリクエストのブランチに対応するブランチ(companion branch)を作成し、その companion branch に 比較後の差分画像 (***_compare.png) をpushします。その companion branch の差分画像をもとにプルリクエストのコメントとして、差分があったことを出力します。

古くなった companion branch は一定期間後 (例えば一ヶ月後) に同じワークフロー上で削除しています。こうすることで companion branch の数が膨れ上がってしまうことも防げます。

ワークフローが動いていることのテスト

ここまでできれば後はスクリーンショットテストがちゃんと動いていることをプルリクエストを作成して確かめてみます。

スクリーンショットテスト導入時のプルリクエスト

スクリーンショット導入時は比較元の画像がまだ GitHub Actionsのアーティファクトとしてアップロードされていないので、スクリーンショットテスト対象とした画面がすべて差分として表示されています。

プルリクエストのコメントでは以下のようにコメントされていることが確認できました。

導入以降のプルリクエスト

一度導入したら、それ以降のプルリクエストでは変更された部分のみが差分として出力されるはずです。

試しに SettingsScreen の画面を以下のようにわざと変更して、プルリクエストを送ってみます。

@Composable
fun SettingsScreen(uiState: SettingsUiState, onMenuItemClicked: (String) -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
+            .padding(start = 4.dp) // わざと padding を追加
    ) {
    ...

コメントでも、追加した padding の分の差分が出力されていることが確認できました!

後は新規に追加した画面についても、スクリーンショット対象とするテストを増やしていけば良さそうです。

まとめ

このブログではアルダグラムでスクリーンショットテストを導入するにいたった経緯や、ライブラリの選定基準、導入方法について紹介しました。Android の UI のリグレッションに悩んでいる方は一度導入を検討してみてはいかがでしょうか。

もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion