🖼️

GitHub ActionsでVisual Regression Test(VRT)の実装を簡単にするカスタムアクションをリリースした

2023/12/20に公開

Easy VRTというGitHub Actionsのカスタムアクションを公開しました。これはVisual Regression Test(VRT)のワークフローを簡単に作ることができるアクションとワークフローのテンプレートで構成されています。

https://github.com/yorifuji/easy-vrt

VRTでは画像の撮影と回帰テストの2つの作業をCIのワークフローに落とし込む必要があります。

画像の撮影はマージ先のブランチ(下の図ではmaster)を正の結果とする期待値の画像(expected)と、topicブランチの画像(actual)の2種類が必要です。Gitのhistoryをもとに撮影対象のコミットを特定する必要があります。


reg-suitのHPより

回帰テストでは過去に撮影した画像(特に期待値の画像)をキャッシュとして利用して2回目以降で再利用することで全体の処理時間を短縮するテクニックなどもあります。キャッシュやテスト結果のレポートの保管先として外部ストレージ(S3/Google Cloud Storageなど)を利用することが一般的で、クラウドサービスの設定や認証情報の準備などが必要になります。

これらの処理をCIの中で行う必要があり、VRTのワークフローや設定は複雑になりがちです。

カスタムアクション

公開したカスタムアクションはVRTに対して汎用的な機能とワークフローのテンプレートを提供します。

特徴は以下の通りです。

  • カスタムアクションがVRTに必要な共通的な処理を行います
  • テンプレートワークフローがCIの雛形を用意します
  • 外部ストレージを利用せずGitHub Actionsのみで完結させます
  • reg-cliを使った回帰テストを行います
  • 回帰テストの結果はArtifactにアップロードします、ダウンロードの一手間がありますが許容することにします

実際の実行例はこちらのリポジトリで見れます。

PR
https://github.com/yorifuji/easy-vrt-example/pull/1

Workflow
https://github.com/yorifuji/easy-vrt-example/actions/runs/7237123682

ユースケースについて

このアクションは私がFlutterアプリ開発で使用していたものがベースとなっています。FlutterはフレームワークのレベルでGolden File Test(VRT)をサポートしていて、アプリのスクリーンショットの撮影が容易に行えるプラットフォームです。

一方でスクリーンショットの撮影のため成果物をデプロイしたり実際にサーバーへアクセスする必要のある言語やプラットフォームなどで、スクリーンショットの取得が複雑なワークフローにならざるを得ない環境では本アクションの利用は難しいかもしれません。

テンプレートワークフロー

テンプレートワークフローは以下のようなJobで構成されています。

各々の環境に合わせて微調整は必要ですが、ポイントとなるのは後述の2箇所に画像を作成するステップを追加することです。

テンプレートワークフローの全体

name: vrt

run-name: visual regression test

on: pull_request

permissions:
  contents: read

jobs:
  lookup:
    runs-on: ubuntu-latest
    outputs:
      actual-sha: ${{ steps.lookup.outputs.actual-sha }}
      actual-cache-hit: ${{ steps.lookup.outputs.actual-cache-hit }}
      expected-sha: ${{ steps.lookup.outputs.expected-sha }}
      expected-cache-hit: ${{ steps.lookup.outputs.expected-cache-hit }}
    steps:
      - uses: yorifuji/easy-vrt@v1
        id: lookup
        with:
          mode: lookup

  expected:
    if: ${{ !cancelled() && !failure() && needs.lookup.outputs.expected-cache-hit != 'true' }}
    needs: lookup
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ needs.lookup.outputs.expected-sha }}

      # >>> add step to create expected image

      # <<< add step to create expected image

      - uses: yorifuji/easy-vrt@v1
        with:
          mode: expected
          expected-dir: your-expected-image-dir # set the directory where the expected image is stored
          expected-cache-key: ${{ needs.lookup.outputs.expected-sha }}

  actual:
    if: ${{ !cancelled() && !failure() && needs.lookup.outputs.actual-cache-hit != 'true' }}
    needs: lookup
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ needs.lookup.outputs.actual-sha }}

      # >>> add step to create actual image

      # <<< add step to create actual image

      - uses: yorifuji/easy-vrt@v1
        with:
          mode: actual
          actual-dir: your-actual-image-dir # set the directory where the actual image is stored
          actual-cache-key: ${{ needs.lookup.outputs.actual-sha }}

  compare:
    if: ${{ !cancelled() && !failure() }}
    needs: [lookup, expected, actual]
    runs-on: ubuntu-latest
    steps:
      - uses: yorifuji/easy-vrt@v1
        with:
          mode: compare
          expected-cache-key: ${{ needs.lookup.outputs.expected-sha }}
          actual-cache-key: ${{ needs.lookup.outputs.actual-sha }}

