著者の自己紹介
著者はイナバくん。
株式会社ニジボックスのフロントエンドエンジニア。
登山家でもあり、禁煙家でもあります。
某アパレル企業に2年ほど常駐していて、自社プロダクトを開発しております。
この記事で話すこと
GitHub Actionsを使ってVRTを実現する方法をお話しします。
この記事で話さないこと
- VRTとは何か? について
- GitHub Actionsの基本的な使い方や文法について
- storycap・reg-suitなどの基本的な使い方について
また、VRTの構成として有名な下記のサービスたちは使っていません。
- Chromatic: Storybookのホスティングサービス
- AWSのS3: VRTのスクショやレポートのアップ先としてよく使われます
あくまでGitHubのみでVRTを実現する方針となります。
VRT導入の背景
私の参画しているプロダクトでは、フロントエンドは Next.jsで作られています。
UIコンポーネントの管理にはStorybookを使っています。
また、
- ユニットテスト(Jest)
- インテグレーションテスト(Testing-library)
- E2Eテスト(Playwright)
はCIにて導入済になります。
CIでUIの変更も検知するためにVRTを導入することになりました。
VRTのツール選定
まず最初に、下記の構成が候補としてありました。
- Chromatic
- Playwright
- Storycap × reg-suit
また、ツール選定の判断軸として重視していたのは、下記の3点になります。
- コストの低さ: VRTにかけられる予算が限られていたため
- テストの網羅性
- テストの実行時間が短いこと
ツール① Chromatic ❌
ChromaticはStorybook公式のホスティングサービスです。
GitHubと連携することでブランチごとのStorybookをホスティングして、VRTを実行できます。
Chromaticは、VRTにおける
- UI比較
- スクショ保存
- レポート表示
の機能がデフォルトで搭載されているので、Storybookを使っているプロダクトであれば、簡単にVRTを始められます。
ただ、ChromaticはStorybookのデプロイに対して従量課金でコストが発生します。
なので実際に運用するとなる場合は、特定のPR・ブランチのみVRTを実行するようなルールが多いです。
ただ、そのルールだと毎コミット・PRでVRTを実行できないので、「テストの網羅性」が低くなります。
なので、「コストが高い」と「テストの網羅性が低い」の2つの理由で、Chromaticは却下となりました。
(もしUIコンポーネントのデザインレビューなど、Chromaticの機能をフル活用するプロダクトだった場合はオススメです)
ツール② Playwright ❌
Playwrightでは、下記の手順でVRTを実行するパターンをよく見ます。
- Storybookをビルドして、
stories.json
を生成する(各Storyのpath・titleなどの情報が記載されている) - ビルドしたStorybookのHTMLファイルをローカルサーバーで立ち上げる
- Playwrightでブラウザを立ち上げて、2のStorybookにアクセスする
- 各Storyに対して、
toHaveScreenshot()
でVRTを実行するテストケースを書く
こちらは実際にローカルで実装してみたのですが、Storycap × reg-suitと比べると、テスト実行時間が長いです。
なぜなら、PlaywrightはVRT専用のツールではなく、あくまでE2Eテストツールであるためです。
(毎テストケースごとにブラウザを立ち上げる時間もかかります)
そのため、このPlaywrightも不採用としました。
ツール③ Storycap × reg-suit ⭕
実際に採用したのが、Storycap × reg-suitです。
消去法で決まりましたが、これらのツールのメリットは下記になります。
- コストが0
- テスト実行時間が早い
- Storybookを活用できるのでテストケース作成の必要がない
VRTの構成
下記のツール・サービスを使った構成となりました。
- Storybook: UIコンポーネント管理
- Storycap: Storybookの各Storyのスクショを取る
- reg-suit: 2つの画像を比較して差分を検出するVRTツール(レポートも生成できる)
- GitHub Actions: GitHub のPR上でVRTを実行する
- GitHub Page: reg-suitが生成したVRTレポートをホスティングする
- GitHubのArtifact・キャッシュ: スクショの保存先
メリットは下記になります。
- コストが0
- テストの網羅性が高い: 全PRの毎コミットごとに、全テストケースを実行できる
- AWSやChromaticなどの他サービスに依存せず、VRTを導入できる
逆にデメリットとしては、下記がありました。
- GitHub Actionsのワークフロー実装に工数がかかる(他の構成より時間がかかる)
ただ、プロダクトの特性上、導入工数をかけることはOKだったため、こちらの構成となりました。
VRTの運用フロー
下記のようなフローでVRTを回しています。
- featureブランチからmainブランチ向けにPRを出す
- VRTのCIが実行される
- GitHub Actionのキャッシュに、mainブランチのStorybookスクショがあるか確認
- スクショが無かったら、スクショ撮影して、GitHubのキャッシュへ保存
- main・featureのスクショ比較
- GitHubのbotがVRTの結果レポートをPRにコメント
- レビュワーがVRTレポートでUIの変更を確認
- UI変更に問題なければapproveを付けて、PRをmergeする
- PRをmainにmergeすると、mainのStorybookスクショが撮影されて、キャッシュに保存される
GitHub ActionsでのVRT設定方法
npm scriptの準備
まずnpm scriptは下記のように設定しておきます。
{
"scripts": {
"build-storybook": "storybook build",
"storycap": "storycap --serverTimeout 60000 --captureTimeout 10000 --serverCmd \"npx http-server storybook-static -p 9003\" http://localhost:9003",
"reg-suit": "reg-suit run"
}
}
ワークフロー実装 Job① 正となる画像の準備(mainブランチのスクショ)
take_expected_screenshots:
name: Take Expected Screenshots
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out code
uses: actions/checkout@v4
with:
ref: main # mainブランチのスクショをVRTの正の画像とする
- name: Restore cached expected screenshots # キャッシュに保存されたスクショがあったら再利用する
id: expected_screenshots_cache
uses: ./.github/composite_actions/cache-screenshots
- name: Client setup
if: ${{ steps.expected_screenshots_cache.outputs.cache-hit != 'true'}}
uses: ./.github/composite_actions/client-setup
- name: Restore or build storybook # キャッシュにスクショが保存されてなかったらStorybookをビルドしてスクショ撮影する
if: ${{ steps.expected_screenshots_cache.outputs.cache-hit != 'true'}}
uses: ./.github/composite_actions/cache-storybook-static
- name: Install Chrome for puppeteer
if: ${{ steps.expected_screenshots_cache.outputs.cache-hit != 'true'}}
run: npx puppeteer browsers install chrome
- name: Take expected screenshots
if: ${{ steps.expected_screenshots_cache.outputs.cache-hit != 'true'}}
run: npm run storycap
- name: Upload expected screenshots to artifact # 他のstepでスクショを使うのでartifactにアップする
uses: actions/upload-artifact@v4
with:
name: expected-screenshots
path: __screenshots__
retention-days: 1
GitHub はワークフロー内でGitHubのキャッシュにデータを保存したりダウンロードする事ができます。
キャッシュ内にあるデータは別のPRから参照することもできるため、mainブランチのスクショは他のPRで再利用ができます。
(キャッシュの保存期間や容量はレポジトリ設定によって異なるので、実際に使う前に確認しましょう。)
Job内で使っているComposite Action
Composite Action とはワークフロー内で再利用できるアクションです。
他ワークフローでも使いたい処理や、頻出するアクションはComposite Actionにしています。
セットアップ処理(npm installなど)
name: "Client Setup"
description: "FEのCIの各Jobに必要なセットアップ処理をまとめたAction"
runs:
using: "composite"
steps:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
cache-dependency-path: package-lock.json
# キャッシュされたnode_modulesを取得する
- name: Restore node_modules Cache
id: node_modules_cache
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} # パッケージの依存関係が変わっていたらキャッシュのキーが変わります
# キャッシュが存在しなかったら、npm installする(Job終了時にnode_modulesはキャッシュされる)
- name: Install Dependencies
if: ${{ steps.node_modules_cache.outputs.cache-hit != 'true'}}
run: npm install
shell: bash
CIの実行時間の短縮のために、package-lock.json
が変更された場合のみ、npm install
するようにしています。
キャッシュされたmainブランチのスクショの再利用
name: "Cache Expected Screenshots"
description: "キャッシュに保存されているスクショを再利用する。(保存されていなかった場合はアップする)"
outputs:
cache-hit:
description: "キャッシュが存在するかどうか"
value: ${{ steps.expected_screenshots_cache_id.outputs.cache-hit }}
runs:
using: "composite"
steps:
- name: Get commit hash of current branch
id: get_hash
run: echo "hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
shell: bash
- name: Cache screenshots
uses: actions/cache@v4
id: expected_screenshots_cache_id
with:
path: __screenshots__
key: ${{ runner.os }}-screenshots-${{ steps.get_hash.outputs.hash }}
mainブランチにチェックアウトして、スクショがキャッシュに保存されているかどうかを確認しています。
キャッシュのキーはコミットハッシュにしているので、mainブランチに変更があれば、再度スクショを取ってキャッシュに保存しています。
キャッシュされたStorybookのビルド生成物の再利用
name: "Restore or Build Storybook"
description: "キャッシュに保存されているStorybookのビルド成果物を再利用する。(保存されていなかった場合はビルドする)"
runs:
using: "composite"
steps:
- name: Get commit hash of current branch
id: get_hash
run: echo "hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
shell: bash
# キャッシュされたStorybookのビルド成果物を取得する
- name: Restore cached storybook build result
id: storybook_cache
uses: actions/cache@v4
with:
path: storybook-static
key: ${{ runner.os }}-storybook-static-${{ steps.get_hash.outputs.hash }}
# キャッシュが存在しなかったら、Storybookをビルドする(Job終了時にビルド成果物はキャッシュされます)
- name: Build Storybook
if: ${{ steps.storybook_cache.outputs.cache-hit != 'true'}}
run: npm run build-storybook
working-directory: client
shell: bash
Storybookのビルドも時間がかかる処理なので、コミットハッシュが変わっていなかったら、キャッシュに保存したビルド生成物を再利用しています。
ワークフロー実装 Job② featureブランチのスクショの準備
take_actual_screenshots:
name: Take Actual Screenshots
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Client setup
uses: ./.github/composite_actions/client-setup
- name: Workaround for detached HEAD
run: |
git checkout ${GITHUB_HEAD_REF#refs/heads/} || git checkout -b ${GITHUB_HEAD_REF#refs/heads/} && git pull
- name: Build Storybook
run: npm run build-storybook
- name: Install Chrome for puppeteer
run: npx puppeteer browsers install chrome
- name: Take actual screenshots
run: npm run storycap
- name: Upload actual screenshots to artifact
uses: actions/upload-artifact@v4
with:
name: actual-screenshots
path: __screenshots__
retention-days: 1
featureブランチにチェックアウトして、StorybookをビルドしてStorycapでスクショを撮っています。
撮影したfeatureブランチのスクショは、このPRのCIのみで使うので、キャッシュではなくArtifacts に保存しています。
(Artifactsは同一ワークフロー内のみでデータを再利用可能です。)
ワークフロー実装 Job③ main・featureブランチの画像比較して、レポート配信
compare_screenshots:
name: Compare Screenshots
needs:
- take_expected_screenshots
- take_actual_screenshots
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out code
if: ${{ github.event_name != 'pull_request_target' }}
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check out PR
if: ${{ github.event_name == 'pull_request_target' }}
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Client setup
uses: ./.github/composite_actions/client-setup
- name: Download expected screenshots from artifact
uses: actions/download-artifact@v4
with:
name: expected-screenshots
path: .reg/expected/
- name: Download actual screenshots from artifact
uses: actions/download-artifact@v4
with:
name: actual-screenshots
path: __screenshots__
- name: Workaround for detached HEAD
run: git checkout ${GITHUB_HEAD_REF#refs/heads/} || git checkout -b ${GITHUB_HEAD_REF#refs/heads/} && git pull
- name: Compare Screenshots
run: npm run reg-suit
- name: Deploy VRT report to github page
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: .reg/
destination_dir: ${{ github.head_ref }}/vrt-report
- name: Find comment
uses: peter-evans/find-comment@v3
id: find_comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: "github-actions[bot]"
body-includes: reg-suit report
- name: Comment VRT report URL
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.find_comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
reg-suit report
https://fictional-adventure-eveyz55.pages.github.io/${{github.head_ref}}/vrt-report
edit-mode: replace
①・② でArtifacts にアップロードしたmain・featureブランチのスクショを、③のジョブでダウンロードしてreg-suit でVRT実行しています。
reg-suitのレポート(VRT結果) は、GitHub Pageにデプロイして、そのURLをbotがPRにコメントするようにしています。
まとめ
GitHub にはキャッシュ・Artifacts・Page があるので、有効活用すれば他のサービスを使わずにVRTを運用できます。