🍀

Storybook Test runnerを活用した品質向上戦略とVRTの安定化

2025/02/12に公開2

はじめに

Visual Regression Test (以下VRT) やっていますか?

ありがちなケースとして VRT が安定せずにオオカミ少年化し、誰も差分のレポートを気にしなくなってしまって、CI のリソースやコストを食い潰すだけの仕組みになってしまうということがあると思います😇

この記事では VRT を安定化させるためのアプローチと、Storybook をベースにテストが実行できる Storybook Test runner 機能の活用法について紹介します!

https://github.com/reg-viz/reg-suit

VRTを安定化させるアプローチ

base64エンコードした画像の利用

Storybook で表示する画像の例として以下があります。

  • ローカルのファイル
  • クラウドストレージ上のファイル
  • Placeholder.com などのダミー画像生成サービス

VRT 用のスクリーンショット撮影時にこれらの画像のロードが完了していないことが原因で VRT 差分が発生してしまうことに…🥹

そこで、画像読み込みのオーバーヘッドをなくすためにbase64エンコードした画像を利用することで安定化させました!

  • Data URI で src 指定 <img src="data:image/png;base64,foo..." />
  • なるべく曲線部分がない画像を利用
  • reg-suit の enableAntialias を有効化
    • 画像のアンチエイリアス部分の差分を無視させるオプション


使用する画像の例

別の方法として、canvas の toDataURL() を使い動的にサイズを決定した画像 URI を使う方法などもあります。

画像読み込みの一括モック

前述の画像 URI を定数化した上で Storybook 上の全ての img の src に指定してもよいのですが
スクリーンショット撮影時以外は別の画像を使った方が Storybook の見栄えもよいですし、特定の画像が期待した通りに表示されるか確認するのも難しくなります。

そこで MSW を使ってスクリーンショット撮影時のみダミー画像でモックさせます。
https://mswjs.io/

MSW と言えば、テスト実行時や Storybook 環境でデータフェッチをモックするためのツールとして広く知られていますが、実は画像ファイルなどのリソース取得の通信もモックできます!

.storybook/preview.ts
import { HttpResponse, http } from 'msw';

const dummyImageBuffer = Buffer.from(base64EncodedDummyImage.split(',')[1], 'base64').buffer;

export const parameters = {
  msw: {
    handlers: {
      // スクリーンショット撮影時のみに適用
      images: process.env.IS_SCREENSHOT
        ? [
            http.get('https://storage.googleapis.com/*', async () => {
              return HttpResponse.arrayBuffer(dummyImageBuffer);
            }),
            http.get('https://via.placeholder.com/*', async () => {
              return HttpResponse.arrayBuffer(dummyImageBuffer);
            }),
          ]
        : [],
    },
  },
};

上記のサンプルコードでは https://storage.googleapis.comhttps://via.placeholder.com への全ての GET 通信に対して base64EncodedDummyImage を返すようにモックしています。

URL のパターンから指定する以外にも、request.url の拡張子から判定することもできそうです。

このように、スクリーンショット撮影時は全ての画像ファイルをbase64エンコードされたダミー画像に差し替えることで VRT の安定性を爆上げさせることに成功しました👏

Storycap -> storycap-testrun への移行

reg-suit で VRT を実施する場合はスクリーンショットの撮影自体は別のツールを利用する必要がありますが、reg-suit に同じく reg-viz が提供している Storycap を採用するケースがほとんどではないでしょうか。
https://github.com/reg-viz/storycap

Storycap は Storybook からスクリーンショットを撮影するためのツールとして非常に優れており、手軽に VRT を始めるツールとしては最適だと思います。

しかし、比較的規模が大きく Story の数も多いプロジェクトでは (おそらく Puppeteer に起因する) よくわからないエラーで失敗してしまったり、React Component のレンダリングを正確に待てない問題などが散見されていました。

そこで、Storycap の代替となる storycap-testrun へ移行したところ結果的に格段に VRT が安定しました!

Storycap と storycap-testrun の大きな違いは、Storycap が Puppeteer でクローリングするのに対し、storycap-testrun は Storybook Test runner (Playwright base) 上で実行される点です。

Storybook Test runner は .stories.tsx ファイルをテストファイルとみなし、Story のレンダリングと Play function を実行しチェックしてくれるツールです。
test の実行・レポートには Jest が使用されます。
(Storybook v8.3で Vitest にも対応したらしいです。)
https://storybook.js.org/docs/writing-tests/test-runner

Play function さえ定義しておけばそのままテストケースになります。

Storybook Test runner には Story rendering のライフサイクルの hooks が記述でき
postVisit で storycap-testrun を実行することで Story がレンダリングされた状態でスクリーンショットが撮影されることを保証します。

.storybook/tesr-runner.ts
import { screenshot } from 'storycap-testrun';
import { type TestRunnerConfig } from '@storybook/test-runner';

const config: TestRunnerConfig = {
  async postVisit(page, context) {
    await screenshot(page, context, {
      /* options */
    });
  },
};