上記のワークフローをそのままコピーして、expectedとactualのJobの以下の2箇所に画像を生成するstepを記述します。画像を保存したフォルダのパスをアクションのパラメータに渡します。

      # >>> add step to create expected image

      # <<< add step to create expected image

      - uses: yorifuji/easy-vrt@v1
        with:
          mode: expected
          expected-dir: your-expected-image-dir # set the directory where the expected image is stored
          expected-cache-key: ${{ needs.lookup.outputs.expected-sha }}

      # >>> add step to create actual image

      # <<< add step to create actual image

      - uses: yorifuji/easy-vrt@v1
        with:
          mode: actual
          actual-dir: your-actual-image-dir # set the directory where the actual image is stored
          actual-cache-key: ${{ needs.lookup.outputs.actual-sha }}

Flutterアプリの例を挙げるとexpectedの画像生成は以下のような記述になります。actualについても同じ処理を記述します。Reusable workflowを使って生成の処理を共通化すると良いかもしれません。

      - uses: subosito/flutter-action@v2

      - run: |
	  flutter pub get
	  flutter test --update-goldens --tags=golden

      - uses: yorifuji/easy-vrt@main
        with:
          mode: expected
          expected-dir: test/golden_test/goldens
          expected-cache-key: ${{ needs.lookup.outputs.expected-sha }}
      - uses: subosito/flutter-action@v2

      - run: |
	  flutter pub get
	  flutter test --update-goldens --tags=golden

      - uses: yorifuji/easy-vrt@main
        with:
          mode: actual
          actual-dir: test/golden_test/goldens
          actual-cache-key: ${{ needs.lookup.outputs.actual-sha }}

actualとexpectedのJobが終わったらcompareのJobでテストが行われます。内部ではreg-cliを使っています。結果のレポートはArtifactsにアップロードされます。

ダウンロードして展開するとreg-cliで生成されたレポートが含まれています。

Job SummaryやPullRequestにコメントを記入するオプションも用意しています。


ワークフローのサマリーに表示される内容


Pull Requestのコメントに表示される内容

Jobの詳細

テンプレートワークフローのJobについて

lookup

lookup
  lookup:
    runs-on: ubuntu-latest
    outputs:
      actual-sha: ${{ steps.lookup.outputs.actual-sha }}
      actual-cache-hit: ${{ steps.lookup.outputs.actual-cache-hit }}
      expected-sha: ${{ steps.lookup.outputs.expected-sha }}
      expected-cache-hit: ${{ steps.lookup.outputs.expected-cache-hit }}
    steps:
      - uses: yorifuji/easy-vrt@v1
        id: lookup
        with:
          mode: lookup

Pull RequestのHEADとBaseブランチから画像生成対象のshaと、キャッシュの有無を確認するJobです。こちらはテンプレートワークフローを利用する際に必要なJobのため、特に編集する必要はありません。

expected, actual

expected, actual
  expected:
    if: ${{ !cancelled() && !failure() && needs.lookup.outputs.expected-cache-hit != 'true' }}
    needs: lookup
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ needs.lookup.outputs.expected-sha }}

      # >>> add step to create expected image

      # <<< add step to create expected image

      - uses: yorifuji/easy-vrt@v1
        with:
          mode: expected
          expected-dir: your-expected-image-dir # set the directory where the expected image is stored
          expected-cache-key: ${{ needs.lookup.outputs.expected-sha }}

  actual:
    if: ${{ !cancelled() && !failure() && needs.lookup.outputs.actual-cache-hit != 'true' }}
    needs: lookup
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ needs.lookup.outputs.actual-sha }}

      # >>> add step to create actual image

      # <<< add step to create actual image

      - uses: yorifuji/easy-vrt@v1
        with:
          mode: actual
          actual-dir: your-actual-image-dir # set the directory where the actual image is stored
          actual-cache-key: ${{ needs.lookup.outputs.actual-sha }}

