🤖

Storybook Test ruuner で安定した Visual Regression Testing を行う

2024/02/19に公開

はじめに

株式会社ナレッジワーク Engineering Division のわだまる(@wadackel)です。

ナレッジワークの Web フロントエンド開発では、Storybook を活用したコンポーネント開発を行っています。そして、昨年末により良いコンポーネント開発の基盤整備を進めるべく @storybook/test-runner(以降 Storybook Test ruuner)を導入しました。導入目的としては主に、各 Story に対するスモークテスト、play 関数を活用したコンポーネントテストを行うことです。

さらに、ナレッジワークでは前述した通常のコンポーネントテストに加えて、reg-suitstorycap を利用した Visual Regression Testing(以降 VRT)を行っています。

これまでは Storybook を活用したテストは VRT のみだったのですが、新たにスモークテスト及びコンポーネントテストが加わることになりました。そうした状況の変化に合わせて VRT の構成自体も見直しを行いました。具体的には storycap の利用を廃止し、新たな Story のスクリーンショット撮影基盤を導入しています。

本記事では、ナレッジワークが採用している Storybook Test ruuner を用いた VRT 構成、安定した VRT を支援するライブラリと使い方について紹介できればと思います。

Storybook Test ruuner を用いた VRT の構成

まずはどのような構成で VRT を行っているか簡単にまとめます。

  • VRT の対象とするのは Storybook で動作する各種 Story
  • Story のスクリーンショット撮影には、後述する storycap-testrun を採用
  • 撮影されたスクリーンショット画像の差分検証、及びレポートに reg-suit を採用
  • VRT の実施は各 Pull Request 毎に GitHub Actions を用いて実行

storycapreg-suit の組み合わせはよくある構成だと思います。個人的にも過去使ってきた馴染みある構成です。多くの Story を扱う上での工夫についても過去アウトプットしてきました。

https://blog.wadackel.me/2022/vrt-performance-optimize/

ナレッジワークでは storycap の利用を廃止しましたが、reg-suit はそのまま利用しています。単に storycapstorycap-testrun に置き換えたのが、過去構成と現状での差分となります。

storycap-testrun

storycap の代替となる、Storybook Test runner で Story のスクリーンショットを撮影するための小さなライブラリを作成、公開しました。

https://github.com/reg-viz/storycap-testrun

名前から推察できるように storycap の兄弟的立ち位置のライブラリです。Orgization も reg-viz としています。storycap と同様にスクリーンショットを撮ることのみを機能として提供します。

storycap は対象の Storybook があればスタンドアロンで動作するツールであるのに対して、storycap-testrun は Storybook Test runner 上で実行されることを前提とします。また、Puppeteer と Playwright でそれぞれ内部的な依存も異なります。

storycap 自体の完成度はとても高く、基本的には満足度の高いツールであることには変わりありません。それでも新たにライブラリを作成し移行するに至った背景について、少し補足をする必要があります。

Srorybook Test ruuner を VRT に用いる利点

前述したように、storycap-testrun では Storybook Test runner に組み込まれることを前提としますが、これには大きな利点が存在します。

Storybook Test runner では、次のような設定を構成することができます。

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

const config: TestRunnerConfig = {
  // すべてのテストが実行される前に1度実行される関数
  async setup() {
    // ...
  },

  // Story が描画される前に実行される関数
  async preVisit(page, context) {
    // ...
  },

  // Story が描画された後に実行される関数
  async postVisit(page, context) {
    // ...
  },
};

export default config;

Story のテストが行われる各ライフサイクル毎に処理を割り込みすることができるようになっています。VRT の場合、Story の描画が完了した postVisit 内でスクリーンショットを撮影することが想定されます。これは 公式のレシピ としても紹介されているユースケースです。

この postVisit は、play 関数の実行も含めて正常に終了した際に実行されるライフサイクル関数です。これが Storybook Test runner を用いた VRT の大きな利点となります。

スクリーンショット撮影時に play 関数の待機が行えると、Storybook をブラウザから通常利用する場合と、VRT 実行時のスクリーンショット撮影のタイミングを一致させることが容易になります。storycap を利用しているケースでは、スクリーンショット撮影を正確に待機させるために waitFor パラメータを使った専用の実装を行うケースがそれなりにありました。

Storybook Test runner の基盤を活かし VRT が行えるようになると、コンポーネントテストや Story 確認を目的として play 関数を実装したものが、スクリーンショット撮影のタイミングとしても機能するようになります。

Button.stories.ts
/**
 * 対象コンポーネントをクリックしたら、ダイアログが立ち上がるような Story を想定
 */
