はじめに
こんにちは、Androidアプリ開発をしている深沢です。
私が担当していたプロジェクトではVisual Regression Testing(以下VRT)を導入しています。VRTとは簡潔に説明すると変更前と変更後の画面のスクリーンショットを比較して差分の有無を確認できるテストのことで、このテストを行うには基準となる正解の画像が必要になります。 画面のデザインの変更は往々にしてあり、その度に正解となる画像を再撮影することになります。この再撮影の手順が思いの外面倒くさく、再撮影のためのテストを実行->撮影された画像を所定のディレクトリに移動->画像をリモートにpushという手順を手動で行う必要がありました。そこで、GitHub ActionsにPRがオープンしたら撮影からpushまでを自動で行ってもらうようにしました。
なお、本記事ではVRTの実装方法については触れませんのでご了承ください。VRTの環境が整っている前提で話を進めます。
今回行ったこと
本記事を執筆するにあたって簡単なAndroidアプリを作成しました。

ボタンを押したら画面遷移をするだけの簡単なアプリです。 この「First Screen」という文字をカタカナにしたくなったので、カタカナに変更したPRを作成しました。

PRをオープンすると、それをトリガーとしてワークフローが動きます。しばらく経つとコメントとともにVRT用の新しい正解画像がpushされるようになりました。


