🤼

VRTのブレに立ち向かう

に公開

こんにちは Social PLUS のフロントエンドエンジニアのまっくすです。

弊社の開発環境の課題であった「VRTのブレ」に立ち向かった話を共有したいと思います。

Storybook v6+Storycap 環境で頻発していた VRT のブレに対して

  • MSW でダミー画像をモック化
  • storycap から storycap-testrun への移行

で対策しました。撮影時間はやや伸びましたが、レビュー負荷が大幅に下がり、開発者体験が確実に向上したので、その手順とポイントを共有します。

私たちと同様に、VRTのブレの悩みを少しでも解消することができれば幸いです!

用語の整理

  • VRT (Visual Regression Test)
    スクリーンショットを比較して UI の意図しない変化を検出するテスト手法。

  • Storycap
    Storybook の各ストーリーのスクリーンショットを撮影するツール。

  • storycap-testrun
    Storycap の後継。ページが静止するまで待機し、再撮影で差分を確定させるスクリーンショットツール。

  • reg-suit
    撮影したスクショを保存し、前回画像と比較レポートを生成する差分管理フレームワーク。

  • ブレ
    UI 変更とは無関係の要因でスクリーンショットが微妙に変わる現象 。スクリーンショットに差分が出るため、VRTが失敗する。レビュー負荷や自動マージ阻害の原因になる。

弊社の環境

弊社はTurborepoを利用してReactNextを利用した複数アプリをモノレポ環境で開発しています。
スクリーンショット関連のツールのバージョンは以下のとおりでした。

  • Storybook: v6
  • スクリーンショット撮影: Storycap v4
  • VRTツール: reg-suit
アプリ名 VRTのブレ数
アプリ1 5〜10件程度
アプリ2 30〜60件程度
アプリ3 40〜70件程度
アプリ4 ほぼ発生しない
アプリ5 ほぼ発生しない

UIに変更のないプルリクエストでも、100~120個のストーリー(全体1500件中)がブレていました😇
VRT のブレが多発しており、チームの開発体験・運用に支障をきたしていました。

ブレが多いことで発生していた問題

UI差分がノイズに埋もれる

小さな変更でも多数のストーリーで差分が出るため、本来確認すべきUIの変更が埋もれてし待っていました。プルリクエストをレビューする際に差分が多く、見ていて辛い状況。

ライブラリ更新が困難になる

Renovateによる依存パッケージの自動アップデートが、VRTの差分検出によってブロックされるため、自動マージが機能せず、更新作業が滞っていました。

ブレる原因と考察

1. ダミー画像の fetch 失敗

Storybook ではプレースホルダー画像として https://placehold.jp/ を読み込んでいました。外部サービスにリクエストを投げるため、

  • CI では同時に何中枚も画像を取得しようとする
  • レート制限や通信の揺らぎで取得に失敗することが多い

という状況が起きます。

そこで、既存の MSW に「モック画像へ置き換えるハンドラー」を追加し、placehold.jp へのリクエストをすべてモックに差し替える方針を採用しました。

具体的にはサイズに応じた SVG を即時生成して返却することで、外部ネットワークへの依存を排除し、画像取得のゆらぎを根本から断ち切れると考えていました。

2. 非同期処理中や再レンダリング中にスクリーンショットが撮られる

