Storybook でテストする: play 関数と Chromatic
04 章で書いた story は、状態を見るためのものでした。この章では、同じ story をテストとして使います。
story をテストにする利点は、状態を用意するコードを書き直さなくて済むことです。「開いている」「お知らせが無い」といった状態は、すでに story として存在します。そこへ操作と検証を足し、a11y の検査と見た目の比較を重ねます。
この章で学ぶこと
- play 関数で、story の中の操作と検証を書く方法
- Portal の外に描画される要素の探し方
- 実ブラウザで動くことから来る、jsdom との違い
- story を Vitest のテストとして実行する方法
- a11y の違反をテストの失敗にしたときに検知できること
- Chromatic で見た目の差分を検知する流れ
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
| Storybook の導入と story の基本 | 04 Storybook を導入する |
| CI への組み込み | Storybook の公式ドキュメント |
| Chromatic の料金や、チームでの承認の運用 | Chromatic の公式ドキュメント |
play 関数で操作を足す
play 関数は、story が描画されたあとに実行される関数です。その中でクリックやキー入力を起こし、結果を検証します1。04 章のファイルの末尾に、次の 2 つの story を足します。あわせて、2 行めの import を import { expect, fn, screen, waitFor } from 'storybook/test' に書き換えます。
/** ボタンを押して開き、Esc キーで閉じるまでを操作して確かめる。 */
export const OpenAndClose: Story = {
play: async ({ canvas, userEvent }) => {
// 開くボタンは story の描画領域 (canvas) の中にある
await userEvent.click(canvas.getByRole('button', { name: /お知らせ/ }))
/*
* ダイアログは Portal で body の直下に出るので、canvas の中には無い。
* 画面全体から探す screen を使う。
*/
const dialog = await screen.findByRole('dialog', { name: 'お知らせ' })
// 開いた直後は不透明度 0 から遷移が始まる。見えるようになるまで待ってから確かめる
await waitFor(() => expect(dialog).toBeVisible())
await userEvent.keyboard('{Escape}')
// 閉じる遷移が終わると DOM から消える
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
},
}
/** 「すべて既読にする」が親へ伝わることを確かめる。 */
export const MarkAllRead: Story = {
args: { defaultOpen: true },
play: async ({ args, userEvent }) => {
await userEvent.click(await screen.findByRole('button', { name: 'すべて既読にする' }))
await expect(args.onMarkAllRead).toHaveBeenCalledTimes(1)
},
}
play 関数は、引数として道具を受け取ります。ここで使っているのは 3 つです。
| 引数 | 内容 |
|---|---|
canvas | story が描画された領域です。getByRole などで、この領域の中から要素を探せます |
userEvent | クリックやキー入力を起こします。06 章で使った user-event と同じ書き方です |
args | story に渡された props です。fn() で作った関数が呼ばれたかどうかを検証できます |
expect と waitFor は、storybook/test から import します。toBeVisible や toBeInTheDocument といったマッチャーは、最初から使える状態になっています。06 章のテストと、書き方はほとんど変わりません。
Portal の外は screen で探す
OpenAndClose は、開くボタンを canvas から、ダイアログを screen から探しています。
canvas が指すのは、story が描画された領域だけです。ダイアログは Portal で body の直下に描画されるので、この領域の外にあります。canvas.getByRole('dialog') では見つかりません。screen は、ページ全体から探します。
Base UI の Dialog、Popover、Menu のように Portal を使う部品では、開いたあとの要素は screen で探す、と覚えておけば足ります。
実ブラウザでは、開いた直後は見えていない
OpenAndClose の中に、06 章には無かった待ち方があります。
const dialog = await screen.findByRole('dialog', { name: 'お知らせ' })
// 開いた直後は不透明度 0 から遷移が始まる。見えるようになるまで待ってから確かめる
await waitFor(() => expect(dialog).toBeVisible())
この検証を、待たずに await expect(dialog).toBeVisible() と書くと、手元ではテストが落ちました。ダイアログは DOM にあるのに「見えていない」と判定されたためです。
理由は 03 章の遷移にあります。開いた直後のダイアログには data-starting-style が付いていて、不透明度は 0 です。toBeVisible は、不透明度が 0 の要素を見えていないと判定します。
06 章の jsdom では、この問題は起きませんでした。CSS が当たっていないので、不透明度は常に既定値のままです。play 関数は実ブラウザで動くので、Tailwind CSS のクラスが本当に効いています。第 2 層からは、遷移が実際に起きることを前提に書く必要があります。
閉じたことの検証も同じです。Esc キーを押した直後、ダイアログは data-ending-style が付いた状態で DOM に残っています。waitFor で、DOM から外れるまで待ちます。
story を Vitest で実行する
play 関数は、Storybook の画面で story を開けば実行されます。Interactions パネルに、操作と検証が順に表示されます。
これをコマンドから実行できるようにするのが、Vitest アドオン (@storybook/addon-vitest) です。04 章の導入コマンドが、vite.config.ts に Storybook 用の project を作っています。次のコードは、それを少し整理したものです。
{
// 2. Storybook: story を 1 つずつテストとして実行する (実体は Chromium の上で動く)
extends: true,
plugins: [storybookTest({ configDir: path.join(import.meta.dirname, '.storybook') })],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
},
storybookTest というプラグインが、.storybook/main.ts の設定を読み、story を 1 つずつ Vitest のテストに変換します。browser の指定は、テストを Playwright 経由で Chromium の上で動かす設定です。
生成されたコードから、動きに関わるところを 2 か所変えています。
| 生成されたコード | 本ガイドのコード |
|---|---|
path.join(dirname, '.storybook') | path.join(import.meta.dirname, '.storybook') |
playwright({}) | playwright() |
1 つめは、設定ファイルのあるディレクトリの取り方です。生成されたコードは、ファイルの先頭で const dirname = typeof __dirname !== 'undefined' ? ... という変数を作って使います。このままだと、Vite が設定ファイルの中の __dirname に警告を出します。import.meta.dirname を直接使う形にしました。
この書き換えをするときは、不要になった 2 行も消します。const dirname = ... の行と、import { fileURLToPath } from 'node:url' の行です。残しておくと、使っていない変数と import として npx tsc -b がエラー (TS6133) で止まります。04 章の stories の import と同じ理由です。
生成されたままの形でも、テストは動きます。書き換えは必須ではありません。本ガイドのコードでは、整形 (セミコロンや改行) とコメントも直してあります。設定ファイルの全体は 08 章に載せます。
使う依存を先に知らせておく
実行の前に、vite.config.ts に設定を 1 つ足します。
optimizeDeps: {
/*
* ブラウザで動くテストは、Vite が依存パッケージを事前にまとめたものを読み込む。
* まとめたあとで新しい依存を使い始めると、次の実行の途中でページが読み込み直され、
* その回のテストが落ちる。先に対象へ入れておくと、読み込み直しが起きない。
*/
include: ['@base-ui/react/button', '@base-ui/react/dialog'],
},
ブラウザで動くテストは、Vite が依存パッケージを事前にまとめたものを読み込みます。まとめた結果はキャッシュされ、次の実行から使い回されます。キャッシュが無いときは、Vite がソースコードをたどって必要な依存を先に洗い出します2。そのため、キャッシュを消した状態では、この指定が無くてもテストは通りました。
指定が無いと困るのは、キャッシュがすでにあり、そこに入っていない依存を使い始めたときです。この指定を外し、Base UI を使わない story だけを一度実行してから、本ガイドの story を足して実行すると、次のメッセージが出て失敗しました。
[vite] (client) dependencies optimized: @base-ui/react/button, @base-ui/react/dialog
[vite] (client) optimized dependencies changed. reloading
[vitest] Vite unexpectedly reloaded a test. This may cause tests to fail, lead to flaky behaviour or duplicated test runs.
For a stable experience, add the newly optimized dependencies to your config's `optimizeDeps.include` field manually.
Vite が実行の途中で新しい依存を見つけ、まとめ直してページを読み込み直しています。そのときに実行中だったテストが巻き込まれ、story は 1 本も実行されませんでした。そのままもう一度実行すると通ります。
「一度落ちて、やり直すと通る」という形で現れるので、原因に気づきにくい不具合です。対象を optimizeDeps.include に書いておくと、同じ手順でも最初の実行から通りました。Base UI の別の部品を使い始めて同じメッセージが出たときは、メッセージに出ている名前 (@base-ui/react/popover など) をここへ足します。
08 章で足す実ブラウザ用の project でも、同じ手順で同じ失敗になり、同じ指定で通るようになりました。
実行する
storybook の project だけを実行します。--reporter=verbose は、テストを 1 本ずつ表示する指定です。
npx vitest run --project=storybook --reporter=verbose
✓ storybook (chromium) src/components/notifications-dialog.stories.tsx > Closed 587ms
✓ storybook (chromium) src/components/notifications-dialog.stories.tsx > Open 80ms
✓ storybook (chromium) src/components/notifications-dialog.stories.tsx > Empty 54ms
✓ storybook (chromium) src/components/notifications-dialog.stories.tsx > Open And Close 166ms
✓ storybook (chromium) src/components/notifications-dialog.stories.tsx > Mark All Read 55ms
Test Files 1 passed (1)
Tests 5 passed (5)
Duration 2.99s
story が 5 つあり、テストも 5 本です。play 関数を持たない Closed、Open、Empty も数に入っています。Vitest アドオンは、どの story についても、エラーなく描画できることを確かめます。play 関数があれば、続けてそれを実行します3。
story を 1 つ足すと、テストが 1 本増えます。04 章で「開いた状態を story にしておく」と書いたのは、ここに効いてきます。
a11y の違反をテストの失敗にする
04 章で、.storybook/preview.tsx の a11y.test を 'error' にしました。この設定があると、Vitest アドオンは story を実行するたびに axe-core の検査も行い、違反があればテストを失敗させます。上の 5 本は、この検査も通っています。
何が検知されるかを、実装をわざと壊して確かめます。
| 壊し方 | a11y の検査で落ちた story | axe-core が報告した規則 |
|---|---|---|
Dialog.Title を素の h2 に置き換える | Open、Empty、MarkAllRead | aria-dialog-name (ダイアログにはアクセシブルネームアクセシブルネーム (Accessible Name)支援技術が要素を読み上げたり探したりするときに使う名前。ボタンなら中の文字、ダイアログなら aria-labelledby が指す要素の文字というように、要素の内容や属性から決まる。が要る) |
説明文の色を text-slate-600 から text-slate-300 に薄くする | Open、Empty、MarkAllRead | color-contrast (文字と背景のコントラスト比が足りない) |
1 行めは、05 章で見た「名前を失ったダイアログ」です。このとき OpenAndClose も落ちますが、理由は別で、play 関数の中の「名前を指定してダイアログを探す」行が失敗します。注目したいのは Open と Empty です。この 2 つには検証のコードを 1 行も書いていませんが、a11y の検査が違反を見つけて落ちています。
2 行めは、06 章の層では検知できない種類の問題です。コントラスト比は、実際に当たっている色から計算します。jsdom の上では色が当たらないので、計算のしようがありません。
この表から、もう 1 つ読み取れることがあります。2 行めで OpenAndClose は落ちていません。a11y の検査は、play 関数が終わった時点の画面に対して行われます。OpenAndClose はダイアログを閉じて終わるので、検査の時点では説明文が画面にありません。開いたまま終わる story が無ければ、ダイアログの中身は a11y の検査を一度も受けません。
a11y.test は story ごとに上書きできます。直す予定の違反を一時的に見逃したいときは、その story だけ 'todo' にします。
Chromatic で見た目の差分を検知する
ここまでのテストは、動作と a11y を確かめています。見た目そのものは確かめていません。
どのくらい確かめていないかは、極端な壊し方をすると分かります。.storybook/preview.tsx から CSS の import を外すと、Tailwind CSS のクラスがすべて効かなくなり、ダイアログは枠も余白も無い素の HTML になります。この状態で上の 5 本を実行すると、5 本とも通ります。開閉は動き、名前も付いていて、黒い文字と白い背景はコントラストの規則も満たすからです。
見た目が壊れたことを検知するには、見た目を比べるしかありません。ビジュアルリグレッションテスト (VRT) は、story の画面を画像として保存しておき、変更のあとの画像と比べて、差分があれば知らせる仕組みです。基準になる画像を baseline と呼びます。
Storybook は、この仕組みを Chromatic というサービスとの連携で提供しています4。Chromatic は Storybook の開発チームが運営しているクラウドのサービスで、story をクラウド上のブラウザで描画して画像にします。
Chromatic は、利用にアカウントの作成が要ります。この節は公式ドキュメントの手順に沿った説明で、本ガイドのほかの節と違い、手元で実行した結果は載せていません。
導入から確認までの流れ
連携用のアドオン @chromatic-com/storybook は、04 章の導入コマンドで入っています。個別に入れる場合は npx storybook@latest add @chromatic-com/storybook を実行します。
| 手順 | 内容 |
|---|---|
| 1. サインインする | Storybook を起動し、Visual Tests パネルから Chromatic にサインインします。プロジェクトを選ぶか、新しく作ります |
| 2. baseline を作る | 最初の実行で、すべての story の画像が撮られ、baseline になります |
| 3. 変更して実行する | コードを変えたあとにもう一度実行すると、baseline と違う story がサイドバーで強調されます |
| 4. 差分を確かめる | パネルで、どの画素が変わったかを見ます。意図した変更なら受け入れて、新しい baseline にします。意図していなければコードを直します |
| 5. CI で実行する | プロジェクトのトークンを環境変数に設定し、CI から Chromatic を実行します。プルリクエストに「UI Tests」というチェックが付きます |
CI から実行するコマンドは、chromatic パッケージを入れたうえで npx chromatic --project-token <トークン> です。Chromatic は build-storybook の出力を使うので、04 章で確かめた npm run build-storybook が通ることが前提になります。
どの story を撮るか
Chromatic は、すべての story を画像にします。つまり、ここでも「story にした状態だけが対象になる」という原則が効きます。
本ガイドのダイアログでは、Open と Empty が見た目の比較の主な対象です。Closed に写るのはボタンだけで、OpenAndClose は閉じた状態で終わります。ダイアログの角丸や余白、幕の濃さを変えたときの差分は、開いたまま終わる story の画像にだけ現れます。
a11y の節と同じ結論になります。ヘッドレス UIヘッドレス UI (Headless UI)見た目を持たず、振る舞いと HTML の構造と支援技術向けの属性だけを提供する UI 部品、またはその部品集。色や余白などのスタイルは使う側が書く。 のダイアログやメニューのように、操作しないと現れない部品では、現れた状態を story として固定しておくことが、第 2 層の検査を効かせる条件です。
まとめ
- play 関数には、story が描画されたあとの操作と、その結果の確認を書きます。Portal の外に出る要素は
screenで探します - play 関数は実ブラウザで動くので、遷移が実際に起きます。開いた直後は不透明度が 0 なので、
toBeVisibleは待ってから確かめます - Vitest アドオンは、story を 1 つずつテストにします。play 関数の無い story も、描画できることが確かめられます
a11y.testを'error'にすると、名前の付け忘れやコントラストの不足がテストの失敗になります。検査されるのは、play 関数が終わった時点の画面です- スタイルがすべて外れても、動作と a11y のテストは通ります。見た目の検知には、画像を比べる Chromatic のような仕組みが要ります
- 開いたまま終わる story を用意しておくことが、a11y の検査と見た目の比較の前提です
次に読む
- 08 実ブラウザでテストする — story に結びつかない検証を、本物のキー入力で書きます
- Interaction tests — play 関数で使える道具の一覧