export default config;

storycap と同様の Chrome DevTools Protocol を通じたメトリクス監視に加え、複数回撮影した上で画像ハッシュの同値チェックなどの安定化のための仕組みがあります。

以前は不安定だったアニメーション系のライブラリの挙動についても安定したように感じます。

その他の storycap-testrun の詳細については作者の @wadackel さんの記事に委ねたいと思います🙏
https://zenn.dev/knowledgework/articles/297ccfb866a5b5#storycap-testrun

(storycap-testrun の mask 機能を使えば前述の msw による画像モックは不要なのではということに執筆しながら気付いてしまったので、そのうち試してみたいです。)

まだ Storybook Test runner および storycap-testrun を導入していない場合は移行をお勧めします!

Storybook Test runner の活用

ランタイムエラーの検知

Storybook Test runner は Playwright を使っているので、Playwright の API を利用することが可能です。
https://playwright.dev/

on('console') event でエラーを検知したときに test を落とすことで、unit test や Linter などでは検知しきれない、ランタイムのみで発生するエラーやブラウザによる警告をチェックすることができます👮

.storybook/tesr-runner.ts
import { type TestRunnerConfig } from '@storybook/test-runner';

const config: TestRunnerConfig = {
  // レンダリング実行前に実行させるのでpreVisitを使う
  async preVisit(page) {
    page.on('console', async (msg) => {
      if (msg.type() === 'error') {
        const messageText = msg.text();
        // Storybook console error! please check console on your browser.
        throw new Error(messageText);
      }
    });
  },
};

export default config;

やや強引ですが、Error を throw してテストを落としています。

この仕組みによって Storybook のメンテナンス性・治安も向上しました🛠️

Storybook Coverage

.stories.tsx ファイルを起点とした、Component の coverage を収集します。

全てのファイルを対象とするのではなく、変更があった Component (.tsx) ファイルに関連するファイルのみを CI でチェックしています。

この仕組みによって新規に Component を追加した際は必ず Storybook 上で確認できることを担保させます🔥

必ずしも Component と Story が1対1で存在する必要はなく、先祖の Component の Story から確認可能であればパスします。

Storybook の coverage が増加すればその分 VRT で担保できる範囲も広くなります。

Coverage の計測

test-storybook cli のオプション --coverage を渡すことで取得できるのですが、後述の coverage 閾値チェックや report 出力のための細かい設定ができなかったため、--coverage は使用せずに、環境変数で有効/無効を切り替え coverage data の収集のみ実施させます。

jestPlaywright.saveCoverage() はページ (Story) ごとに coverage を追跡し、保存してくれます。

.storybook/test-runner.ts
const config: TestRunnerConfig = {
  async postVisit(page) {
    if (process.env.CI_COLLECT_STORYBOOK_COVERAGE === 'true') {
      await globalThis.jestPlaywright.saveCoverage(page);
    }
  },
};

export default config;

--coverage オプションを利用しない場合、jest-playwright の collectCoverage を明示的に有効にする必要があります。
Storybook Test runner 実行時の Jest は test-runner-jest.config.js の設定が適用されるのでファイルがない場合は作成します。

test-runner-jest.config.js
const { getJestConfig } = require('@storybook/test-runner');

// The default Jest configuration comes from @storybook/test-runner
const testRunnerConfig = getJestConfig();

module.exports = {
  ...testRunnerConfig,
  testEnvironmentOptions: {
    'jest-playwright': {
      ...testRunnerConfig.testEnvironmentOptions['jest-playwright'],
      collectCoverage:
        process.env.CI_COLLECT_STORYBOOK_COVERAGE === 'true' ||
        testRunnerConfig.testEnvironmentOptions['jest-playwright'].collectCoverage,
    },
  },
};

Storybook が立ち上がっている状態で以下のコマンドを実行します。
pnpm exec の部分はお使いのパッケージマネージャーによって適宜読み替えてください。

CI_COLLECT_STORYBOOK_COVERAGE=true pnpm exec test-storybook \
  -- \
  --passWithNoTests \
  --findRelatedTests $TEST_TARGET_FILE_LIST

コマンド引数の説明です。

  • --: 以降に渡す引数は test-storybook ではなく、内部で Jest に渡されます。
  • --passWithNoTests: テスト対象のファイルが存在しない場合にエラーにならないようにする Jest のオプションです。
  • --findRelatedTests: 変更したファイルに関連するテストファイル (.stories.tsx) のみを実行対象にする Jest のオプションです。
  • $TEST_TARGET_FILE_LIST: git diff コマンド等で、差分がある .tsx ファイルをスペース区切りで取得しておきます。

このコマンドを実行すると、.nyc_output/coverage.json に coverage data が出力されます!

Coverage レポートの出力

Coverage のチェックとレポートの出力は istanbuljs/nyc を利用します。
Jest もデフォルトでは nyc が repoter に使われます。
https://github.com/istanbuljs/nyc