export const Primary: Story = {
  // storycap で撮影待機する関数実装や delay が不要となる
  // parameters: {
  //  screenshot: {
  //    waitFor: async () => {/* ダイアログ待機の機構 */},
  //  },
  //},

  // play 関数さえあれば、通常のテスト + 撮影待機ロジックとなる
  play: async ({ canvasElement }) => {
    const canvas = with(canvasElement);
    // ボタンを見つけクリックする
    const button = await canvas.findByRole('button');
    await userEvent.click(button);
    // ダイアログが立ち上がることを確認する
    await waitFor(async () => {
      const dialog = await canvas.findByRole('dialog');
      expect(dialog).toBeVisible();
    });
  },
};

これは storycap をはじめ Lost Pixel などのツールでは現状満たせていない強みかなと思います [1]。Story のライフサイクルを理解しメンテナンスしている公式ならではの強みではないかなと感じています。

ライブラリの実装観点での利点

storycap では対象の Story を特定し、Puppeteer のブラウザインスタンスをプールした上で Story のクローリングを行うロジックを抱えます。この実装に多くの工夫がなされ storycap の高速性に寄与します。一方で、後方互換性を維持し Story のクローリングをするのはそれなりに大変です。

こうしたクローリング処理をまるごと Storybook Test runner 側に委譲し、スクリーンショットを撮ることに専念できることは、ライブラリ実装観点ではとても楽できるポイントでした。

storycap-testrun を用いた Story のスクリーンショット撮影

ここまで、Storybook Test runner を VRT に用いる利点について触れてきました。ここからは実際に storycap-testrun を利用する方法と、ライブラリとしての工夫や制限についてです。

前提として Storybook Test runner のページを参考に Test runner 自体のセットアップを行っておく必要があります。以降の手順は基本的なセットアップが行えている前提とします。

https://storybook.js.org/docs/writing-tests/test-runner

npm から storycap-testrun をインストールします。

$ npm install --save storycap-testrun

次に Storybook Test runner の設定ファイル .storybook/test-runner.ts の、postVisit 内部で screenshot 関数を呼び出します。

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

const config: TestRunnerConfig = {
  async postVisit(page, context) {
    // NOTE: 公式が提供する `waitForPageReady` の利用は不要
    await screenshot(page, context, {
      /* some options */
    });
  },
};

export default config;

最低限 Story のスクリーンショットを撮るだけならこれで基本的なセットアップは完了です。もし TypeScript を使っている場合、利用する Framework 毎の型定義に一手間を加えることで Type-safe な Parameters を利用することができます。

適当なパスに.d.tsを定義しておく
import type { ScreenshotParameters } from 'storycap-testrun';

// ここでは Framework に `@storybook/react` を使った例
declare module '@storybook/react' {
  interface Parameters {
    screenshot?: ScreenshotParameters;
  }
}

ここまで設定したら Storybook Test runner を実行することで、スクリーンショットが撮影され storycap に互換性あるファイルパスで画像が保存されていくはずです。

$ npx test-storybook

安定性に対する工夫

play 関数による待機を行う Story に関しては、ある程度実装者の手を通じて安定させることができますが、全ての Story に設定されているわけではないと思います。そうなると、storycap でも工夫されているように如何に Story の描画内容が安定しているかの確認は依然として重要です。

storycap-testrun を通じて撮影されるスクリーンショットの安定性を向上するために取り入れている、いくつかの工夫についてまとめます。

storycap 由来の安定性チェック

storycap-testrun では、storycap から踏襲している安定性チェックの機構を採用しています。

具体的には実行されるブラウザが Chromium の場合に、Chrome DevTools Protocol(以降 CDP)を通じて取得可能なノード変更、スタイル再計算回数などのメトリクスを監視することで、描画状態の安定性を確認します。

各種メトリクスが同値を返すようになるまで監視を続けますが、その上限回数はオプションで設定可能です。利用している環境で安定しないことが多い場合は上限を増やし様子を見ることをおすすめします。

.storybook/test-runner.ts
// 設定例
screenshot(page, context, {
  flakiness: {
    metrics: {
      retries: 3000,
    },
  },
});

この機能は CDP に依存するため Chromium 以外のブラウザの場合では実行できません。Chromium 以外のブラウザを利用しているケースでは警告が表示される仕様となっています。Chromium 以外のブラウザを中心に利用する場合は flakiness.metrics.enabledfalse にすることで無効化することができます。

複数回撮影した画像ハッシュの同値チェック

前述したメトリクス監視による描画状態の安定性チェックを行った後、最低 2 回撮影した画像データのハッシュを比較し、描画内容が一致するかを検証する機構も備えています。

これらの機構に関してもオプションで有効・無効の切り替え、検証方法を変更することができます。

.storybook/test-runner.ts
// 設定例
screenshot(page, context, {
  flakiness: {
    retake: {
      interval: 250, // 250ms 間隔で
      retries: 20,   // 20回を上限に検証する
    },
  },
});

