ご機嫌麗しゅうございます。 @kimukei です。 これは 【前編】Visual Regression Testing の内製化への道 🚀 〜Chromaticから代替ツールへ〜 の後編にあたります。
ではさっそく、どうやって Chromatic から代替ツールへ移行していったかを紹介していきます。
コンテキスト
- カイポケリニューアルでは 577 個の Story, kaipoke-ui1 は 63 個ほどの Story を有している
- VRT しているスナップショット数はカイポケリニューアルで 969 枚、kaipoke-ui で 139 枚程度
- カイポケリニューアルでは比較的動きの多い Modal や yagisan-reports2 を使用した PDF を表示する Story が多く存在する
これは、そんなカイポケリニューアルの pull request に実行する CI で Storybook の配信から VRT の実行とテストレポートの発行と配信までを 5 分程度に収めた物語。 また、それまで VRT の仕組みは Chromatic を使っていたため、なるべく VRT の体験は Chromatic のそれを損なわないように工夫しました。
ここで行う VRT としての CI と Storybook の配信と VRT の結果レポートの配信としての CD についてワークフローで行う処理の流れは以下のようになります。
- Storybook を build
- Baseline3 の取得
- Storybook の撮影
- 撮影した画像と Baseline との比較(VRT)
- VRT レポートを作成
- Storybook と VRT レポートをデプロイ & 配信
- VRT で差分が検出されたときはマージをブロックする
CI/CD の実行環境は GitHub Actions です。 VRT は reg-viz/storycap と reg-viz/reg-cli を用いて実現しています。
そこで、実現する中でいくつか知見が溜まったので紹介していきます。
Storybook, VRT について CI/CD 最適化の知見
GitHub Actions ランナーとコスト最適化
GitHub Actions hosted runner と AWS CodeBuild hosted runner のコスト比較
large runner についてホストが GitHub Actions hosted runner と AWS CodeBuild hosted runner とでコスト比較すると、CodeBuild の方が安いことがわかります。
- GitHub Actions hosted runner pricing: https://docs.github.com/en/billing/managing-billing-for-your-products/managing-billing-for-github-actions/about-billing-for-github-actions#per-minute-rates-for-x64-powered-larger-runners
- AWS CodeBuild hosted runner pricing: https://aws.amazon.com/codebuild/pricing/
※ GitHub Actions hosted runner は arm64 アーキテクチャだと安いのですが、今回 chrome や chromium と puppeteer を動かしつつ継続的にメンテナンスしていく都合上 arm64 アーキテクチャの runner の採用は見送っています
今回私が選定した runner は CPU のコア数が欲しくて CodeBuild(ap-northeast-1)の general1.xlarge($0.1002/min) なのですが、近しい性能を出そうとすると GitHub Actions self hosted runner の Linux 32-core($0.128/min)を選ぶことになります。
また、CodeBuild の runner はネットワーク上、S3 との通信が速いため Baseline の取得が早くなる利点などもありました。
ジョブ分割の落とし穴
VRT の時間的なネックはだいたい撮影にあります。枚数が多いのです。そこでよく sharding をして job を分割する手段が取られますが、この場合 job 分割のオーバーヘッドがなかなか無視できません。 具体的には以下のようなオーバーヘッドが乗ってきます。
- runner の割り当て待ち
- 前の job で作られた生成物のダウンロード(前の job では生成物をアップロードするコストも発生します)
runner の割り当て待ちについてですが、GitHub Actions では標準 runner ではないものを使うとしばしば pick されるまで遅い現象が発生しました。 CodeBuild では provisioning なども発生するため、だいたい job の最初の step が実行されるまで 30 秒程度かかってきました。
reg-viz/storycap には parallel オプションが、 reg-viz/reg-cli には concurrency オプションが提供されています。
そのためここではあえて job 分割を避けて large runner を用いてその中で大量に実行することでオーバーヘッドを極力なくしました。(そのために CPU コア数が欲しかった!)
ちょうどこれくらい大きな Storybook だと、build した生成物も 50MB 強くらいの大きさで、GitHub Actions の標準 runner を使っていると動作が安定しないことなども起こっていましたが、CPU コア数が潤沢な runner を選ぶとだいたいそこそこ潤沢なメモリも付いてきます。これが撮影動作の安定化なども副産物的にもたらしてくれました。
ちなみに CodeBuild hosted runner を用いているとマシンリソースについてのメトリクスも同時に取れるので、最適な runner 探しも捗りました。GitHub Actions hosted runner でもメトリクスは見ようと思えば見れますが、見るためのスクリプトを入れたりなどが必要で「そういえば」とふと思った時に見られる準備をしていないことがほとんどです。
この辺はマシンスペックのチューニングと共に job と step のチューニングも同時に行うと良いでしょう。 ワークフロー全体のネック部分や pick 部分の可視化を行うのに Kesin11/actions-timeline がとても便利でした。設定や基盤が不要でワークフローに差し込むとすぐにこのような図が見られるようになります。