nyc の設定を nyc.config.js に記述します。

nyc.config.js
const defaultExclude = require('@istanbuljs/schema/default-exclude');

module.exports = {
  'per-file': true,
  branches: 0,
  lines: 0,
  functions: 0.1,
  statements: 0,
  include: ['**/components/**/*.tsx'],
  exclude: [...defaultExclude, '**/*.stories.tsx'],
};

まずは Component が Storybook から呼び出されるかのみを最低限チェックするための閾値 functions: 0.1, を指定しています。

レポートを出力するコマンドです。

pnpm exec nyc report \
  --reporter=text \
  --temp-dir .nyc_output \
  --report-dir coverage/storybook \
  $INCLUDE_OPTION \
  > coverage/storybook/coverage.txt

$INCLUDE_OPTION は差分がある .tsx ファイルを --include [ファイル名] の形式で取得しておきます。よしなにシェル芸などしてください。
Jest の --findRelatedTests とは形式が違うので注意します。

コマンドを実行すると coverage/storybook/coverage.txt にテキスト形式でレポートが出力されます。

次はこのレポートを action-coverage-report を使って Pull Request のコメントとして出力します。
https://github.com/fingerprintjs/action-coverage-report-md

他にもコメントとして出力する github action は存在するのですが、色々試した結果こちらが一番相性が良かったため採用しました。

- name: Prepare coverage report in markdown
  uses: fingerprintjs/action-coverage-report-md@v2
  id: storybook-coverage-markdown-report
  with:
    textReportPath: "coverage/storybook/coverage.txt"

- name: Create coverage report comment body
  id: create-coverage-report-comment-body
  # nyc --include option を使用した場合はファイルのurlが正確に生成されないため無理矢理リンクを非活性化
  run: |
    echo 'COVERAGE_REPORT_COMMENT<<EOF' >> $GITHUB_OUTPUT
    echo "${{ steps.storybook-coverage-markdown-report.outputs.markdownReport }}" | sed 's/http//g' | sed 's/\[NaN\]/[Storybook not found]/g' >> $GITHUB_OUTPUT
    echo 'EOF' >> $GITHUB_OUTPUT

あとは create-or-update-comment を使いコメントが存在しない場合は新規にコメントを追加、存在する場合はアップデートさせます。
細かい設定は割愛します。
https://github.com/peter-evans/create-or-update-comment

最終的にこちらの様な Coverage レポートが Pull Request にコメントされます!

どの .stories.tsx からも呼び出されない BarPage.tsx は Storybook not found と報告されています👮

Coverage チェック

最後に coverage が閾値を上回っているかチェックし、満たしていない場合は CI でエラーにします。

pnpm exec nyc report \
  --check-coverage \
  --reporter=text \
  --temp-dir .nyc_output \
  $INCLUDE_OPTION

$INCLUDE_OPTION はレポート出力時と同様です。

以上、変更があった Component ファイルに対し Storybook で確認可能かどうかをチェックしつつ coverage レポートを出力する方法でした!

今後やりたいこと

Storybook を起点とした様々なインテグレーションを追加したいです🔥
a11y testing, performance testing など…
開発者は Storybook さえ用意しておけば、追加でテストコードを書いたり人の手によるチェックを介することなく一定の品質が担保できる環境の実現を目指していきたいです!

一方で、CI の実行時間が増加するなどの課題もあります。
一般的な改善手法として並列化などもありますが、小手先のチューニングだけではなく、Storybook の粒度の最適化などの方針設計も見直していく必要があると考えています。

おわりに

Storybook Test runner は Playwright やライフサイクル hooks などによってインテグレーションを組み込みやすいので、ぜひ試して欲しいです。

Storybook への依存が強くなるが?という問いがありそうですが自分は以下のように考えます。

インテグレーションの選択肢が豊富な Storybook は現時点では強力なツールです。
結局は使い方次第で、Storybook を Component カタログとしてのみ利用するのはもったいないです!

  • フロントエンドのエコシステムは変わり続けるもの
  • その時・その環境に最適だと思うものを選択し続ける
  • いざとなったら捨てる勇気を持つ

これからも変わりゆくフロントエンド界隈の中で審美眼を磨き続けていきたいと思います👁️‍🗨️

Gaudiy Engineers' Blog

Discussion

まっくすまっくす

placeholder.comはサービスが停止しており、現在は倉庫会社のサイトになっています... 👀
https://placeholder.com/

サーバーにデータが残っているのか、一部の画像は利用できるものの、今後利用できなくなる可能性もあるので、類似するサービスに乗り換えた方がよさそうです 🙋‍♂️
一例ですが、https://placehold.jp/ は同じようなインターフェースで使えるので乗り換えが楽でした!

kotorikotori

placeholder.com 以前にアクセスできなくなって使うの辞めたのですが、一時的ではなく完全に終了してしまっていたのですね…

placehold.jp 初めて知りました!ありがとうございます☺️