Puppeteer + Storycap は、DOMContentLoaded のタイミングでスクリーンショットを実行する実装が採られています。(参考記事

DOMContentLoaded は HTML の解析と同期/defer スクリプトの実行完了 を示すだけであり、画像読込や外部 API 取得などの非同期処理は待機しないようです(参考ブログ
この段階では、たとえば次の処理がまだ進行中のことがあります。

  • API 呼び出し後のデータ反映
  • React コンポーネントの再レンダリング
  • アニメーション

弊社ではスクリーンショットを撮影する際は、アニメーションを無効にしていたものの、Storybook の play function でfetchuserEvent を行なっているストーリーも多いので、DOMContentLoaded 直後に撮影すると途中状態をキャプチャしてしまい、ブレの温床になります。
storycap-testrunstorycap をベースにした後継ツール(開発元も同じ)で、以下のような機能があります。

  1. ページが静止してから撮影する
    最初のレンダリングが終わった後、MutationObserver を使って DOM に変化がなくなるまで監視し、動きが収まったことを確認してからスクリーンショットを実行します。これにより、play function 内の非同期処理や再レンダリングが完了した状態で撮影できます。

  2. 同一フレームを再撮影し差分チェックする
    撮影直後に もう一度同じフレームをキャプチャし、ハッシュを比較。わずかでも差があれば自動でリトライします。連続 2 回で一致すれば「問題なし」と判定するため、タイミング次第で起きるブレがほぼ解消されます。

これらによってブレがなくなることを期待していました。

主な原因と考察は以上です。
ここからは、実際に行った対策について解説していきます!

対策1: ダミー画像をモック化する

プレースホルダー画像の取得失敗を防ぐため、MSW にダミー画像を返すハンドラーを追加し、https://placehold.jp へのリクエストをローカルで完結させる方針にしました。たとえば https://placehold.jp/240x200.png へのアクセスがあると、外部サイトに接続せずに SVG を返します。これにより ネットワーク依存を排除し、VRT のブレを抑えられます。

handler.ts

import { rest } from 'msw';
import { generatePlaceholderSVG } from './dynamicPlaceholder';

/**
 * placehold.jp のダミー画像を返すハンドラー
 * 幅x高さ.png の形式に対応(例: 48x48.png, 300x300.png など)
 * 動的SVG生成で任意サイズに対応
 *
 * example:
 * https://placehold.jp/300x300.png
 */
export const placeholdHandlers = [
  rest.get('https://placehold.jp/:filename', async (req, res, ctx) => {
    const filename = req.params.filename as string;

    // パス名から画像サイズを抽出
    const match = filename.match(/^(\d+)x(\d+)\.png$/);

    // https://placehold.jp/300.png のようなファイル名は弾く
    if (!match) {
      return res(
        ctx.status(400),
        ctx.json({
          error: 'Invalid image URL pattern',
          message: `URL pattern '${filename}' doesn't match expected format`,
          filename,
          expectedFormat: '{width}x{height}.png',
        }),
      );
    }

    const width = Number(match[1]);
    const height = Number(match[2]);

    if (!(width > 0 && height > 0)) {
      return res(
        ctx.status(400),
        ctx.json({
          error: 'Invalid image size',
          message: `Invalid image size '${width}x${height}'`,
        }),
      );
    }

    return res(
      ctx.status(200),
      ctx.set('Content-Type', 'image/svg+xml'),
      // 動的に生成したSVGを返す
      ctx.body(generatePlaceholderSVG(width, height)),
    );
  }),
];

dynamicPlaceholder.ts

/**
 * SVG形式のプレースホルダー画像を動的生成する
 */
export const generatePlaceholderSVG = (
  width: number,
  height: number,
): string => `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#cccccc"/>
<text x="50%" y="50%" font-family="sans-serif" font-size="16px" text-anchor="middle" dy=".3em" fill="#666666">${width}×${height}</text>
</svg>`;

利用側は MSW のハンドラーの中に追加するだけです。

export const setupMocks = async (): Promise<
  ServiceWorkerRegistration | undefined
> => {
  worker = setupWorker(
		// ...handlers
    ...placeholdHandlers, // 👈 mswのhandlersに追加するだけ
  );
  return worker.start({ onUnhandledRequest: 'bypass' });
};

このように画像がモック化されます(VRTの画像です) 🎉

そのほかのアイディアとしては以下のようなものがありました。

  1. ローカル画像を全パターン用意し Base64 にエンコードしてしてモック化する(参考ブログ
    画像の全パターンを用意するのは面倒だったのでなしになった

  2. Canvas でダミー画像を生成するアプローチ(参考ブログ
    実装が複雑になる割に効果が同じため、シンプルな SVG 生成を選択。

このモックによりダミー画像の fetch 由来のブレは 0 になり、スクリーンショット取得にかかる時間も短縮されました 🎉

画像のモック化の話は以上です。

次は storycap から storycap-testrun に置き換えた話をしていきます。

対策2: storycap から storycap-testrun に乗り換える

すんなり移行できるかと思ったのですが、そもそもの Storybook のバージョンが古いという問題が立ちはだかりました。storycap-testrun の依存パッケージは Storybook v7 系以上を前提としており、v6 のままでは依存解決ができません。そこでまず v6 → v7 へのアップデートを実施しました。

storybook v6 → v7 へのアップデート

1. 公式 codemod を試行

storybook の公式が出している codemod は CSF 2→3 など主要変更を自動変換してくれます。しかし、codemod を利用してアップデートを試みたのですが、廃止予定の機能でメンテナンスされていないファイルは自動で修正されず、storybook のビルドが通らない状況でした。

2. Roo Code による一括修正案を断念

弊社は Roo Code を利用できる環境なので、アップデートできないファイルを Roo Code に依頼して修正してもらうということを画策したのですが、以下の点で Roo Code を利用した修正は断念しました。

  • 1コミットに複数の種類の変更が混ざりレビューが辛い。スコープを絞ってみたが、気を利かせて色々修正してくれるいい子ではあります。
  • 1つずつファイルを見に行くので遅い&コストがかかる

3. 使い捨て codemod を自作

そこで 変更点を一点に絞った小型 codemod を自作し、局所的に差分を当てる方法を選択しました。コミット単位で差分が明確になり、レビューも容易です。変更点が明確になり(のちに消すがコミット履歴には残る)コミットごとに差分を追いやすい状態になりました。
たとえば component プロパティを自動付与する codemod は次のようなプロンプトから生成しています。

Storybook の古い形式のファイルがエラーになるので、修正したいと思います。*.stories.tsxexport default {} のところに、component プロパティが入っていないので、component: Component のような指定を追加します。ここで指定するコンポーネントの名前は、ファイル名に対応しています。例えば、FooBar.stories.tsx であれば components: FooBar となります。

まずはこの変更を行う codemod を作成してください。

storybook v7 にアップデートが完了してめでたく storycap-testrun を導入できる状態になったのですが、導入した際、複数のポイントで詰まりましたので、その点も共有します。

storycap-testrun に移行する

移行自体はとてもあっさり完了しました。やったこととしては、test-runner.ts を作成するのとスクリプトを書き換えるだけでした。storycap-testrun のオプションを使ってチューニングせずとも、ブレが大幅に減る点はとても魅力的ですね😊

  1. .storybook/test-runner.ts を追加
import { getStoryContext, type TestRunnerConfig } from '@storybook/test-runner';
import { screenshot } from 'storycap-testrun';

const config: TestRunnerConfig = {
  async postVisit(page, context) {
    // ストーリーの viewport パラメーターをそのまま Playwright に反映
    const storyContext = await getStoryContext(page, context);
    await page.setViewportSize(storyContext.parameters.screenshot.viewport);

    await screenshot(page, context, {
      output: { dir: 'REG_SUIT_DIR' },
    });
  },
};

export default config;

ハッシュ比較回数や待機時間などオプションはありますが、デフォルトのままで十分安定しました。

  1. package.json にスクリーンショットのスクリプトを追加
{
  "scripts": {
    "screenshot:server": "pnpm dlx http-server storybook-static --port 6006 --silent",
    "screenshot:wait-on-server": "pnpm wait-on tcp:6006",
    "screenshot": "pnpm dlx concurrently --prefix '[{time}] {name}:' --timestamp-format 'yyyy-MM-dd HH:mm:ss' -k -s first -n 'Server,Screenshot' 'pnpm screenshot:server' 'pnpm screenshot:wait-on-server && pnpm test-storybook --verbose'"
  }
}

ちょっと長いのですが、やっていることはシンプルです。
concurrently で ①サーバー起動 ②サーバー待機+test-storybook を並列実行しています。concurrently を利用するのは公式ドキュメントのレシピに載っています。

pnpm dlx concurrently --prefix '[{time}] {name}:' --timestamp-format 'yyyy-MM-dd HH:mm:ss' -k -s first -n 'Server,Screenshot'

--prefix '[{time}] {name}: --timestamp-format 'yyyy-MM-dd HH:mm:ss'のオプションついては、各 Story のスクリーンショット撮影時間を計測するために入れています

  • screenshot:server で storybook-static ディレクトリにビルドしてある Storybook をローカル配信する
  • screenshot:wait-on-server がポート 6006 のオープンを検知した瞬間、test-storybook を起動。
  • pnpm test-storybook --verbose' コマンドでスクリーンショットを撮影実行
  • --verbose は 各ストーリーの撮影結果と所要時間を表示するために入れています

ハマったところ

1.viewport を個別に指定できない

storycap 時代では preview.tsx でデフォルトの viewport を指定し、個別に viewport を指定したい場合はストーリーのパラメーターに viewport を設定する運用でした。
しかし、 storycap-testrun は各ストーリーの viewport 自動では読み込まれないため、 代わりに test-runner.ts 内で設定を拾って page.setViewportSize() に渡す必要がありました。

実装サンプルはこんな感じ

ストーリー

export const HogeHoge: StoryObj<typeof Hoge> = {
  parameters: {
    screenshot: {
      viewport: {
        width: 800,
        height: 1200,
      },
    },
  },
};
  • ストーリーごとに parameters.screenshot.viewport を持たせる
  • 値を指定しない場合は preview.tsx のデフォルトが適用される

test-runner.ts

const config: TestRunnerConfig = {
  async postVisit(page, context) {
    const storyContext = await getStoryContext(page, context);
    await page.setViewportSize(storyContext.parameters.screenshot.viewport);

    await screenshot(page, context, {
	    // ...
    });
  },
};
  • getStoryContext(page, context) で現在のストーリー情報を取得する
  • storyContext.parameters.screenshot.viewport から width / height を取得する
  • page.setViewportSize() に渡して撮影を実行する

2.Playwright バージョン不一致による storycap-testrun 起動エラー

storycap-testrun は内部で Playwright 1.52.0 を呼び出します。一方、リポジトリには @playwright/test 1.37.1 がすでに入っており、ブラウザのバイナリを要求するバージョンが食い違う状態でした。

このまま pnpx playwright install --with-deps を実行すると、下記のようなメッセージが出てブラウザの起動に失敗します。

╔═════════════════════════════════════════════════════════════════════════╗
║ Looks like Playwright Test or Playwright was just installed or updated. Please run the following command to download new browsers:              ║
║                                                                         ║
║     pnpm exec playwright install                                        ║
║                                                                         ║
║ <3 Playwright Team                                                      ║
╚═════════════════════════════════════════════════════════════════════════╝ 
Failed to launch browser.

一時凌ぎでdevDependenciesplaywright@1.52.0 を追加することで、強引に依存関係を解決していました。

解決策:Playwright 入りの Docker イメージを使う

CircleCI の executor を、Playwright 1.52.0 がプリインストールされた公式イメージに差し替えます。これによりブラウザを別途インストールせずにテストを開始でき、バージョン衝突も発生しません。

executors:
  # ... other executor
  playwright-executor:
    docker:
      - image: mcr.microsoft.com/playwright:v1.52.0-noble

	storycap_testrun
    executor: playwright-executor
		# ... storycap-testrun の設定
		

VRT のブレに立ち向かった結果(まだまだ途中経過)

storycap から storycap-testrun への移行と Storybook v7 へのバージョンアップを実施しました。移行後のブレについては以下のとおりです。

アプリ名 移行前のブレ数 移行後のブレ数
アプリ1 5〜10件程度 0件 🎉 (storycap-testrun導入)
アプリ2 30〜60件程度 0〜5件程度 🎉(storycap-testrun導入)
アプリ3 40〜70件程度 未対応(変化なし)
アプリ4 ほぼ発生しない 未対応(変化なし)
アプリ5 ほぼ発生しない 未対応(変化なし)

アプリ1とアプリ2で顕著な改善が見られました。特にアプリ2は劇的に安定化しています。
特にアプリ2でブレが出なくなった時はとってもハッピーでした。

一方で storycap-testrun への移行で生じた課題もあります。

CI 上でのスクショ撮影にかかる時間が遅くなった

実行時間はおよそ1分49秒(約27%)長くなっています。

ジョブ名 期間 実行回数 実行時間 (p95)
storycop 60日 2725 6分50秒
storycap-testrun 2週間 466 8分39秒

まだ試していませんが、storybook-testrunner は --shard オプションがサポートされているため、並列化することで実行時間は短くできそうです。

まとめ

CI 全体では撮影時間が約 27 % 伸びましたが、開発者体験は向上し、VRTの差分が確認しやすくなったと好評でした。まだ storycap-testrun に移行できていないアプリにも導入するのが楽しみです。

この記事がVRTの差分で悩んでいる方に参考になれば嬉しいです。

最後まで読んでいただきありがとうございました!

Social PLUS Tech Blog

Discussion