Vitest(jsdom)でStorybookのStory全部テストする大作戦
この記事は 株式会社ゆめみの23卒 Advent Calendar 2023 7日目の記事です。
今北産業
- ファイル
component.test.tsx
を置くと、Storybook test runnerのように全部のStoryのスモークテストとインタラクションテストがVitestとjsdomでできるよ! - ヘッドレスブラウザを使わずにテストできるので、CIやGit hookなど実行時間を少なくしたいシチュエーションで使えるよ!
- でもブラウザで実行していないから複雑なインタラクションテストやアクセシビリティテストなど、一部のテストが不安定になるかもね! 考えて使い分けよう
Storybook をテストとして動かす
Storybookでは、Storyファイルのplay
関数内にユーザのインタラクション(振る舞い)やアサーション(期待する結果)を記述することで、インタラクションテストを行うことができます。
例えば、次のコードはフォームがきちんと入力されている状態でsubmitできることを保証するテストです。
export const FullfilledSubmit: Story = {
play: async ({ canvasElement, step, args }) => {
const canvas = within(canvasElement);
// 入力ステップ
await step("input", async () => {
// `Username` という名前がついた入力欄に `name` と入力する
await userEvent.type(
canvas.getByRole("textbox", { name: "Username" }),
"name",
);
});
// 送信ステップ
await step("submit", async () => {
// `Submit` という名前がついたボタンをクリックする
await userEvent.click(canvas.getByRole("button", { name: "Submit" }));
});
// アサーションステップ
await step("assert", async () => {
await waitFor(async () => {
// `onSubmit` propが「{ username: "name" }」というデータで送信されていることを確かめる
await expect(args.onSubmit).toBeCalledWith(
{
username: "name",
},
expect.anything(),
);
});
});
},
};
このStoryを閲覧すると、次の画像のようにCanvas下の「Interactions」パネルにテストの実行結果が表示されます。ちゃんとPASSされていますね。
さらにこのインタラクションテストはStorybook test runnerによって、Playwright(ヘッドレスブラウザ)上で全てのテストを自動的に実行できるようになりました。
インタラクションテストとStorybook test runnerによって、JestやVitestといったテストツールで書いていたコンポーネントのテストをStorybookで実行できるようになります。これでStoryファイルとテストファイルを別々に管理する手間や、Storybookではテストができず、テストツールでは見た目が確認しにくいといった問題が解決されました。
Storybookのディスカッションでは、Storybook test runnerがjsdomベースになることは考えていない旨の発言があります。PlaywrightはJestと比較して、ブラウザでテストが実行されることから結果がより信頼できるものになること、速度の差はこのメリットと比較すれば無視できるほどであったことが理由のようです。
しかし、VitestはPlaywrightと比べて十分速いと感じます(計測はしていません)。テストをCIやGit hook上などで頻繁に実行する場合は、実行時間の短縮が開発者体験の改善に繋がることから、Vitest上でStorybookのテストをしたいと考えました。
Vitest上でテストを実行するには、Vitest上でStoryファイルを再利用するという方法があります。Storybookでは、importされたStoryファイルをReact Testing Libraryで実行可能なコンポーネントに変換する関数が提供されており、これを使用する方法です。(composeStory
関数やcomposeStories
関数)しかし、これはStory1つづつに対してテストを記述しなければならないという問題がありました。これではStoryファイルとテストファイルを別々に管理する手間は減りません。
書籍『フロントエンド開発のためのテスト入門』「第8章 UIコンポーネントエクスプローラー」の最後では、以下のようにまとめられています。
Jest でStoryを再利用するほうが優れている点
- モジュールモックやスパイが必要なテストが書ける (Jestのモック関数を使用)
- 実行速度が速い (ヘッドレスブラウザを使用しない)
Test runner のほうが優れている点
- テストファイルを別途用意しなくてもよい(工数が少ない)
- 忠実性が高い(ブラウザを使用するのでCSS指定が再現される)
余談ですが、『フロントエンド開発のためのテスト入門』には先述した内容も詳細に解説されています。より詳しい情報をお求めの方はご参照ください。
だが…… 今は違う!!(ギュッ)
Storybook をテストとして「ブラウザの外で」動かす
ここではVitestで全てのStorybookのStoryをテストとして実行する方法を提案します。冒頭にも出てきたの component.test.ts
がそれです。
実際にcomponents.test.ts
を導入している例はリポジトリygkn/storybook-test-runner-jsdomにあります。このリポジトリでVitestを実行すると、全てのStoryが実行されていることを確認できます。
$ npm run test
> storybook-test-runner-jest@0.1.0 test
> vitest
DEV v1.0.2 storybook-test-runner-jsdom
(node:92367) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
✓ src/test/component.test.tsx (6)
✓ 'src/components/ui/button.stories.tsx' (1)
✓ 'Default'
✓ 'src/components/ui/form.stories.tsx' (3)
✓ 'Default'
✓ 'EmptySubmit'
✓ 'FullfilledSubmit'
✓ 'src/components/ui/input.stories.tsx' (1)
✓ 'Default'
✓ 'src/components/ui/label.stories.tsx' (1)
✓ 'Default'
Test Files 1 passed (1)
Tests 6 passed (6)
Start at 00:35:43
Duration 769ms (transform 131ms, setup 172ms, collect 183ms, tests 54ms, environment 194ms, prepare 42ms)
PASS Waiting for file changes...
press h to show help, press q to quit
実現方法は半ば力技で、Vitestのimport.meta.glob
で全てのStoryファイルを列挙し、それらをcomposeStories
関数でテストとして実行しています。(import.meta.glob
を使って全てのStoryファイルを列挙することはStorybookのドキュメント「Storyshots migration guide」内「Configure the testing framework for portable stories」セクションでも記載されています。)
関数のモック
Storybookではreact-docgenを使用してargTypes
(propの型情報)を取得しています(記事Storybook と react-docgen の仕組みを追うに詳細が書かれています)。このargTypes
からActionsを生成しています(Automatically matching args)。
Actionは、インタラクションテストでは関数のモックとして使用できます。先述の例で onSubmit
propの呼び出しの確認していたのはこの機能によるものです。
自動でActionsを生成する機能はImplicit actionsと呼ばれています。Implicit actionsは、composeStories
では使用できないため、以下のように自分でargTypes
かactions
を書く必要がありました。
export default {
component: Form,
argTypes: {
onSubmit: { onSubmit: {type: "function" } },
// または
onSubmit: { onSubmit: {action: "onSubmit" } },
},
};
しかし、つい先日Storybook 7.6で以下のように以前より楽にactionを指定できる@storybook/test
が導入されました。
import { fn } from '@storybook/test';
export default {
component: Form,
args: {
onSubmit: fn(),
},
};
さらに、Implicit actionsはStorybook 8.0で非推奨になる予定です(StorybookのリポジトリMIGRATION.mdを参照)。
今から @storybook/test
の fn()
を使用しておくのが良いでしょう。
課題
この手法の既知の課題について述べます。
コンポーネントやStoryファイルが編集された際にすべてのStoryが再実行される
src/test/components.test.tsx
ファイルにて全てのStoryファイルを動的にimportしていることが原因と考えられます。
Storybook上でPASSするテストがPASSしない
複雑なインタラクションテストや、一部のaddonを使用したStoryで発生する可能性があります。ブラウザやフレームワークのAPIのモックをcomponent.test.ts
やsetup.ts
にて行うと動く可能性があります。
どうしても動かないときは、play
関数内で以下のように書いてVitest上での実行をスキップしています。この場合は、ブラウザのStorybook上で特に注意して確認するようにしています。
// @ts-expect-error TODO: addon が vitest で動かないので暫定対応
if (typeof import.meta.env !== 'undefined') {
return;
}
アクセシビリティチェックの信頼性が落ちる
Storybook test runnerではaxe-playwrightを使用して、この手法ではvitest-axeを使用してアクセシビリティチェックを実行できます。
アクセシビリティのチェックはブラウザ上で実行しているStorybook test runnerのほうが信頼できます。もし結果に差異が出たらStorybook test runnerの結果を採用すると良いでしょう。
まとめ
この記事ではcomponents.test.ts
を用いてStorybookをVitest上でテストする方法について述べました。
Testing Trophyの話などでもよく言われるように、多くのテストの手法にはトレードオフが存在します。メリットとデメリットを把握してより良い開発者体験を目指していきましょう。
Discussion