【Android】 CircleCI + Roborazzi + Showkase で VRT 試しました

### 2024/4/1更新
新しくこちらの記事を改善した記事を投稿してます

https://qiita.com/KSND/items/17babf5a58cb0f71a46e


***


VRT (Visual Regression Testing) = 画像回帰テスト

技術記事初投稿になります。
２０２３年３月からAndroidアプリエンジニアとなったKSNDといいます。
いつもは [X(Twitter)](https://twitter.com/ksnd_dev) で技術的なことを呟いたりしていますが、今回はかなり量があったのと技術記事書くのを2024年の目標の１つにしていたので書きました。
ご指摘や、改善点など教えていただけると幸いです。

|項目|
|---|
|[今回VRTを試した理由](#今回vrtを試した理由)|
|[完成品](#完成品)|
|[実際に試した際の比較画像](#実際に試した際の比較画像)|
|[使用したライブラリやサービス](#使用したライブラリやサービス)|
|[VRTの大まかな流れ](#vrtの大まかな流れ)|
|[Roborazzi導入](#roborazzi導入)|
|[Amazon　S3に画像を保存するときのパス](#amazon-s3に画像を保存するときのパス)|
|[Circle CI の Orbs、ジョブ、コマンド とその説明](#circle-ci-の-orbsジョブコマンド-とその説明)|
|[Showkaseの設定](#showkaseの設定)|
|[詰まったところ、大変だったところ](#詰まったところ大変だったところ)|
|[参考資料](#参考資料)|


## 今回VRTを試した理由
[DroidKaigi 2023 公式アプリ](https://github.com/DroidKaigi/conference-app-2023) のPRでベースブランチからスクリーンショットの差分が発生すると比較画像が表示されるのがすごく有用にかんじ個人開発、仕事でも使用したくてそのために試しました。

https://github.com/DroidKaigi/conference-app-2023

## 完成品
### レボジトリ
https://github.com/kosenda/hiragana-converter

### config.yml　（CircleCI 設定ファイル）
https://github.com/kosenda/hiragana-converter/blob/test/sample-vrt/.circleci/config.yml


## 実際に試した際の比較画像
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587421/9784466f-55d3-727b-15d7-bb0383e0178b.png)



## 使用したライブラリやサービス
### Circle CI
https://circleci.com/docs/ja/about-circleci/
- 個人的にCircle CIをよく触っているため今回使用しました

### Roborazzi
https://github.com/takahirom/roborazzi
- Android端末を使わずに、JVM上でスクリーンショットを撮影することができるライブラリ
- UnitTestで行えるためかなり早いです

### Showkase
https://github.com/airbnb/Showkase
- PreviewなどのアノテーションつけたComposableからUIコンポーネントカタログを作成できるライブラリ
- PreviewアノテーションがついているComposableをそのまま使用できるため負担はかなり少なかったです

### GitHub Apps
https://docs.github.com/ja/apps
- GitHub の機能を拡張するツール
- GitHub で、issue を開く、PRにコメントする、プロジェクトを管理する、といったことができます
- GitHubのアカウントはいくつも作れないため使用しました

### GitHub CLI
https://docs.github.com/ja/github-cli/github-cli/about-github-cli
- コマンド ラインから GitHub を使用するためのオープンソースツール
- PRにコメント投稿するのとベースブランチ名を取得するために使用しました
- 今回は[Orbs(circleci/github-cli)](https://circleci.com/developer/ja/orbs/orb/circleci/github-cli)を使用しました

### Amazon S3
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/Welcome.html
- オブジェクトストレージサービス
- S3は、Simple Storage Service
- スクリーンショットや比較画像の保存をするために使用しました
- ライフサイクルが設定でき、とりあえず30日間過ぎたら自動削除するようにしました
- 今回AWSの新規登録からしました（セキュリティの設定とかが沢山あったりして怖かったです）

### Amazon CLI
https://aws.amazon.com/jp/cli/
- コマンドラインから AWS の複数のサービスを制御できるツール
- 今回は[Orbs(circleci/aws-cli)](https://circleci.com/developer/ja/orbs/orb/circleci/aws-cli)を使用しました
- AWS S3を使うときなどに便利なコマンドを用意してくれてます


## VRTの大まかな流れ

`比較用のコメント表示`　と　`比較用のスクリーンショット保存`で処理が分かれます。
PR時の処理は`比較用のコメント表示`で、マージ時の処理は`比較用のスクリーンショット保存`を行います。

#### `比較用のコメント表示`　
1. GitHub Appsからアクセストークン取得
2. アクセストークンを使用してGitHubにログイン
3. GitHub CLIからベースブランチ名取得
4. Amazon S3からベースブランチのスクリーンショット取得
5. Roborazziで取得したスクリーンショットと現在のスクリーンショットを比較し、差分があれば比較画像を作成
6. 比較画像をAmazon S3にあげる
7. PRに出すコメント作成
8. GitHub CLIでコメントをPRに投稿(同じPRにすでにコメントしてある場合は上書き)

#### `比較用のスクリーンショット保存`
1. Roborazziでスクリーンショット作成
2. Amazon S3にスクリーンショット保存


## Roborazzi導入
以下の`README`にある`Build setup`の箇所に記載されてる通りに依存関係(`plugins`, `dependencies`)を追加しました

https://github.com/takahirom/roborazzi?tab=readme-ov-file#build-setup


## Amazon S3に画像を保存するときのパス
PRのスクショ比較画像保存
`<パケット名>/image/pr/<PRの番号>/〜_compare.png`

スクリーンショット保存
`<パケット名>/image/<ブランチ名>/〜.png`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/587421/38e4fb29-a655-5758-bc01-2fa3ba49daa6.png)


## Circle CI の Orbs、ジョブ、コマンド とその説明

※　不要そうな箇所を一部除いています

### 使用したOrbs

Orbsかなり便利でした。

```yaml:config.yml
orbs:
  gh: circleci/github-cli@2.3.0
  aws-cli: circleci/aws-cli@4.1.2
  aws-s3: circleci/aws-s3@4.0.0
```

### スクリーンショットを作成して保存するジョブ
- `./gradlew recordRoborazziProdDebug`でスクリーンショットを保存しています
- `aws-cli-setup`はOrbsの`aws-cli/setup`コマンドを読んでいるだけです（Amazon CLIのセットアップを行なっています）
- `aws-s3/sync`は、OrbsのコマンドでAmazon S3にデータを同期しながら保存してます
```yaml:config.yml
  save-screenshots:
    executor: android
    steps:
      - checkout
      - run:
          name: create screenshots
          command: ./gradlew recordRoborazziProdDebug --stacktrace
      - aws-cli-setup
      - aws-s3/sync:
          arguments: --delete
          from: ./app/build/outputs/roborazzi
          to: s3://hiraganaconverter-roborazzi/image/$CIRCLE_BRANCH
```

### 比較画像を作成してPRに表示するジョブ

- `gh/install`はOrbsのコマンドで、GitHub CLIをインストールしています
- set-github-access-tokenは後述の [GitHub Appsからトークンを取得するコマンド](#github-appsからトークンを取得するコマンド) で説明しています
- `aws-s3/copy`の`arguments: --recursive`は、これを設定することでフォルダ内にあるファイルを取得してくれます(コピーすることでベースブランチと現在のスクリーンショットの差分が取得できるようになります)
- `./gradlew compareRoborazziProdDebug --stacktrace`で`〜_compare.png`が作成されます
- `gh pr comment "$CIRCLE_PULL_REQUEST" --edit-last -F ./comments` の `--edit-last`は、最後に表示されているログインしているユーザーのコメントを編集するために必要なものになります
- Amazon S3の画像を表示するのに設定がちょっと必要です（設定しないと権限がないと表示されます）
- 他の箇所は説明省略します 🙇

```yaml:config.yml
  compare-screenshots:
    executor: android
    steps:
      - checkout
      - gh/install
      - set-github-access-token
      - run:
          name: gh login
          command: echo "$GITHUB_ACCESS_TOKEN" | gh auth login --with-token
      - run:
          name: get base branch name
          # ref: https://discuss.circleci.com/t/how-to-retrieve-a-pull-requests-base-branch-name-github/36911
          command: |
            # ◼ get base branch name
            pr=$(echo https://api.github.com/repos/${CIRCLE_PULL_REQUEST:19} | sed "s/\/pull\//\/pulls\//")
            base=$(curl -s -H "Authorization: token ${GITHUB_ACCESS_TOKEN}" $pr | jq '.base.ref')
            echo "base branch name: $base"
            echo "export BASE_BRANCH_NAME=${base}" >> $BASH_ENV
      - aws-cli-setup
      - aws-s3/copy:
          arguments: --recursive
          from: "s3://hiraganaconverter-roborazzi/image/$BASE_BRANCH_NAME"
          to: ./app/build/outputs/roborazzi
      - run:
          name: compare screenshots
          command: ./gradlew compareRoborazziProdDebug --stacktrace
      - run:
          name: move screenshots files
          command: |
            mkdir -p ./build/outputs/roborazzi/compareProdDebug
            mv ./app/build/outputs/roborazzi ./build/outputs/roborazzi/compareProdDebug
            # delete except *_compare.png
            find ./build/outputs/roborazzi/compareProdDebug/roborazzi  -type f |  grep -v -e '.*_compare.png' | xargs rm -rf
            ls ./build/outputs/roborazzi/compareProdDebug/roborazzi
      - run:
          name: set pr number
          command: |
            pr_number=$(basename $CIRCLE_PULL_REQUEST)
            echo "export PR_NUMBER=${pr_number}" >> $BASH_ENV
      - aws-s3/sync:
          arguments: --delete
          from: ./build/outputs/roborazzi/compareProdDebug/roborazzi
          to: s3://hiraganaconverter-roborazzi/image/pr/$PR_NUMBER
      - run:
          name: create comments
          command: |
            cat \<< EOF > comments
            |File name|Image|
            |---|---|
            EOF
            
            # Find all the files ending with _compare.png
            files_to_add=$(find . -type f -path "./build/outputs/roborazzi/compareProdDebug/roborazzi/*" -name "*_compare.png")
            
            # Add compare image
            for file in $files_to_add; do
              compare_file_path=$(basename $file)
              echo "compare file path: $compare_file_path"
              compare_file_name=$(echo $compare_file_path | rev | cut -d. -f-2 | rev )
              image_url="https://hiraganaconverter-roborazzi.s3.ap-northeast-1.amazonaws.com/image/pr/$PR_NUMBER/$compare_file_path"
              echo "| $compare_file_name | <img src=\"$image_url\" width=\"600\"> |" >> comments
            done
      - run:
          # ref: https://github.com/cli/cli/issues/6790
          name: test comment
          command: gh pr comment "$CIRCLE_PULL_REQUEST" --edit-last -F ./comments || gh pr comment "$CIRCLE_PULL_REQUEST" -F ./comments

```

### GitHub Appsからトークンを取得するコマンド

今回JWTという言葉を初めて知ったくらいには知識がありませんでしたが、
こちらの記事 -> [Zenn - シェルスクリプトで GitHub App のインストールアクセストークンを取得する](https://zenn.dev/gorohash/articles/e2c5f6ce50f4e6) がとても参考になりトークン取得できることができました。

- 上から順に `JWT(JSON Web Token)作成`, `installation ID取得`, `GitHub アクセストークン取得`, `環境変数に設定` を行なっています

```yaml:config.yml
  set-github-access-token:
    steps:
      - run:
          name: set github access token
          # Create GitHub App Token using JWT and pass it with gh auth to login
          command: |
            # create JWT
            # header
            header=$(echo -n '{"alg":"RS256","typ":"JWT"}' | base64 -w 0)
            # payload
            # Current Unix time(Epoch)
            now=$(date "+%s")
            # Date and time token was issued (issued at) (Set 1 minute before the current time to account for the possibility of the clock being off)
            iat=$((${now} - 60))
            # Date and time when token expires (Expiration time) (10 minutes later)
            exp=$((${now} + (10 * 60)))
            # iss: GitHub App ID (issuer)
            iss="$APP_ID"
            payload=$(echo -n "{\"iat\":${iat},\"exp\":${exp},\"iss\":${iss}}" | base64 -w 0)
            # signature
            echo $COMMENT_GITHUB_API_TOKEN | base64 --decode > ./githubApps
            signature=$(echo -n $(echo -n "${header}.${payload}" | openssl dgst -binary -sha256 -sign "./githubApps" | base64))
            # jwt
            jwt="${header}.${payload}.${signature}"
            
            # Generating an installation access token for a GitHub App
            user_name="kosenda"
            installation_id=$(
              curl -s -G \
                -H "Authorization: Bearer ${jwt}" \
                -H "Accept: application/vnd.github+json" \
                "https://api.github.com/app/installations" \
                | jq -r ".[] | select(.account.login == \"${user_name}\" and .account.type == \"User\") | .id"
            )
            
            # Get an installation access token
            access_token=$(
              echo $(
                curl -s -X POST \
                -H "Authorization: Bearer ${jwt}" \
                -H "Accept: application/vnd.github+json" \
                "https://api.github.com/app/installations/${installation_id}/access_tokens" \
                | jq -r ".token"
              )
            )
            
            # set github access token
            echo "export GITHUB_ACCESS_TOKEN='$access_token'" >> $BASH_ENV
```

参考: https://docs.github.com/ja/rest/using-the-rest-api/media-types?apiVersion=2022-11-28
メディアの種類
> 注: 以前は v3 を Accept ヘッダーに含めることを推奨しておりました。 それは不要になりました。API 要求には影響しません。


## Showkaseの設定
- appモジュールと、Previewアノテーションを設定したComposableがあるモジュール全てに、以下の依存関係を追加します(Kspも依存必要です(Kaptでもできます))
```kotlin:build.gradle.kts
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")
```
- appモジュールにShowkaseのルートモジュールを実装したクラスを配置します(例: `app/src/main/java/ksnd/hiraganaconverter/ShowkaseRootModule.kt`)
```kotlin:ShowkaseRootModule.kt
import com.airbnb.android.showkase.annotation.ShowkaseRoot
import com.airbnb.android.showkase.annotation.ShowkaseRootModule

@ShowkaseRoot
class ShowkaseRootModule : ShowkaseRootModule
```
- Preview関数をprivateにしている場合はprivateを削除する
- Previewのテストを追加します
    - [DroidKaigi conference-app-2023 - Introduce Preview screenshot testing with Showkase and Roborazzi](https://github.com/DroidKaigi/conference-app-2023/pull/217)を参考にしました

```kotlin:PreviewTest.kt
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.Pixel6)
class PreviewTest(
    private val showkaseBrowserComponent: ShowkaseBrowserComponent,
) {

    @Test
    fun previewScreenshot() {
        val componentName = showkaseBrowserComponent.componentName.replace(" ", "")
        val filePath =
            DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH + "/" + showkaseBrowserComponent.group + "_" + componentName + ".png"
        captureRoboImage(filePath) {
            showkaseBrowserComponent.component()
        }
    }

    companion object {
        @ParameterizedRobolectricTestRunner.Parameters
        @JvmStatic
        fun components(): Iterable<Array<Any?>> {
            return Showkase.getMetadata().componentList.map { showkaseBrowserComponent ->
                arrayOf(showkaseBrowserComponent)
            }
        }
    }
}
```
::: note warn  
`Showkase.getMetadata()`は最初に赤く（Not Found状態）なります（ビルドすれば解消されます）
:::

::: note warn  
Previewで`LottieAnimation`が表示されているとRoborazziの処理が終わりませんでした
:::

::: note info  
PreviewでuiModeの設定をしていて、ダークモード・ライトモードの切り替えを表示したい場合は [こちら（DroidKaigi 2023の公式アプリでのPR)](https://github.com/DroidKaigi/conference-app-2023/pull/649)　がかなり参考になります
:::

## 詰まったところ、大変だったところ
- Roborazzi + CircleCIの実装例が調べた限り無かったため暗中模索の状態でした（できるかどうかすらわかりませんでした）
- PRに差分表示のコメントを表示するのに一番時間がかかって大変でした
    - GitHub AppsからGitHubのアクセストークン取得する箇所が難しいです(JWTとか知らない知識がかなり必要でした)
- AWS新規登録してからAWS S3に画像保存してから公開するまでにする処理が多いのとセキュリティがわからなくて怖くて大変でした
- Shellコマンドの知識があまりなくて、簡単な操作に苦労しました
- CircleCIの知識がそんなになく調査するのにすごく時間がかかって大変でした


# 参考資料
- [Roborazzi: Elevating Android Visual Testing to the Next Level](https://medium.com/@takahirom/roborazzi-elevating-android-visual-testing-to-the-next-level-46ec56b24055)
    - Roborazziがどのようなライブラリか、どのようにしてVRTをするかがわかる
- [GitHub docs - GitHub App インストールとしての認証](https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation)
    - 正直なところ分かりづらいけど役に立った
- [Zenn - JWT の仕組み](https://zenn.dev/mikakane/articles/tutorial_for_jwt)
    - JWTとは何かがそこそこ分かりやすく書かれている
- [Note - CircleCI から GitHub Apps 経由で Pull Request にコメントを投げる](https://note.com/weekly_report/n/n46f398a4a0f4#de8d2d77-3c08-4692-b2bb-8457450599de)
    - CircleCIとGitHub Appsの記事だから参考になった
- [takahirom roborazzi-compare-on-github-comment-sample](https://github.com/takahirom/roborazzi-compare-on-github-comment-sample/tree/main)
    - RoborazziとGitHub Actionsを使用したVRTのサンプル、GitHub Actionsだけどかなり参考になる
- [スタディサプリ小学・中学講座でRoborazziを導入しました](https://blog.studysapuri.jp/entry/2023/10/05/introduce-roborazzi)
    - なんのために何を導入したかがわかりやすい
- [S3にアップロードしたファイルを自動的に削除する](https://blog.apiless.com/2023/01/16/s3-lifecycle/)
    - S3のライフサイクルの設定で参考になった
- [airbnb Showkase - Support for AlertDialog previews](https://github.com/airbnb/Showkase/issues/235)
    - AlertDialogはShowkaseで表示できない
- [introduce Preview screenshot testing with Showkase and Roborazzi](https://github.com/morux2/RoborazziPractice/pull/3)
    - Showkase導入のサンプル、参考になった
- [How to Retrieve a Pull Request’s Base Branch Name [GitHub]](https://discuss.circleci.com/t/how-to-retrieve-a-pull-requests-base-branch-name-github/36911)
    - CircleCiでベースブランチ名を取得する方法が書いてあって参考にした
- [Add --edit-last-or-create option or enhance the functionality of --edit-last for PR comments](https://github.com/cli/cli/issues/6790)
    - --edit-lastで、最後に投稿された特定のユーザーのコメントを上書きできるが、初めて投稿する場合にコマンドが失敗してしまうため追加機能のリクエストしているissue（失敗しないようにする方法も書いてある）
- [Support for AlertDialog previews](https://github.com/airbnb/Showkase/issues/235)
    - ShowkaseではAlertDialogが対応していない
- [s3 sync コマンドを使ってS3バケット間を同期する](https://qiita.com/kanadeee/items/122acbf6bc069c36b4da)
    - `--delete`という引数を初めて知った(便利そう)