はじめに
こんにちは!唐揚げ大好き森澤です。
参画中のプロジェクトにて、CIにVisual Regression Test(以下VRTと書きます)を組み込んでいるのですが、コード上で見た目の差分がないはずの時にも、差分ありの結果が出てしまうことがしばしばありました。
flakyな差分が頻発するようになると、
「これはflakyだからヨシ」
と段々と慣れてきてしまって、本来気づかないといけない差分を見逃してしまう可能性もあります。
flakyな差分によるノイズをなくして本当に気にすべき差分へ集中できるようにする、というのが今回の目的になります。
※「flaky」という表現について、この記事では以下の意味として使用します。
「flaky」= 不安定・気まぐれなという意味で、同じコードなのに、実行するたびに異なる結果が出てしまう差分
flakyな差分が発生する事例
同じstoryにも関わらず差分が出る原因としては、スクリーンショット撮影のタイミングが若干ズレるためのようです。
flakyな差分が出ているケースは、見かけた限りではほとんど play 関数があるstoryでした。
例えば以下のようなstoryです。
export const AccordionOpen: Story = {
name: "アコーディオンが開いている状態",
args: { ... },
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const accordionButtons = canvas.getAllByRole("button", { name: "開く" });
for (const button of accordionButtons) {
await user.click(button);
}
},
アコーディオンを全て開く処理が play 関数に書かれています。
実行結果によっては、開く処理が完了しきらない状態のスクリーンショットが撮られ、画像に差分が出るというような状態になっていました。
また、 play 関数の中で、スムーススクロールなどのアニメーションが発生する場合はよりflakyな差分が出やすかった印象です。
なぜstorycap-testrunなのか
storycap-testrun の特長については実装者ご本人様が書かれた以下の記事に詳しく説明があります。
Storybook Test ruuner で安定した Visual Regression Testing を行う
そのため、移行する決め手となった部分のみ簡単にまとめます。
まず、 storycap と storycap-testrun はどちらもstorybookのstoryのスクリーンショットを撮影できるツールですが、以下のような違いがあります。
storycap
- Puppeteer上で動作
- 単体のCLIツールとして動作
- Chrome DevTools Protocolを通じたメトリクス監視
storycap-testrun
-
@storybook/test-runner上で動作(JestとPlaywrightで動作) -
Test Hook APIで使用可能な
screenshot関数を提供 - Chrome DevTools Protocolを通じたメトリクス監視に加え、複数回撮影した画像のハッシュ値比較によるレンダリング内容の安定性チェック
storycap と storycap-testrun は動作環境が異なります。
storycap-testrun は @storybook/test-runner 上で動くため、postVisit hookを利用できる点がポイントになります。
postVisit hookは @storybook/test-runner のTest Hook APIで、storyのレンダリング完了後に実行されるhookです。
同ページに記載の内容より抜粋します。
When the test-runner executes, your existing tests will go through the following lifecycle:
- The setup function is executed before all the tests run.
- The context object is generated containing the required information.
- Playwright navigates to the story's page.
- The preVisit function is executed.
- The story is rendered, and any existing play functions are executed.
- The postVisit function is executed.
@storybook/test-runner のライフサイクルは上記で、play 関数実行より後に postVisit hookが実行される順序となっております。
play 関数実行を待つのであればflakyな差分に効果的なのでは、と考え移行に至りました。
移行手順
どのように移行したか、簡潔にまとめます。
ざっくり分けると4つの対応をしました。
- パッケージ追加
- package.jsonのスクリプトコマンドの変更
- .storybook/test-runner.tsの追加
- workflowファイルの変更
パッケージ追加
こちらのコマンドでインストール。
npm install --save-dev storycap-testrun @storybook/test-runner
package.jsonのスクリプトコマンドの変更
次に、package.jsonにスクリプトコマンドを変更します。
"scripts": {
"deploy:storybook:ci": "storybook build -o ./docs --test",
"test-storybook": "npx concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"npx http-server docs --port 6006 --silent\" \"wait-on tcp:6006 && test-storybook\"",
}
deploy:storybook:ci
storybookのビルドコマンドをciで実行する用に用意しています。
-oは出力先ディレクトリです。
--testオプションをつけると、不要な機能を削除しテスト向けにビルドを最適化してくれます。
スクリーンショット用には--testオプションつけても支障なかったのでつけています。
test-storybook
concurrentlyで以下の作業を並列化しています。
- localhost:6006にビルドしたstorybookをホスティング
- test-storybookコマンドの実行
.storybook/test-runner.tsの追加
次に @storybook/test-runner のconfigファイルを作成します。
postVisit hook の中に screenshot 関数を設定していきます。
import { getStoryContext, type TestRunnerConfig } from "@storybook/test-runner";
import path from "path";
import { screenshot } from "storycap-testrun";
const config: TestRunnerConfig = {
async preVisit(page, context) {
// ビューポートの設定
// 必要があればここで出し分けする
page.setViewportSize({ width: 375, height: 812 });
},
async postVisit(page, context) {
// storycap-testrunで提供されるスクリーンショット実行関数
await screenshot(page, context, {
// flaky対策のオプション
flakiness: {
metrics: {
enabled: true,
retries: 1000, // メトリクス監視中の再試行回数。
},
retake: {
enabled: true,
interval: 100, // リテイクの周期。
retries: 10, // リテイクの回数。
},
},
});
},
tags: {
// tags: ["skip"]を持つstoryはスクリーンショット(storybook-testrunnerのテスト)をスキップする
// 重すぎてタイムアウトするものなどを指定するのが良いかも
skip: ["skip"],
},
};
export default config;
screenshot 関数にflaky対策のオプションを設定できます。画像を安定させる上で強力なポイントになるでの是非活用しましょう。
screenshot 関数のオプションについては公式ドキュメントをご確認ください。
tagsにskip設定をしておくと、storyに以下のような記載をすることでそのstoryはスクリーンショットをスキップします。
export const Hoge: Story = {
// ...
tags: ["skip"],
};
大量のデータを表示するstoryなどはどうしても重かったり安定しない場合が多いため、適宜スキップすると良いと思います。
workflowファイルの変更
deploy:storybook:ci(storybookのビルド)
↓
test-storybook(スクリーンショット)
↓
reg-suit(比較&結果出力)
の順で実行するように設定します。
jobs:
reg-suit:
name: Run Reg-suit
runs-on: [self-hosted, normal]
container:
image: mcr.microsoft.com/playwright:v1.53.1-jammy
outputs:
hash: ${{ steps.storybook-static-hash.outputs.hash }}
steps:
# 中略(nodeやnpmなどのセットアップ)
- name: build storybook
run: npm deploy:storybook:ci
- name: Uninstall unnecessary font
# Playwrightの依存関係の定義にfonts-wqy-zenheiが含まれている。このフォントが日本語フォントより優先される。より自然な日本語フォントで描画するためにアンインストール。
run: apt-get purge -y fonts-wqy-zenhei && apt-get clean && rm -rf /var/lib/apt/lists/*
- name: run test-storybook
run: npm test-storybook
- name: run reg-suit
run: npm reg-suit
storycap-testrun移行後も残るflaky差分について
移行後も、一部のstoryに関しては、まだflaky差分が発生していました。
該当のstoryを見ると、スムーススクロールが発生するstoryになっていました。
スムーススクロール完了前にスクリーンショット撮影されてflaky差分が発生しているのではないかと仮定し、スムーススクロールが発生しないように対策しました。
以下の処理を preVisit 関数に挟み、test-storybook 実行時はスムーススクロールを即時スクロールに上書きしています。
/**
* test-runner上でスクロールをスムーススクロールではなく、即時スクロールにするための関数
* スムーススクロールを無効化することで、スクリーンショットの安定性を向上させる
*/
const smoothScrollToInstant = () => {
const originalScrollIntoView = Element.prototype.scrollIntoView;
const originalWindowScrollTo = window.scrollTo;
const originalElementScrollTo = Element.prototype.scrollTo;
// スムーススクロールを無効化するために、scrollIntoViewやscrollToの引数にbehavior: "instant"を設定する
Element.prototype.scrollIntoView = function (
arg?: boolean | ScrollIntoViewOptions,
) {
if (typeof arg === "object" && arg !== null) {
const options = {
...arg,
behavior: "instant",
} satisfies Readonly<ScrollIntoViewOptions>;
return originalScrollIntoView.call(this, options);
}
return originalScrollIntoView.call(this, arg);
};
window.scrollTo = function (
optionsOrX?: ScrollToOptions | number,
y?: number,
) {
if (typeof optionsOrX === "object" && optionsOrX !== null) {
const options = {
...optionsOrX,
behavior: "instant",
} satisfies Readonly<ScrollToOptions>;
return originalWindowScrollTo.call(window, options);
}
return originalWindowScrollTo.call(window, optionsOrX, y);
};
Element.prototype.scrollTo = function (
optionsOrX?: ScrollToOptions | number,
y?: number,
) {
if (typeof optionsOrX === "object" && optionsOrX !== null) {
const options = {
...optionsOrX,
behavior: "instant",
} satisfies Readonly<ScrollToOptions>;
return originalElementScrollTo.call(this, options);
}
return originalElementScrollTo.call(this, optionsOrX, y);
};
};
const config: TestRunnerConfig = {
async preVisit(page, context) {
page.setViewportSize({ width: 375, height: 812 });
// スムーススクロールを無効化
await page.evaluate(smoothScrollToInstant); // 追加
},
...
}
以上の対応で、ほとんどのflaky差分が解消されました。
現状、まだ1件ほどflaky差分が出ているのですが、まだそちらについては解消できておりません。
以下のようなstoryです。
-
play関数あり - 入力欄に不正な値があると吹き出しでエラー内容を表示する仕様のフォーム
- 全ての入力欄でエラー吹き出しを表示するstory
該当storyのスクリーンショットで、以下の2パターンの画像が生成されます。
A. 吹き出しまで描画完了している画像
B. 一部の吹き出しで、下部分のみが表示され途切れるような見た目の画像
これらの比較になった際、flaky差分として検知されています。
Aが左だとすると、Bは右のような見た目になっています。
| A | B |
|---|---|
![]() |
![]() |
Bは描画が完了しきっていないというより、あるはずの吹き出しが欠けてしまうような見た目なので、今までのflaky差分とは少し毛色が異なるように感じます。
原因は解明できていないですが、少なくとも当プロジェクトにおいては、移行後も完全にflaky差分が無くなる訳ではなかったというのが今回得られた知見でした。
移行結果
一部flaky差分が残るものの、移行が完了したので、移行前と移行後のVRTを比較してみましょう。
renovateのブランチでは、基本的に見た目の差分が入ることはほぼないだろうという理由で、renovate/* ブランチのVRTの結果を見ることにします。
gh run list コマンドを使います。branchでの絞り込みはワイルドカードが使用できなかったため、grepを用いてrenovate/* ブランチの結果だけを取得します。
移行前の任意の1ヶ月と、移行後の任意の1ヶ月で比較しました。
調整が色々入った移行直後は避けました。
gh run list --workflow="reg.yml" --created="2025-XX-XX..2025-XX-XX" --limit 1000 | grep "renovate/"
移行前と移行後の結果はどう変わったでしょう?
| 指標 | storycap | storycap-testrun | 比較 |
|---|---|---|---|
| workflow成功率 | 92.2% (59/64) | 96.2% (76/79) | +4% |
| workflow成功時の差分出現率 | 49.1% (29/59) | 7.9% (6/76) | -41.2% |
| 最大差分ケース数 | 2 | 1 | -1 |
サンプル数がそこまで多くないとはいえ、workflow成功率は維持もしくは向上したまま、workflow差分出現率が大きく減り、十分改善していると言えるのではないでしょうか。
パフォーマンスへの影響
ただし、プロジェクトによっては、storycap-testrun が必ずしも良いとは限りません。
storycapからstorycap-testrun へ移行して、VRTの実行時間は増えているからです。
1回のVRTでのスクリーンショット数は700程度で、
元々VRTにかかる時間は6分程度でした。(runner待機時間を除く。)
storycap-testrun へ移行後、チューニングはしたものの、10分程度かかるようになっており、実行時間が約1.7倍に増えています。
10分程度かかるworkflowが他にあったので、ボトルネックにはならないギリギリのラインということで、チームには許容していただきました。
なぜ実行時間が伸びたかについてですが、storycap がスクリーンショットのみ行うのに対し、storycap-testrun はスクリーンショット以外のこともしているためと考えられます。
@storybook/test-runner はstorybookのテストをするためのツールです。test-storybook 実行時、テストの中でスクリーンショット撮影を行います。
テストを設定しなければsmoke test(表示できるか確認するテスト)になりますが、そこにもやはりコストがかかります。
test-storybook で元々テストを行っているのであれば、テストしながらスクリーンショットも撮れるようになるので効率的に行うことができます。
しかし当プロジェクトでは現在storybookのテストを行っていないため、テスト部分が丸々オーバーヘッドになっているのが少しもったいない部分ではあります。
shard化といって、一つのworkflow中でrunnerを複数動かしてスクリーンショットを並列化することも@storybook/test-runnerのオプションで設定できるので、実行時間をもっと縮めることは可能です。
ただ、当プロジェクトではセルフホステッドランナーを使用しており、他のworkflowも同時に実行される関係で、runnerの枯渇を懸念してshard化はしておりません。
パフォーマンスチューニングのポイント
最後に、storycap-testrun への移行時のパフォーマンスチューニングでいくつか意識したポイントを挙げます。
-
--maxWorkersオプションを設定する - 重いstoryや不要なstoryをスキップする
- shard化を検討
--maxWorkers オプションを設定する
@storybook/test-runnerのオプションに --maxWorkers があります。
ワーカープールがテスト実行のために生成するワーカーの最大数を指定するオプションです。
ワーカーの数だけ、テスト実行およびスクリーンショットが並行で実行されます。
デフォルトでは、実行するrunnerの利用可能なCPUコア数 - 1が設定されます。
実際にデフォルトで動かしたらやたらと遅く感じたのですが、それは1つのワーカーで実行されていたためでした。
--maxWorkers オプションを1ずつ上げていって、以下の観点でちょうど良いところを見つけて設定しました。
- workflowが途中で落ちない
- 実行完了が早い
重いstoryや不要なstoryをスキップする
シンプルですが大事かなと思います。
大量のデータを表示するstoryなどはスクリーンショット撮影にも時間がかかります。
VRTで必要かどうか検討し、不要ならスキップしてしまえばその時間分短縮できます。
内容が重複するなどがあれば一つだけ残して他はスキップしてしまうのも良いと思います。
story自体が不要と判断できる場合はstoryごと消しましょう。
shard化を検討
これは前述した通り、当プロジェクトとしては導入していないですが、一つのworkflow中でrunnerを複数動かしてスクリーンショットを並列化することができます。
1つから2つにshard化したとしても、単純に実行時間が1/2になる訳ではない点に注意が必要です。
nodeやnpmなどのセットアップは各runnerで行う必要がありその部分は削れないため、などの理由が考えられます。
なので、
- runnerのリソースに余裕がある
- 実行時間を手っ取り早く短縮したい
などの場合は、shard化の検討もすると良いかもしれません。
まとめ
storycap から storycap-testrun への移行で以下の結果になりました。
- flaky差分が減少した
- workflow実行時間が伸びた
flaky差分と実行時間がトレードオフのような関係になっています。
これを踏まえると、特に以下のようなプロジェクトには移行をおすすめできると思います。
- 既にstorybookのテストを導入している
- VRTにおいてflakyな差分が目立つ
- VRT実行時間が伸びてもあまり問題にならない
- runnerリソースに余裕がある(shard化を検討できるため)
flaky差分が0になっていないことや、実行時間のチューニングなど、まだ課題はありますが、ここで一旦記事にさせていただきました。
また分かったことがあれば投稿します!ありがとうございました!

