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 設定ファイル)
実際に試した際の比較画像
使用したライブラリやサービス
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時の処理は比較用のコメント表示
で、マージ時の処理は比較用のスクリーンショット保存
を行います。
比較用のコメント表示
- GitHub Appsからアクセストークン取得
- アクセストークンを使用してGitHubにログイン
- GitHub CLIからベースブランチ名取得
- Amazon S3からベースブランチのスクリーンショット取得
- Roborazziで取得したスクリーンショットと現在のスクリーンショットを比較し、差分があれば比較画像を作成
- 比較画像をAmazon S3にあげる
- PRに出すコメント作成
- GitHub CLIでコメントをPRに投稿(同じPRにすでにコメントしてある場合は上書き)
比較用のスクリーンショット保存
- Roborazziでスクリーンショット作成
- Amazon S3にスクリーンショット保存
Roborazzi導入
以下のREADME
にあるBuild setup
の箇所に記載されてる通りに依存関係(plugins
, dependencies
)を追加しました
Amazon S3に画像を保存するときのパス
PRのスクショ比較画像保存
<パケット名>/image/pr/<PRの番号>/〜_compare.png
スクリーンショット保存
<パケット名>/image/<ブランチ名>/〜.png
Circle CI の Orbs、ジョブ、コマンド とその説明
※ 不要そうな箇所を一部除いています
使用したOrbs
Orbsかなり便利でした。
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にデータを同期しながら保存してます
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/copy
のarguments: --recursive
は、これを設定することでフォルダ内にあるファイルを取得してくれます(コピーすることでベースブランチと現在のスクリーンショットの差分が取得できるようになります) -
./gradlew compareRoborazziProdDebug --stacktrace
で〜_compare.png
が作成されます -
gh pr comment "$CIRCLE_PULL_REQUEST" --edit-last -F ./comments
の--edit-last
は、最後に表示されているログインしているユーザーのコメントを編集するために必要なものになります - Amazon S3の画像を表示するのに設定がちょっと必要です(設定しないと権限がないと表示されます)
- 他の箇所は説明省略します 🙇
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 アクセストークン取得
,環境変数に設定
を行なっています
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でもできます))
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
)
import com.airbnb.android.showkase.annotation.ShowkaseRoot
import com.airbnb.android.showkase.annotation.ShowkaseRootModule
@ShowkaseRoot
class ShowkaseRootModule : ShowkaseRootModule
- Preview関数をprivateにしている場合はprivateを削除する
- Previewのテストを追加します
@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の知識がそんなになく調査するのにすごく時間がかかって大変でした
参考資料
-
Roborazzi: Elevating Android Visual Testing to the Next Level
- Roborazziがどのようなライブラリか、どのようにしてVRTをするかがわかる
-
GitHub docs - GitHub App インストールとしての認証
- 正直なところ分かりづらいけど役に立った
-
Zenn - JWT の仕組み
- JWTとは何かがそこそこ分かりやすく書かれている
-
Note - CircleCI から GitHub Apps 経由で Pull Request にコメントを投げる
- CircleCIとGitHub Appsの記事だから参考になった
-
takahirom roborazzi-compare-on-github-comment-sample
- RoborazziとGitHub Actionsを使用したVRTのサンプル、GitHub Actionsだけどかなり参考になる
-
スタディサプリ小学・中学講座でRoborazziを導入しました
- なんのために何を導入したかがわかりやすい
-
S3にアップロードしたファイルを自動的に削除する
- S3のライフサイクルの設定で参考になった
-
airbnb Showkase - Support for AlertDialog previews
- AlertDialogはShowkaseで表示できない
-
introduce Preview screenshot testing with Showkase and Roborazzi
- Showkase導入のサンプル、参考になった
-
How to Retrieve a Pull Request’s Base Branch Name [GitHub]
- CircleCiでベースブランチ名を取得する方法が書いてあって参考にした
-
Add --edit-last-or-create option or enhance the functionality of --edit-last for PR comments
- --edit-lastで、最後に投稿された特定のユーザーのコメントを上書きできるが、初めて投稿する場合にコマンドが失敗してしまうため追加機能のリクエストしているissue(失敗しないようにする方法も書いてある)
-
Support for AlertDialog previews
- ShowkaseではAlertDialogが対応していない
-
s3 sync コマンドを使ってS3バケット間を同期する
-
--delete
という引数を初めて知った(便利そう)
-