スクリーンショットを複数回撮影する、という処理を行うため環境によってはパフォーマンスに対するネガティブな影響をもたらすことが想定されます。ナレッジワークではそれほど実行時間の増加傾向が見られなかったため、メトリクス監視及び画像ハッシュの同値チェックいずれも有効化して運用を行っています。

マスク・要素削除による安定化

メトリクス、画像ハッシュとして同一な描画内容であることが確認できても、Story 実行の度に変わってしまう情報もあると思います。iframe で読み込まれる外部リソースなどです。

それらを都度差分ありとしてレポートしてしまうことは意に反するので、storycap-testrun ではマスクや要素削除といった一般的な対策を簡単に実施できるようになっています。

これは各 Story の Parameters を使って指定が可能です。以下の例では [data-vrt-mask] セレクタに合致する全ての要素に矩形を乗せてマスクします。

Page.stories.ts
export const Overview: Story = {
  parameters: {
    screenshot: {
      mask: '[data-vrt-mask]',
    },
  },
};

アプリケーション固有の課題を吸収する Hooks

アプリケーション固有で共通して待機したいロジックが存在することがあります。例えばナレッジワークでは、Suspense で利用されることが想定されるコンポーネントの Story で、全体を囲っている Suspense が Loading 表示を行うことがあります。これらを都度 play 関数などで待機するのは開発者の神経を使います。

storycap-testrun では Hooks という形で、スクリーンショット撮影の前後に処理を割り込みさせることができます。Storybook Test runner の設定ファイルと似た概念です。

スクリーンショットを撮影する前に共通して待機が必要な場合は、preCapture 関数を実装したオブジェクトを hooks オプションに渡します。

const config: TestRunnerConfig = {
  async postVisit(page, context) {
    await screenshot(page, context, {
      hooks: [
        {
          async preCapture(page, context) {
            // Loading が非表示になることを待機するロジックを実装する
          },
        },
      ],
    });
  },
};

現状、これらの Hooks は 3 つのライフサイクルをサポートしています。詳しくはドキュメントを参照ください。

https://github.com/reg-viz/storycap-testrun?tab=readme-ov-file#hooks

マスク処理や要素削除といったビルトイン提供する機能自体も、この Hooks に沿って実装されているものです。

抱える制限

storycap と比較すると storycap-testrun はいくつかの制限を抱えています。

1つ目に複数の Viewport でスクリーンショットを撮影する機能です。これは Storybook Test runner 自体の現時点での仕様です。Storybook Test runner 自体を複数の Viewport で実行するような工夫を必要とします。

2つ目に focus や hover といった UI 状態毎のスクリーンショット撮影をする機能です。今後何かしらの形でサポートをするかもしれませんが、現時点では非対応です。必要に応じて play 関数でも同様のことができるのであまり重要視はしていません。

storycap-testrun の導入結果

ナレッジワークのコードベースに対して実際に適用していった結果、これまでいくつか Flaky 気味だった Story も全て解消しかなりの安定化を達成しました。僕が昨年末に入社して以来、見ることのできていなかった reg-suit による意図しない差分なしレポートも確認できました。

storycap-testrun で行っている安定性チェックもそうですが、play 関数の完了待機が安定的に行える点が大きいように感じています。

ただ、storycap と比較すると CI 上での実行時間が数分程度 [2] 伸びてしまいました。幸い Storybook Test runner は --shard オプションがサポートされているため、GitHub Actions で実行している VRT の Job に対して並列化を行うことができます。その他キャッシュ最適化などの見直しも合わせて行うことで storycap を利用していた際の時間にまで縮めることには成功しましたが、Billable Time のことも考えると確実に金銭的コストは増しています。

おわりに

Storybook Test runner を活用することで、スモークテスト、コンポーネントテストが気軽に行えることに加えて、VRT を安定的に運用することもできるようになりました。VRT が信頼できないオオカミ少年状態に陥ると開発生産性の低下に直結することを過去経験してきたので、安定化を達成したことは嬉しいです。

ナレッジワークでは、今後3年で10個の新プロダクトの開発・提供を予定しています。プロダクトの開発だけでも多くの課題解決が必要です。それらを支え、高速にかつ精度高く開発を進めることのできる開発基盤へも創意工夫をしていきたいと考えています。

現状やりたいと思いつつやりきれていないものが多くあります。Web フロントエンドのプロダクト開発、開発基盤の改善など興味がある方は、ぜひカジュアル面談で気軽にお話できると嬉しいです...!

https://kwork.studio/recruit-engineer

もっと気軽にナレッジワークについて聞いてみたい、という方は @wadackel に DM をいただければと思います。

脚注
  1. Chromatic を使ったことがないので詳細わかっていませんが、Chromatic ではどうなんだろう。 ↩︎

  2. 1,300 程度の Story ファイルが存在するケースの場合 ↩︎

株式会社ナレッジワーク

Discussion