Storybook をフル活用してテストを実装した話

サムネイル

こんにちは、フロリアでエンジニアとして活動している irico です。
現在 kintone ではフロリアというプロジェクトの中で、Closure Tools から React への移行作業に取り組んでいます。
今回は、そのフロリアのチームの 1 つである Reactone チーム が採用した「Storybook をフル活用したテスト手法」についてお話します。

Storybook によるテストアプローチ

Storybook の v6.4 から play 関数が導入されv6.5 から Interaction tests が可能になりました。

これによって Story 上でテストを実行するだけでなく、実行したテストの動作確認がブラウザ上で可能になりました。
今までは Jest や Vitest 上で Testing Library を利用する際、DOM 操作の視覚的な確認が難しく感じていましたが、Interaction tests を利用すればブラウザ上で簡単に確認できます。
また、Chromaticというサービスを利用することで、Storybook 上で記述した Interaction tests を VRT(Visual Regression Test) に流用することができます。

これらの機能について詳しく紹介していきますが、各機能についての細かい説明は必要ないよ!という方は、なぜ Storybook にテストを寄せたのかから読み進めてください。

Test runner を使って Storybook のテストを CLI で実行する

Test runner の公式ドキュメント

Test runner によって Storybook 上で記述した Interaction tests が CLI で実行可能になります。
内部では Playwright が使用されており、Jest や Testing Library を用いてテストを記述します。

以下のように play 関数を用いることで、Jest や Testing Library のほとんどの機能が使用可能です。

export const LoggedIn = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const loginButton = await canvas.findByRole("button", {
      name: /Log in/i,
    });
    await userEvent.click(loginButton);
    expect(loginButton).not.toBeInTheDocument();
  },
};

test-storybookを実行すると、以下のようにヘッドレスブラウザでテストが実行されます。

❯ npm run test-storybook

 storytest@1.0.0 test-storybook
 test-storybook

 PASS   browser: chromium  stories/Header.stories.js
 PASS   browser: chromium  stories/Page.stories.js
 PASS   browser: chromium  stories/Button.stories.js

Test Suites: 3 passed, 3 total
Tests:       8 passed, 8 total
Snapshots:   0 total
Time:        4.861 s
Ran all test suites.

Interaction tests でブラウザ上の動作を確認する

play 関数の書かれた Storybook を起動すると、ブラウザ上で テストを 1 ステップ毎に実行できます。
また、タイムトラベル機能もあり、各操作時の画面の状態を確認することも簡単です。

Storybook interaction tests のタイムトラベル機能 demo

Chromatic で Story を VRT に使う

Interaction tests として書いた Story を VRT に使うこともできます。
実行方法には様々な手法がありますが、Reactone チームではChromatic というサービスを用いて VRT を行いました。
以下のように Storybook を元にスナップショットをとり、コミット毎の差分を検知します。

Chromatic VRTが差分を検知しているサンプル

