GitHub ActionsでVisual Regression Test(VRT)の実装を簡単にするカスタムアクションをリリースした
Easy VRTというGitHub Actionsのカスタムアクションを公開しました。これはVisual Regression Test(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
Workflow
ユースケースについて
このアクションは私が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-dir
とactual-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/cache
のlookup-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