まとめると、一見効率的に思えるジョブの分割ですが実際には
- 生成物のアップロード・ダウンロードに時間がかかる
- ジョブ間のリレーで待機時間が発生
- ランナーの割り当て待ちが複数回発生
むしろ 1 つの大きなジョブで実行する方が、総合的なパイプライン実行時間が短くなることがあるということでした。
並列実行できる step をバシバシ並列実行させていく
コマンドはこんな感じで並列実行できます。
jobs:
parallel-commands:
runs-on: ubuntu-latest
steps:
- name: Run commands in parallel
run: |
command1 &
command2 &
command3 &
wait
Baseline のダウンロードと Storybook の撮影は並列化可能でどちらも時間のかかる処理のため並列化したことで 1 分程度の短縮につながっています。 これも large runner のスペックがあるが故ですね。
配信基盤の選択とサイズ管理
Storybook と VRT レポートの配信について「どこ」に配信するかは重要な問題です。
よく使われるのは Amazon S3 でしょうか。今回は Private GitHub Pages を選択しています。
Private GitHub Pages の利点
AWS で認証付き配信基盤を構築するよりも、Private GitHub Pages を使用する方がセットアップが容易で管理コストも低くなります。
前提として、弊社ではエンジニアはほぼフルリモートで開発しています。 そのため何かを限定公開する際にはアクセスできるのは弊社の人間のみにする何かしらの仕組みが必要になります。
S3 では VPN の IP のみアクセス可能にするのがお手軽ですが、VPN 接続がめんどくさいという体験上のネックポイントが発生します。 組織の Google アカウントと連携した認証を用意するには工夫が必要です。
弊社の GitHub リポジトリは Google アカウントでの SAML 認証を必須化しているため、Private GitHub Pages で配信することで VPN 接続の煩わしさを解決しつつ同時にセキュアな限定配信を可能にしました。
ただし、GitHub Pages には制限があります。
- アーティファクトのサイズ制限がシビア
- 同時デプロイリクエストに弱い(順次処理が基本)
それぞれ次のセクションでどうやって対応したのか紹介します。
アーティファクトサイズ管理の重要性
GitHub Pages のデプロイは
- 特定のブランチ(ここでは
gh-pagesブランチ)の内容を artifact 化し、アップロード - アップロードした artifact を Pages にデプロイ
という流れで行われます。 この artifact は 1GB 程度に収めておかないと deploy の失敗確率が上がったり、deploy に時間がかかるようになります。
GitHub Pages のパフォーマンスを維持するために、
- VRT レポートから不要な画像(差分がないもの)を除去
- 変更がない場合はレポートを作成しない判断ロジックを導入
- デプロイは可能な限りまとめて実行(同時リクエストの頻度を減らす)
- Pull Request の merge/close タイミングでの
gh-pagesブランチの掃除 - 定期的な古いファイルの
gh-pagesブランチの掃除
を行いました。 VRT レポートから不要な画像を除去できたり、変更がない場合はレポートを作成しないようにできたのは、フロントエンド部分を実装しているエンジニアにヒアリングしたところ PASSED の画像はほぼ見ていないとこがわかったためです。 PASSED の画像をレポートから除外することでフルだと 50MB くらいの容量のレポートが大抵 2~3MB になり 90%以上のサイズ削減が見込めます。
同時デプロイリクエストの制御
GitHub の Deployments はリクエストを排他的に処理します。つまり、実行中の Deployments があれば他の Deployments のリクエストは失敗します。 また、GitHub Pages の Build and deployment には「GitHub Actions」と「Deploy from a branch」の 2 種類のやり方があります。 前者は完全にこっちが deploy の面倒を見る設定で、後者がブランチが更新されたら自動で GitHub の方でデプロイしてくれるやり方です。
「Deploy from a branch」の設定は自動でやってくれるのは嬉しいのですが、ブランチの変更頻度が高いと同時実行性の点で難があります。 重複実行が回避されるように、実行中に新しい実行リクエストが来たら現在の実行をキャンセルし、新しい実行を始めるように設計されていて、この設定はいじれません。 そのため、ブランチの変更頻度が高い場合に実行リクエストが続々と来てしまうと常に最新のリクエストを処理しようとして最初のリクエストの変更が延々と Pages に反映されません。
これを避けるために前者の「GitHub Actions」で Pages デプロイする方法を取りました。 この方法ですと、ワークフロー内の concurrency 設定で同時実行された際の行動を制御できるようになります。 ここではデプロイのワークフローは他の実行によりキャンセルされないようにして、それぞれデプロイをやり切るようにしました。 その際、Deployments のリクエスト自体は排他的ですので、後続の Deployments リクエストは失敗する恐れがありますが、前のデプロイが終われば成功します。 そのため自動的に rerun できる仕組みを構築4し、artifact name はみな同一にしておいて last write win の状況を作り、upload artifact を行う job と deploy artifact を実行する job とを分け、deploy artifact を実行する job のみ rerun が行われるように組むことで順次デプロイは解消しつつ最終的には最新の artifact が Pages にデプロイされている状況を作り出しました。
VRT の最適化
これまで Chromatic を活用していたため、開発者のメンタルモデルには Chromatic の体験が根付いています。 今回 VRT の手法を Chromatic から移行させるにあたり、いかに Chromatic の体験に近づけていったか、それぞれポイントを紹介します。
storycap により生成される画像ファイル名と対象の Story の連携改善
Chromatic の体験として大きいのが、VRT で検知されたスナップショットで該当する Story のページへ即座に飛べることがあります。 この体験を移行するのに、reg-viz/storycap で生成される画像ファイル名から Storybook の URL に変換できる関数を実装し、VRT レポートのサマリーを Pull Request にコメントすると共に変更を検知したスナップショットについてそれぞれ該当する Storybook のリンクをコメントと一緒に添えるようにしました。
例)

