14
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

記事投稿キャンペーン 「2024年!初アウトプットをしよう」

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

Last updated at Posted at 2024-01-03

2024/4/1更新

新しくこちらの記事を改善した記事を投稿してます


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

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

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

今回VRTを試した理由

DroidKaigi 2023 公式アプリ のPRでベースブランチからスクリーンショットの差分が発生すると比較画像が表示されるのがすごく有用にかんじ個人開発、仕事でも使用したくてそのために試しました。

完成品

レボジトリ

config.yml (CircleCI 設定ファイル)

実際に試した際の比較画像

image.png

使用したライブラリやサービス

Circle CI

  • 個人的にCircle CIをよく触っているため今回使用しました

Roborazzi

  • Android端末を使わずに、JVM上でスクリーンショットを撮影することができるライブラリ
  • UnitTestで行えるためかなり早いです

Showkase

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

GitHub Apps

  • GitHub の機能を拡張するツール
  • GitHub で、issue を開く、PRにコメントする、プロジェクトを管理する、といったことができます
  • GitHubのアカウントはいくつも作れないため使用しました

GitHub CLI

  • コマンド ラインから GitHub を使用するためのオープンソースツール
  • PRにコメント投稿するのとベースブランチ名を取得するために使用しました
  • 今回はOrbs(circleci/github-cli)を使用しました

Amazon S3

  • オブジェクトストレージサービス
  • S3は、Simple Storage Service
  • スクリーンショットや比較画像の保存をするために使用しました
  • ライフサイクルが設定でき、とりあえず30日間過ぎたら自動削除するようにしました
  • 今回AWSの新規登録からしました(セキュリティの設定とかが沢山あったりして怖かったです)

Amazon CLI

  • コマンドラインから AWS の複数のサービスを制御できるツール
  • 今回はOrbs(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)を追加しました

Amazon S3に画像を保存するときのパス

PRのスクショ比較画像保存
<パケット名>/image/pr/<PRの番号>/〜_compare.png

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

image.png

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

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

使用したOrbs

Orbsかなり便利でした。

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にデータを同期しながら保存してます
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からトークンを取得するコマンド で説明しています
  • aws-s3/copyarguments: --recursiveは、これを設定することでフォルダ内にあるファイルを取得してくれます(コピーすることでベースブランチと現在のスクリーンショットの差分が取得できるようになります)
  • ./gradlew compareRoborazziProdDebug --stacktrace〜_compare.pngが作成されます
  • gh pr comment "$CIRCLE_PULL_REQUEST" --edit-last -F ./comments--edit-lastは、最後に表示されているログインしているユーザーのコメントを編集するために必要なものになります
  • Amazon S3の画像を表示するのに設定がちょっと必要です(設定しないと権限がないと表示されます)
  • 他の箇所は説明省略します 🙇
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 のインストールアクセストークンを取得する がとても参考になりトークン取得できることができました。

  • 上から順に JWT(JSON Web Token)作成, installation ID取得, GitHub アクセストークン取得, 環境変数に設定 を行なっています
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でもできます))
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)
ShowkaseRootModule.kt
import com.airbnb.android.showkase.annotation.ShowkaseRoot
import com.airbnb.android.showkase.annotation.ShowkaseRootModule

@ShowkaseRoot
class ShowkaseRootModule : ShowkaseRootModule
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)
            }
        }
    }
}

Showkase.getMetadata()は最初に赤く(Not Found状態)なります(ビルドすれば解消されます)

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

PreviewでuiModeの設定をしていて、ダークモード・ライトモードの切り替えを表示したい場合は こちら(DroidKaigi 2023の公式アプリでのPR) がかなり参考になります

詰まったところ、大変だったところ

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

参考資料

14
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?