流れとしては以下のようになります。
- PRのオープンをトリガーにワークフローが動く
- ワークフロー内でプロジェクト内のマッピングファイルを確認し、変更ファイルと一致しているか確認
- 一致している場合はマッピングファイルで変更ファイルに紐づいているテストを実行し、所定のディレクトリに移動
- 画像に変更がある場合はその画像をpush
- 完了した旨をPRのコメントに表示
文章だと分かりづらいので以下でもう少し細かく説明していきます。ただしコード量がそれなりにあるため、適宜省略しながらになりますのでご了承ください。
実装の前に
実装の説明の前に、このアプリでは以下のルールに沿って作成しています。そのため、必要に応じて適宜修正をお願いします。
- テストファイルは~Test.ktという名称に必ずすること
- テストクラス名とテストファイル名は同じであること
- テストコード内には画像ファイル名が記載されていること
onRoot().captureRoboImage( filePath = Constants.ROBORAZZI_OUTPUT_DIR_PATH + "/FirstScreen.png", roborazziOptions = roborazziOptions )
実装
マッピングファイルを作成
マッピングファイルにはJetpack Compose関数が書かれたUIに関するファイルとそのUIに紐づくテストのクラス名を書きます。
FirstScreen.kt|FirstScreenTest
SecondScreen.kt|SecondScreenTest
今回のアプリにはFirstScreenとSecondScreenしか無いため以上で完了です。コンポーネント毎にファイルを分けている場合はお手数ですがその分作成してください。
シェルファイルを作成
続いてシェルファイルを作成します。このシェルでは実行したいテストクラス名を入力し、そのテストクラス内のテストをすべて実行させるようにしています。 はじめにテスト名入力欄を作成します。
if [ -n "$1" ]; then
input_test_name="$1"
else
echo "テスト名を入力してください:"
read -r input_test_name
fi
if [ -z "$input_test_name" ]; then
echo "エラー: テスト名が入力されていません"
exit 1
fi
そして、入力したクラス名とマッチしたテストクラスがあることを確認します。見つからない場合はスクリプトを終了します。
for test_file in "$TEST_DIR"/*Test*.kt; do
if [ ! -f "$test_file" ]; then
continue
fi
# ファイル名から拡張子を除く
filename=$(basename "$test_file" .kt)
# ファイル内のクラス名を取得
class_name=$(grep -E "^class [A-Za-z0-9_]+" "$test_file" | head -1 | sed -E 's/^class ([A-Za-z0-9_]+).*/\1/')
# 入力がファイル名またはクラス名と一致するかチェック
if [ "$input_test_name" = "$filename" ] || [ "$input_test_name" = "$class_name" ]; then
found_test="$class_name"
test_class_name="$class_name"
test_file_path="$test_file"
break
fi
done
テスト名が確定したらgradlewコマンドでテストを実行します。
full_test_name="$PACKAGE.$test_class_name"
echo "テストを実行します: $full_test_name"
echo ""
./gradlew :app:testDebugUnitTest --tests "$full_test_name"
TEST_EXIT_CODE=$?
テストが完了したら、テスト結果が格納されているXMLファイルを探します。見つかったらfailureタグの有無を確認し、passed_testsまたはfailed_testsにそれぞれ格納します。failed_testsについては必須ではないですが、確認しやすいように記載しています。
test_results_dir="$PROJECT_ROOT/app/build/test-results/testDebugUnitTest"
test_result_xml=""
if [ -d "$test_results_dir" ]; then
# 完全なテスト名(パッケージ.クラス名)でXMLファイルを検索
test_result_xml=$(find "$test_results_dir" -name "TEST-${full_test_name}.xml" -type f | head -1)
# 見つからない場合は、クラス名のみで検索(フォールバック)
if [ -z "$test_result_xml" ]; then
test_result_xml=$(find "$test_results_dir" -name "TEST-*${test_class_name}.xml" -type f | head -1)
fi
fi
passed_tests=()
failed_tests=()
if [ -n "$test_result_xml" ] && [ -f "$test_result_xml" ]; then
current_testcase=""
in_failure=0
while IFS= read -r line; do
# testcaseタグの開始を検出
if echo "$line" | grep -q '1){split($2,a,"\""); print a[1]}}')
if [ -n "$test_method" ] && echo "$test_method" | grep -qv '\.'; then
current_testcase="$test_method"
in_failure=0
# 自己終了タグで終わっている場合は成功
if echo "$line" | grep -q '/>$'; then
passed_tests+=("$current_testcase")
current_testcase=""
fi
fi
fi
# failureタグを検出
if [ -n "$current_testcase" ] && echo "$line" | grep -q '<failure'; then
in_failure=1
fi
# testcaseタグの終了を検出
if [ -n "$current_testcase" ] && echo "$line" | grep -q ''; then
if [ $in_failure -eq 1 ]; then
failed_tests+=("$current_testcase")
else
passed_tests+=("$current_testcase")
fi
current_testcase=""
in_failure=0
fi
done < "$test_result_xml"
fi
成功したテストについては各テスト関数から画像ファイル名を探し出し、画像をapp/__screenshots__にコピーします。
if [ ${#passed_tests[@]} -gt 0 ]; then
screenshots_dir="$PROJECT_ROOT/app/__screenshots__"
expected_dir="$PROJECT_ROOT/app/__screenshots__"
mkdir -p "$expected_dir"
png_count=0
copied_pngs=()
for test_method in "${passed_tests[@]}"; do
test_function_pattern="fun ${test_method}("
start_line=$(grep -n -F "$test_function_pattern" "$test_file_path" | head -1 | cut -d: -f1)
if [ -n "$start_line" ]; then
end_line=$(tail -n +$((start_line + 1)) "$test_file_path" | grep -n "^[[:space:]]*@Test" | head -1 | cut -d: -f1)
if [ -z "$end_line" ]; then
end_line=$(tail -n +$((start_line + 1)) "$test_file_path" | grep -n "^[[:space:]]*fun [a-zA-Z]" | head -1 | cut -d: -f1)
fi
if [ -z "$end_line" ]; then
end_line=$(wc -l "$temp_png_file"
while IFS= read -r png_name; do
if [ -n "$png_name" ]; then
# 既にコピー済みかチェック
already_copied=false
for copied in "${copied_pngs[@]}"; do
if [ "$copied" = "$png_name" ]; then
already_copied=true
break
fi
done
if [ "$already_copied" = false ]; then
source_png="$screenshots_dir/$png_name"
if [ -f "$source_png" ]; then
# app/__screenshots__にコピー
cp -f "$source_png" "$expected_dir/$png_name"
copied_pngs+=("$png_name")
png_count=$((png_count + 1))
fi
fi
fi
done /dev/null; then
git pull origin "$BRANCH_NAME" || true
else
git checkout -b "$BRANCH_NAME"
git pull origin "$BRANCH_NAME" || true
fi
続いて、PRの変更ファイルを確認して、実行するテストを判定させます。
以下で変更ファイルを取得します。
BASE_SHA=$(git merge-base origin/${{ github.base_ref }} HEAD)
CHANGED_FILES=$( \
{ git diff --name-only $BASE_SHA HEAD; \
git diff --name-only; \
git diff --cached --name-only; } | sort -u | grep -E '\.(kt|ktx)$' || true \
)
マッピングファイルを探索してファイル一覧を取得し、変更ファイルと一致するものがあるか調べます。
# vrt_mapping.txtのすべてのsource_file(一番左のファイル名)を取得
MAPPING_FILE=""
for path in "$GITHUB_WORKSPACE/.github/workflows/vrt_mapping.txt" ".github/workflows/vrt_mapping.txt" "$(pwd)/.github/workflows/vrt_mapping.txt"; do
if [ -f "$path" ]; then
MAPPING_FILE="$path"
break
fi
done
if [ -n "$MAPPING_FILE" ] && [ -f "$MAPPING_FILE" ]; then
# すべての行の1列目(source_file)を取得し、重複を排除
REQUIRED_FILES=$(cut -d'|' -f1 "$MAPPING_FILE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sort -u)
# 1つでもsource_fileが変更ファイルに含まれているかチェック
FOUND_FILE=""
while IFS= read -r required_file; do
if [ -n "$required_file" ]; then
if echo "$CHANGED_FILES" | grep -qE "(^|/)$required_file$"; then
if [ -z "$FOUND_FILE" ]; then
FOUND_FILE="$required_file"
else
FOUND_FILE="$FOUND_FILE $required_file"
fi
fi
fi
done << Test: $test_name"
if [ -z "$TEST_NAMES" ]; then
TEST_NAMES="$test_name"
else
# 重複を避ける
if echo "$TEST_NAMES" | grep -qv "$test_name"; then
TEST_NAMES="$TEST_NAMES $test_name"
fi
fi
fi
fi
done /dev/null | grep -E '\.png$' || true)
if [ -n "$PNG_CHANGES" ]; then
# PNGファイルをステージング
git add AutoVrt/app/__screenshots__/*.png
# 変更がある場合のみコミット
if ! git diff --staged --quiet; then
# コミットするPNGファイル名を取得
COMMITTED_IMAGES=$(git diff --staged --name-only | grep -E '\.png$' | sed 's|AutoVrt/app/__screenshots__/||' | tr '\n' ',' | sed 's/,$//')
git commit -m ":green_heart: VRT画像更新 [skip ci]"
git push origin ${{ github.head_ref }}
fi
fi
fi
そして、結果コメントをPRに追加します。テスト結果とコミットした画像をVRT_TEST_CONCLUSION,COMMITTED_IMAGESと置き、結果によってコメントの内容を作成します。
env:
VRT_TEST_CONCLUSION: ${{ steps.run-vrt-tests.conclusion }}
COMMITTED_IMAGES: ${{ steps.commit-png.outputs.committed_images }}
GH_TOKEN: ${{ env.GH_TOKEN }}
run: |
PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
COMMENT_BODY="### Android VRTレポート\n\n"
# ステータスメッセージ
if [ "$VRT_TEST_CONCLUSION" = "failure" ]; then
STATUS="🔴VRTテストの実行に失敗しました!"
COMMENT_BODY+="VRTテストの実行中にエラーが発生しました。ログを確認してください。\n\n"
elif [ "$VRT_TEST_CONCLUSION" = "skipped" ]; then
STATUS="⚠️VRTテストがスキップされました!"
elif [ "$VRT_TEST_CONCLUSION" = "cancelled" ]; then
STATUS="⚠️VRTテストがキャンセルされました!"
else
STATUS="🟢VRTテストが完了しました!"
fi
COMMENT_BODY+="$STATUS\n\n"
# コミットした画像がある場合は表示
if [ -n "$COMMITTED_IMAGES" ] && [ "$COMMITTED_IMAGES" != "" ]; then
COMMENT_BODY+="**コミットした画像:**\n"
# カンマ区切りの画像ファイル名を改行区切りに変換して、各行の前に-を追加
IMAGE_LIST=$(echo "$COMMITTED_IMAGES" | tr ',' '\n' | sed 's/^/- /')
COMMENT_BODY+="$IMAGE_LIST\n"
fi
# コメントをPRに投稿
echo -e "$COMMENT_BODY" | gh pr comment $PR_NUMBER --body-file -
こうすることで以下の画像のようなコメントが表示されるようになります。

以上で簡単な実装の流れについての説明になります。
さいごに
まだテストが失敗したときは手動で行う必要があったり、ワークフローの実行時間が長かったりと改善点はありますが、PRを作成すれば自動で差分を検知してスクリーンショットの更新を行うようになってくれたのは便利だなと思います。 今後もどんどん作業効率化を図っていければと思います。
深沢雛子