reg-viz/reg-cli で生成した VRT レポート内に Storybook のリンクを配置するのは難しいですが、これならばそこまで体験を損ねずに VRT の結果と該当の Storybook への遷移がしやすくなります。
アニメーション対策とテスト安定性向上
VRT は flaky さとの戦いです。
VRT の安定性を高めるための施策をいくつか実施しました。
- CSS アニメーションを無効化
- JavaScript アニメーションを一時停止
- タイミングに依存する要素の制御
最初の 2 つは DOM のテスティングでもよくやる推奨されるようなやり方ですし、紹介は割愛します。
タイミングに依存する要素の制御については、 parameters.screenshot.waitFor で頑張るのが良いのですが、構造上やむを得ないものは parameters.screenshot.delay でお茶を濁す感じになりました。
さらに、カイポケリニューアルでは複数のフォントを扱っているのですが、たまにフォント読み込みが終わっていないのに撮影されたために誤検出するケースもありました。
その問題については、すべてのフォント読み込みまで待つ util を実装しました5。
他にもスナップショットを安定させるためのあらゆる待ち条件を util 化し、各 Story で管理しやすくしました。
さらに、テストの不安定性(flaky)を検出するための仕組みも構築しました。具体的には、Baseline となる画像群(すでに S3 にアップロード済み)と、デフォルトブランチの最新コードから新たに生成した画像群を比較するワークフローを実装し、定期的(デイリー)かつ複数回実行することで、環境や実行タイミングによる差異を検出しています。
約 1000 枚のスナップショットを扱う規模では、単純な数の問題からも不安定性が生じやすくなります。このため、検出 → 対応 → 再確認のサイクルを地道に繰り返すことが信頼性向上のカギとなりました。特に flaky テストは確率的に発生するため、複数回の実行による検証は非常に重要です。
PDF のビジュアルテスト
PDF 生成は yagisan-reports を使用しているのですが、PDF に対しても VRT を実施したいとなると何かしらの viewer が必要になります。 wojtekmaj/react-pdf を viewer に活用し、PDF の VRT を実現しています。 PDF は他のコンポーネントに比べるとレンダリングにやや時間がかかるため、撮影タイミングの制御は必須です。レンダリングを検知するための data-testid などを設定し、その要素の表示を待ってからスクリーンショットを撮影するようにしています。
VRT 判定基準の最適化
VRT における変更検知の閾値設定は、いわば「匠の塩加減」のようなものです。例えば Chromatic では diffThreshold のデフォルト値として .063 (6.3%) を採用しており、この閾値の決定には経験と試行錯誤が不可欠です。
https://www.chromatic.com/docs/threshold/
しかし、Chromatic の 6.3% をそのまま reg-viz/reg-cli に適用すると、検知感度に違いが生じ、偽陰性(変更があるのに検知されない)が増加する傾向がありました。
また、reg-viz/reg-cli では、より詳細な閾値設定が可能になっています:
-M, --matchingThreshold Matching threshold, ranges from 0 to 1. Smaller values make the comparison more sensitive. 0 by default. Specifically, you can set how much of a difference in the YIQ difference metric should be considered a different pixel. If there is a difference between pixels, it will be treated as "same pixel" if it is within this threshold. -T, --thresholdRate Rate threshold for detecting change. When the difference ratio of the image is larger than the set rate detects the change. Applied after matchingThreshold. 0 by default. -S, --thresholdPixel Pixel threshold for detecting change. When the difference pixel of the image is larger than the set pixel detects the change. This value takes precedence over thresholdRate. Applied after matchingThreshold. 0 by default.
https://github.com/reg-viz/reg-cli?tab=readme-ov-file#options
様々なパラメータを試した結果、最終的に --matchingThreshold 0.011 --thresholdPixel 100 という値に落ち着きました。閾値調整の基本姿勢としては、まず 0 に近い値(高感度)から始め、偽陽性(変更と検出されたくないのに検知される)が目立つようであれば徐々に値を上げていく方法が効果的です。これは偽陰性の方が発見・対応が難しいためです。
また、開発者のメンタルモデルとして、画像差分を画面全体の割合で捉えるよりも、変更部分のピクセル数で判断することが多いと観察されたため、割合ベース(thresholdRate)ではなくピクセル数ベース(thresholdPixel)での検知手法を採用しました。大きな画面であっても、開発者は画面全体ではなく構成コンポーネント単位で変更を認識しているためです。
VRT の承認プロセスとマージブロックの連動
プルリクエストのマージ制御には、GitHub の Branch protection rule にある「Require status checks to pass before merging」機能が効果的です。これにより、ステータスチェックとマージ条件を連動できます。
ただし、VRT を実施するワークフローでマージブロックを直接制御しようとすると問題が生じます。承認プロセスという強制的に成功とみなす「裏口的な手法」がワークフロー自体に混入してしまい、全体の見通しが悪くなりメンテナンス性を損なう恐れがあります。そこで私たちは、マージブロック制御を別の仕組みとして実装しました。
具体的なアプローチは以下の通りです:
- VRT で差分検出時、プルリクエストに専用のラベルを付与
- このラベルが存在する場合、ステータスを pending に設定
- ラベルが外れた場合、ステータスを success に更新
このマージブロック制御には on.pull_request.types: [labeled, unlabeled] イベントを活用し、ラベルの付け外しという単純な操作で VRT の承認管理を実現しています。
また、開発者からのフィードバックを受け、VRT 承認待ち状態ではコミットステータスを「failure」ではなく「pending」に設定するよう改善しました。pending 状態はブラウザタブで以下のように表示され、対応状況が視覚的に把握しやすくなっています。
| ステータスが失敗の見え方 | ステータスがペンディングの見え方 |
|---|---|
| |
|
これは最新のコミットに対する status を制御することで実現できます。GitHub の REST API(Create a commit status)を使って、VRT の承認状態に基づいて最新コミットのステータスを操作し、「Require status checks to pass before merging」で設定している job 名のコンテキストで付与することでブロック制御と両立させました。
この一連の処理フローは以下のとおりです:
- VRT で差分検出時、GitHub App のトークンを使用6してプルリクエストにマージブロック用ラベルを付与
- このラベルが付いている場合、最新コミットのステータスを特定の job 名のコンテキストで pending に設定
- VRT の変更を承認する場合、VRT 結果を案内するコメント内のチェックボックスにチェックを入れる(issue_comment.types: edited イベントが発火)
- コメント編集イベントをトリガーに、承認コメントであることを確認してマージブロック用ラベルを除去
- ラベル削除により、最新コミットのステータスを job 名のコンテキストで success に更新
- これでマージが可能になる
この仕組みにより、意図しないビジュアル変更のマージを防止しつつ、意図的な変更は簡単な承認プロセスで対応できるワークフローを実現しました。
まとめ
カイポケリニューアルのプルリクエストに対する CI 処理で、Storybook の配信から VRT の実行、テストレポートの配信までを 5 分程度に収めるために実施した主な最適化ポイントは以下の通りです:
ランナー選定の最適化:
- AWS CodeBuild hosted runner の採用によりコスト効率を改善
- 適切なマシンスペックの選定により VRT のパフォーマンスを大幅に向上
ジョブ構成の最適化:
- 多数の小さなジョブへの分割ではなく、単一の大きなジョブ内での並列処理を選択
- 並列実行可能なステップを特定し、効率的な実行パイプラインを構築
配信基盤の効率化:
- Private GitHub Pages を活用した認証付き配信の簡素化
- アーティファクトサイズの厳格な管理による安定したデプロイ
- デプロイメントリクエストの効率的な制御
VRT の開発者体験向上:
- Chromatic に近い使用感を再現し、移行コストを最小化
- アニメーションなどの不安定要素に対する堅牢な対策
- 効果的な判定基準の設定による誤検出の最小化
- 直感的な承認プロセスとコミットステータスの連動
これらの最適化により、577 個の Story と 969 枚のスナップショットを扱う大規模 VRT プロセスを約 5 分で完了できるようになりました。この改善は開発サイクル全体の効率化に貢献し、開発者体験の向上にもつながっています。 また、この移行を通して VRT や Storybook を配信するのに支払っていた月々の支払いを 90% 近く削減できました。
- 内製している UI コンポーネント群。Chakra UI をベースに作っている。↩
- PDF の生成に利用している帳票発行エンジン。 https://denkiyagi.jp/yagisan-reports/↩
- VRT をする際の比較元となる main branch のスナップショットを事前に S3 にアップロードしています。↩
- https://github.com/orgs/community/discussions/67654#discussioncomment-8038649 が参考になると思います↩
- FontFace API の loaded プロパティを活用し、プロジェクトで使用するすべてのフォントを列挙して、それぞれの loaded() の Promise が解決するまで待機する実装としました。参考: https://developer.mozilla.org/en-US/docs/Web/API/FontFace/loaded↩
- GitHub App のトークンを使用する理由はデフォルトトークンだとイベントが後続のワークフローにフックされない GitHub Actions の仕様のためです。↩
