メインコンテンツまでスキップ

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' に書き換えます。

src/components/notifications-dialog.stories.tsx
/** ボタンを押して開き、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 つです。

引数内容
canvasstory が描画された領域です。getByRole などで、この領域の中から要素を探せます
userEventクリックやキー入力を起こします。06 章で使った user-event と同じ書き方です
argsstory に渡された 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 章には無かった待ち方があります。

src/components/notifications-dialog.stories.tsx
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 を作っています。次のコードは、それを少し整理したものです。

vite.config.ts
{
// 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 つ足します。

vite.config.ts
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 の検査で落ちた storyaxe-core が報告した規則
Dialog.Title を素の h2 に置き換えるOpen、Empty、MarkAllReadaria-dialog-name (ダイアログにはアクセシブルネームが要る)
説明文の色を text-slate-600 から text-slate-300 に薄くするOpen、Empty、MarkAllReadcolor-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 のダイアログやメニューのように、操作しないと現れない部品では、現れた状態を story として固定しておくことが、第 2 層の検査を効かせる条件です。

まとめ​

  • play 関数には、story が描画されたあとの操作と、その結果の確認を書きます。Portal の外に出る要素は screen で探します
  • play 関数は実ブラウザで動くので、遷移が実際に起きます。開いた直後は不透明度が 0 なので、toBeVisible は待ってから確かめます
  • Vitest アドオンは、story を 1 つずつテストにします。play 関数の無い story も、描画できることが確かめられます
  • a11y.test を 'error' にすると、名前の付け忘れやコントラストの不足がテストの失敗になります。検査されるのは、play 関数が終わった時点の画面です
  • スタイルがすべて外れても、動作と a11y のテストは通ります。見た目の検知には、画像を比べる Chromatic のような仕組みが要ります
  • 開いたまま終わる story を用意しておくことが、a11y の検査と見た目の比較の前提です

次に読む​

Footnotes​

  1. Interaction tests に、play 関数の引数と、storybook/test から import できるものの説明があります。 ↩

  2. Vite の Dependency Pre-Bundling の「Automatic Dependency Discovery」の節です。キャッシュが無いときはソースコードをたどって依存を見つけること、サーバーの起動後にキャッシュに無い依存が見つかると、まとめ直して必要ならページを読み込み直すことが書かれています。 ↩

  3. Vitest addon の「How it works」に、story は描画の確認と play 関数の実行の 2 通りでテストされる、とあります。 ↩

  4. Visual tests と、Chromatic の Quickstart に、導入から CI までの手順があります。 ↩