lookupで取得したハッシュもとにexpectedとactualの画像生成を行うJobです。lookupでcacheが存在した場合はJobをスキップします。

各々の環境でテンプレートワークフローを使う場合はこのJobに画像を生成するstepを追加します。生成した画像を保存したフォルダをexpected-diractual-dirにセットします。

compare

compare
  compare:
    if: ${{ !cancelled() && !failure() }}
    needs: [lookup, expected, actual]
    runs-on: ubuntu-latest
    steps:
      - uses: yorifuji/easy-vrt@v1
        with:
          mode: compare
          expected-cache-key: ${{ needs.lookup.outputs.expected-sha }}
          actual-cache-key: ${{ needs.lookup.outputs.actual-sha }}

expectedとactualの画像に対してreg-cliを使って回帰テストを行います。結果のレポートはArtifactにアップロードされます。オプションでSummaryやPullRequestにコメントを記入する機能を使う場合はフラグをセットします(詳しくはREADMEを参照)。

カスタムアクションの実装

ワークフローで利用しているカスタムアクション(yorifuji/easy-vrt)の内部実装です、テンプレートワークフローを利用するだけであれば中身を知る必要はありません。興味のある方のみ参考にしてください。

lookup

actualとexpectedに対応するGitのshaを確認します。actualはtopicブランチのheadを、expectedはbaseブランチとtopicブランチの共通の親(git merge-base)を使用します。

shaが確定したらactions/cachelookup-only:フラグを使ってcacheの有無を確認します。

ハッシュ値とキャッシュの有無はoutputを経由して後述のJobで利用します。

lookup
    # lookup

    - if: ${{ inputs.mode == 'lookup' }}
      uses: actions/checkout@v4
      with:
        fetch-depth: 0

    - if: ${{ inputs.mode == 'lookup' }}
      id: lookup-sha
      shell: bash
      run: |
        echo "actual-sha=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
        echo "expected-sha=$(git merge-base ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }})" >> $GITHUB_OUTPUT

    - if: ${{ inputs.mode == 'lookup' }}
      id: lookup-actual-cache
      uses: actions/cache@v3
      with:
        key: reg-suit-cache-${{ steps.lookup-sha.outputs.actual-sha }}
        path: .easy-vrt/actual
        lookup-only: true

    - if: ${{ inputs.mode == 'lookup' }}
      id: lookup-expected-cache
      uses: actions/cache@v3
      with:
        key: reg-suit-cache-${{ steps.lookup-sha.outputs.expected-sha }}
        path: .easy-vrt/expected
        lookup-only: true

expected, actual

パラメータで受け取った画像をactions/cache/saveを使ってcacheに保存する処理を行います。

actual, expected
    # expected

    - if: ${{ inputs.mode == 'expected' }}
      shell: bash
      run: |
        if [ -e .easy-vrt ]; then exit 1; fi
        mkdir .easy-vrt && mv ${{ inputs.expected-dir }} .easy-vrt/expected

    - if: ${{ inputs.mode == 'expected' }}
      uses: actions/cache/save@v3
      with:
        path: .easy-vrt/expected
        key: reg-suit-cache-${{ inputs.expected-cache-key }}

    # actual

    - if: ${{ inputs.mode == 'actual' }}
      shell: bash
      run: |
        if [ -e .easy-vrt ]; then exit 1; fi
        mkdir .easy-vrt && mv ${{ inputs.actual-dir }} .easy-vrt/actual

    - if: ${{ inputs.mode == 'actual' }}
      uses: actions/cache/save@v3
      with:
        path: .easy-vrt/actual
        key: reg-suit-cache-${{ inputs.actual-cache-key }}

compare

少し長いですが以下の処理を行っています。

  • actualとexpectedのデータをcacheからリストア
  • npm で reg-cli をインストール
  • npx reg-cli を実行
  • Artifactsにレポートをアップロード
  • (オプション)Job SummaryとPull Requestにサマリーを出力
