2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【GitHub Actions】 PRの差分を確認して自動でVRT用の画像を更新させてみた

2
Last updated at Posted at 2026-03-11

はじめに

こんにちは、Androidアプリ開発をしている深沢です。

私が担当していたプロジェクトではVisual Regression Testing(以下VRT)を導入しています。VRTとは簡潔に説明すると変更前と変更後の画面のスクリーンショットを比較して差分の有無を確認できるテストのことで、このテストを行うには基準となる正解の画像が必要になります。
画面のデザインの変更は往々にしてあり、その度に正解となる画像を再撮影することになります。この再撮影の手順が思いの外面倒くさく、再撮影のためのテストを実行->撮影された画像を所定のディレクトリに移動->画像をリモートにpushという手順を手動で行う必要がありました。そこで、GitHub ActionsにPRがオープンしたら撮影からpushまでを自動で行ってもらうようにしました。

なお、本記事ではVRTの実装方法については触れませんのでご了承ください。VRTの環境が整っている前提で話を進めます。

今回行ったこと

本記事を執筆するにあたって簡単なAndroidアプリを作成しました。

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

create_pr.png

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

comment.png

pr_diff.png

流れとしては以下のようになります。

  1. PRのオープンをトリガーにワークフローが動く
  2. ワークフロー内でプロジェクト内のマッピングファイルを確認し、変更ファイルと一致しているか確認
  3. 一致している場合はマッピングファイルで変更ファイルに紐づいているテストを実行し、所定のディレクトリに移動
  4. 画像に変更がある場合はその画像をpush
  5. 完了した旨を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 '<testcase'; then
            test_method=$(echo "$line" | awk -F'name="' '{if(NF>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 '</testcase>'; 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 < "$test_file_path")
            else
                end_line=$((start_line + end_line - 1))
            fi

            # テスト関数内のPNGファイル名を抽出
            temp_png_file=$(mktemp)
            sed -n "${start_line},${end_line}p" "$test_file_path" | grep -oE '[A-Za-z0-9_]+\.png' > "$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 < "$temp_png_file"

            rm -f "$temp_png_file"
        fi
    done
fi

exit $TEST_EXIT_CODE

ワークフローを作成

マッピングファイルとシェルを組み合わせてワークフローを作成していきます。トリガーやJDKの設定等は割愛します。
まずは、PRのマージ元ブランチに移動します。

- name: workaround for detached HEAD
  if: github.event_name == 'pull_request'
  run: |
    BRANCH_NAME=${GITHUB_HEAD_REF#refs/heads/}
    
    if git checkout "$BRANCH_NAME" 2>/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 <<< "$REQUIRED_FILES"

そして実行すべきテスト名をマッピングファイルから取得します。

if [ -n "$MAPPING_FILE" ] && [ -f "$MAPPING_FILE" ]; then
  while IFS="|" read -r source_file test_name; do
    # source_fileの前後の空白を削除
    source_file=$(echo "$source_file" | xargs)
    test_name=$(echo "$test_name" | xargs)

    # 変更ファイルにsource_fileが含まれているかチェック(ファイル名のみでマッチ)
    if echo "$CHANGED_FILES" | grep -qE "(^|/)$source_file$"; then
      if [ -n "$test_name" ] && [ "$test_name" != "" ]; then
        echo "Found matching file: $source_file -> 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 < "$MAPPING_FILE"

実行すべきテストがあるときはVRTを実行します。

run: |
  chmod +x .github/workflows/auto_photograph_vrt.sh
  
  # gradlewに実行権限付与
  if [ -f "AutoVrt/gradlew" ]; then
    chmod +x AutoVrt/gradlew
  fi
  
  TEST_NAMES="${{ steps.check-and-run-vrt.outputs.test_names }}"
  
  # 各テストを実行
  for test_name in $TEST_NAMES; do
    echo "Running VRT test: $test_name"
    .github/workflows/auto_photograph_vrt.sh "$test_name" || true
  done
continue-on-error: true

これでテスト実行までが終わりました。最後に変更画像がある場合にpushとPRにコメントを追加します。

まず、VRTで生成されたスクリーンショットの画像の変更を検出し、コミットをおこないます。

if [ -d "AutoVrt/app/__screenshots__" ]; then
    # 変更されたPNGファイルを検出
    PNG_CHANGES=$(git status --porcelain AutoVrt/app/__screenshots__/ 2>/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 -

こうすることで以下の画像のようなコメントが表示されるようになります。

comment.png

以上で簡単な実装の流れについての説明になります。

さいごに

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

この記事はRecoChoku Engineering Blogの記事を転載したものとなります。

2
0
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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?