どの Story のスナップショットを撮るかは、Story 側の parameters で調整が可能です。
実際の設定では、Chromatic によるスナップショットを .storybook/preview.ts で一括で有効にし VRT が必要な Story のみ スナップショット を無効とすることで無駄なスナップショットを抑えました。(Chromatic はプランごとに スナップショット 数に上限があり、上限を超えると枚数ごとの従量課金となります

// .storybook/preview.ts
import type { Preview } from '@storybook/react';

const preview: Preview = {
  parameters: {
    // 不要なスナップショットを撮りすぎないようにデフォルトは無効
    chromatic: { disableSnapshot: true },
    ...
  }
};

export default preview;
// Button.stories.tsx
export const Button = {
  play: async ({ canvasElement }) => {
    //... 略
  },
  parameters: {
    // 必要なコンポーネントのみスナップショットを撮る
    chromatic: { disableSnapshot: false },
  },
};

なぜ Storybook にテストを寄せたのか

Reactone チームでは Kent C. Dodds 氏の提唱する Testing Trophy モデルに乗っ取ってテスト設計を行っており、Integration Test の比重を高くしています。
参考: static-vs-unit-vs-integration-vs-e2e-tests

Testing Trophy上の比重の多いIntegrationTest(VRT)の領域を、Storybookで実装する
今回、エンジニア以外の職種含む別チームのサポートメンバーにもテスト実装をお願いすることにしました。
そのためコーディングに馴染みのないサポートメンバーにもテストが書きやすい環境を用意する必要がありました。

Storybook のテスト機能(主に Interaction tests)を使うことで、1 ステップ毎に現状を把握するのが容易になります。
また、Storybook さえ起動できればブラウザの開発者ツール等を用いてデバック可能なため、環境構築の面でも障壁を下げることができました。

VRT の導入を検討したのは、 Storybook である程度 Integration Test を書いた後でした。
すでに Story の資産が出来上がっている状態だったため、Chromatic を導入することでその Story を VRT にそのまま流用できることがわかっていました。
追加実装をほぼ必要とせず VRT を導入できるので、コストパフォーマンスの面から見て Chromatic の導入は良い選択だったと感じています。

実績と振り返り

実際にローカル環境で Reactone のテストを実行した結果は以下の通りです。

実際のtest-runnerの実行結果
現在では 460 件のテストがあり、その内約 7 割程度というかなりの量のテストがサポートメンバーによって実装され、無事リリースまでに必要なテストの自動化を終えることができました。
Chromatic による VRT も当初予想していた通り、ほとんどのケースで既存の Story を再利用することができました。

サポートメンバーからは Storybook の Interaction tests が特に好評で、 「実際に動く画面で確認できるので実装・デバッグしやすい」「エラーのタイミングわかりやすい」といったフィードバックがありました。
どの画面の状態まで実装がうまくいってるかの確認や、DOM 取得時に DOM がないケースでテストが落ちる場合の画面の状態の調査など、詰まりやすいポイントで視覚的な効果を発揮できたようです。

課題点

Storybook によるテスト実装の課題もいくつかあります。

純粋な Jest / Testing Library と比べ実行コストがかかってしまう

Test runner は build した Storybook を元に ヘッドレスブラウザでテストするので、純粋な Jest や jsdom を使った Testing Library と比較すると実行速度がかかることが予想されます。

jsdom is not a full browser: it does not perform layout or rendering, and it does not support navigation between pages. It does support the DOM, HTML, canvas, many other web platform APIs, and running scripts.

参考: jsdom vs PhantomJS

現在のテスト数(460 件)では実行時間が 45 秒 とそこまで大きな負荷にはなっていませんが、将来的に実装やテストが拡充した場合ボトルネックになる可能性があります。

mock が難しい

Jest にはjest.mockというモジュールの mock を行うメソッドがありますが、Storybook 上では jest.mockを用いることができません。

このために jest.mockを利用したい場面であっても、別の mock 手法を採用する必要がありました。

Storybook 内のドキュメントには webpack の resolve.alias を利用して import を mock する例が紹介されていますが、やや煩雑な印象です。

Storybook から提供された Jest や Testing Library を用いるため、ライブラリの依存関係が複雑になった

Storybook 内で Jest や Testing Library を用いる場合、Storybook 側でラップされカスタマイズされた @storybook/testing-library@storybook/jest を利用する必要があります。

参考:set-up-the-interactions-addon

Storybook のバージョンに依存するライブラリが多く、これらのパッケージの管理(定期的なアップデートや依存関係の整理)にコストを感じる場面がありました。

global な操作を行う場合、ページ間を跨ぐテストで相互に影響が出ることがあった(v7 より 以前)

テストを書き始めたタイミングでは Storybook のバージョン 6.5 を利用しており、デフォルトでは Story 毎にビルドを分割できませんでした。
そのため、Story 毎の操作(global なデータ操作など)を行いたい場合に、Story 同士で相互に影響が出てしまうケースがありましたが、6.5 から試験的に導入されていたStorybook on-demand architectureという機能では自動コード分割が採用されており、起動時にすべての Story を読み込みません。

Storybook on-demand architecture を先行で利用するには下記の設定を追加します。

// .storybook/main.ts
module.exports = {
  features: {
    storyStoreV7: true,
  },
};

なお、 7 以降のバージョンでは Storybook on-demand architecture がデフォルトで有効になっているため、上記の設定は不要です。

これから模索していきたいこと

Reactone チームでは今後はJest Previewなどのより軽量なシステムで視覚的なわかりやすさを実現することを検討しています。

Jest Preview では、提供されたpreview.debug()を Jest のテストファイルに差し込むことで、ローカル上でpreview.debug()が実行された時点の画面状態を確認することが可能になります。

jest-previewのデモ

おわりに

今回はテスト実装メンバーが短期的に参加するという特殊なチーム体制だったため、それに合った Storybook によるテスト実装を行いました。 テストを実装したメンバーには Interaction な操作が非常に好評で、視覚的なわかりやすさによってテスト実装の敷居を大幅に下げることに成功しました。
一方で、mock の辛さや実行コスト、メンテコストなど、長期的に考えると不安な要素もあります。

チームの状況によってテストツール(テストに限りませんが)の最適な選択は変わっていきます。 今後も変化していくチームに合わせてより最適なテスト手法を模索していきたいです。

そういった面では、フロリアのようにチーム単位で細かく意思決定できることの重要さを改めて実感する良い機会となりました。

また、フロリアでは絶賛メンバーを募集しています。 フロントエンド刷新に興味がある、開発環境を一緒に良くしていきたいという方はぜひ応募してみてください。