compare
    # compare

    - if: ${{ inputs.mode == 'compare' }}
      shell: bash
      run: |
        echo expected-cache-key ${{ inputs.expected-cache-key }}
        echo actual-cache-key ${{ inputs.actual-cache-key }}

    - if: ${{ inputs.mode == 'compare' }}
      shell: bash
      run: |
        if [ -e .easy-vrt ]; then exit 1; fi

    - if: ${{ inputs.mode == 'compare' }}
      shell: bash
      run: |
        if [ -e package.json ]; then exit 1; fi
        if [ -e package-lock.json ]; then exit 1; fi
        cp $GITHUB_ACTION_PATH/package.json $GITHUB_ACTION_PATH/package-lock.json .

    - if: ${{ inputs.mode == 'compare' }}
      uses: actions/setup-node@v4
      with:
        node-version: 20
        cache: npm

    - if: ${{ inputs.mode == 'compare' }}
      shell: bash
      run: npm install

    - if: ${{ inputs.mode == 'compare' }}
      uses: actions/cache/restore@v3
      with:
        key: reg-suit-cache-${{ inputs.actual-cache-key }}
        path: .easy-vrt/actual

    - if: ${{ inputs.mode == 'compare' }}
      uses: actions/cache/restore@v3
      with:
        key: reg-suit-cache-${{ inputs.expected-cache-key }}
        path: .easy-vrt/expected

    - if: ${{ inputs.mode == 'compare' }}
      shell: bash
      run: >
        npx reg-cli
        .easy-vrt/actual
        .easy-vrt/expected
        .easy-vrt/diff
        -R .easy-vrt/report.html
        -J .easy-vrt/report.json

    - if: ${{ !cancelled() && inputs.mode == 'compare' }}
      uses: actions/upload-artifact@v3
      with:
        name: easy-vrt-report
        path: .easy-vrt
        # retention-days: 3

    - if: ${{ !cancelled() && inputs.mode == 'compare' }}
      uses: actions/github-script@v7
      env:
        SUMMARY_COMMENT: ${{ inputs.summary-comment }}
        REVIEW_COMMENT: ${{ inputs.review-comment }}
      with:
        script: |
          const fs = require('fs');

          const summaryComment = process.env.SUMMARY_COMMENT === 'true';
          const reviewComment = process.env.REVIEW_COMMENT === 'true';

          const log = fs.readFileSync('.easy-vrt/report.json', 'utf-8');
          console.log(log);
          const json = JSON.parse(log);
          console.log(json);

          const titleIcon = '✅';
          const easyVrtComment = '<!-- Easy VRT Comment -->';

          const stats = {
            changed: json.failedItems.length.toString(),
            newItems: json.newItems.length.toString(),
            deleted: json.deletedItems.length.toString(),
            passing: json.passedItems.length.toString()
          };

          const markdown = await core.summary
            .addHeading(`${titleIcon} easy-vrt has checked for visual changes`, 3)
            .addTable([
              ["🔴 Changed",  "🟡 New",        "🟤  Deleted",  "🔵 Passing"],
              [stats.changed, stats.newItems, stats.deleted, stats.passing]
            ])
            .addHeading("📊 Download Report", 3)
            .addLink('You can download the report from the artifact here', `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`)
            .addRaw(`${easyVrtComment}`)
            .stringify()

          if (summaryComment) {
            await core.summary.write()
          }

          if (reviewComment) {
            const requestPerPage = 100;
            try {
              const listComments = await github.rest.issues.listComments({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                per_page: requestPerPage
              });
              const easyVrtCommentList = listComments.data.find(comment => comment.body.includes(easyVrtComment));
              if (easyVrtCommentList) {
                // delete summary comment if exists
                await github.rest.issues.deleteComment({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  comment_id: easyVrtCommentList.id
                });
              }
              // create a comment
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: markdown
              });
            } catch (error) {
              logError(`Failed to modify comment: ${error.message}`);
              return;
            }
          }

回帰テストはreg-suitではなくreg-cliを使っていることがポイントです。reg-cliはreg-suitの一部の機能が省略されたcliツールです。また-J, --jsonオプションを使うことで結果をjson形式で出力することができます。出力されたjsonファイルを解析してJobSummaryやPull Requestへコメントする機能を実装しています。

Artifactへのアップロードやログの解析などはactions/github-scriptを使ってJavaScriptで実装しています。

画像の生成について

Flutterであれば「Flutter VRT」で検索すると記事が出てくるのでそちらを参照ください。

まとめ

VRTのワークフローをテンプレート化することで手軽に利用できるようにしました。

それでは良きVRTライフをお楽しみください!

Discussion