StorybookでAPIドリブンコンポーネントをテストする
Storybook 10でMSWを使い、API駆動コンポーネントの読み込み・エラー・空・成功状態をテストし、ストーリーをコンポーネントテスト化します。
データをフェッチするコンポーネントは、リクエストに応答するバックエンドが存在しないため、Storybook上で「Loading…」のまま停止するか、エラーをスローします。解決策はフックをスタブ化することではなく、Mock Service Workerを使ってネットワーク層でリクエストをインターセプトすることです。
コンポーネントがStorybookで「Loading…」のまま固まり、コンソールに何も表示されない状況を経験したことがあれば、その原因はこれです。マウント時に発火するリクエストに応答するサーバーが存在しないのです。モックを一度設定すれば、以降に作成するすべてのストーリーが自動的に同じ恩恵を受けられます。
本ガイドでは、Storybook 10にmsw-storybook-addon(MSW 2.x が必要)をセットアップし、UserListコンポーネントと4つのストーリー(成功・ローディング・エラー・空)を構築します。さらに、モック化されたこれらの状態を自動インタラクションテストへと発展させる方法も解説します。
重要なポイント
- ネットワーク層でモックを行うことで、一組のMSWハンドラーをStorybook・Nodeベースのユニットテスト・Chromaticのビジュアルリグレッションテストで変更なく共通利用できます。
- MSW v2では、成功ハンドラーは
http.get(url, () => HttpResponse.json(data))と記述します。rest.getや(req, res, ctx) => res(ctx.json())というリゾルバー構文は廃止されました。 - 永続的なローディング状態は
delay('infinite')をawaitすることで、エラーはHttpResponse.json(null, { status: 500 })で、空の結果はHttpResponse.json([])を返すことでモデル化できます。 .storybook/previewのloaders配列にmswLoaderを追加し、メイン設定のstaticDirsでワーカーを配信する設定を一度行うだけでアドオンが機能します。- 各モック状態に
play関数を組み合わせることで、状態を自動的にアサートできます。play関数を持つストーリーはコンポーネントテストになります。
データフェッチコンポーネントはなぜStorybookで動作しないのか?
Storybookはコンポーネントをアプリケーションシェルもサーバーも存在しない隔離された環境でレンダリングします。マウント時にfetch・useQuery・Apolloを呼び出す「アプリコンポーネント」は、何も応答しないリクエストを発火させるため、ローディング状態のまま永遠に停止するか、Promiseがリジェクトされてエラーをスローします。こうした場合、フックをスタブ化しようとする発想が生まれがちです。useQueryをあらかじめ用意したデータを返すモックに差し替えるわけです。しかしこれは避けるべきです。フックのスタブ化はストーリーを特定のデータライブラリやコンポーネントの内部構造に密結合させてしまい、同じシナリオをNodeテストで実行したい場面では役に立ちません。
代わりにネットワーク層でモックを行いましょう。MSWはブラウザ上でアウトゴーイングリクエストをインターセプトするサービスワーカーを登録し、あらかじめ定義したレスポンスを返します。これによりコンポーネントは実際のフェッチコードパスをそのまま実行できます。同じハンドラーはsetupServerを通じてNode環境でも動作するため、Storybook・Vitest・CI間でポータブルに利用できます。シナリオを一度記述すれば、コンポーネントが動作するあらゆる環境で機能します。
Discover how at OpenReplay.com.
StorybookのモックAPIスタックをセットアップする方法
両パッケージをインストールし、サービスワーカーを生成し、ローダーを登録し、Storybookをワーカーファイルに向けます。この一度限りのセットアップはStorybookのネットワークリクエストモックガイドに従っています。
npm install msw msw-storybook-addon --save-dev
npx msw init public/
npx msw init public/はmockServiceWorker.jsを静的ディレクトリに書き出します。.storybook/preview.tsのloadersにmswLoaderを追加して、アドオンをグローバルに登録します。ローダーはストーリーのレンダリング前に実行されるため、アドオンはデコレーターではなくローダーを使用しています。
import type { Preview } from '@storybook/react-vite';
import { initialize, mswLoader } from 'msw-storybook-addon';
initialize();
const preview: Preview = {
loaders: [mswLoader],
};
export default preview;
次に、.storybook/main.ts内のstaticDirsにpublicフォルダを追加して、生成されたワーカーを配信します。Storybook 10では旧来のstart-storybook -s publicフラグは廃止されています。
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
framework: '@storybook/react-vite',
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
staticDirs: ['../public'],
};
export default config;
成功ストーリー
以下がテスト対象のコンポーネントです。配列をフェッチして4つのUIブランチのいずれかをレンダリングするUserListです。後のテストでクエリに使用するアクセシブルなrole="status"とrole="alert"に注目してください。
// UserList.tsx
import { useEffect, useState } from 'react';
type User = { id: number; name: string };
const endpoint = 'https://api.example.com/users';
export function UserList() {
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
fetch(endpoint)
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
.then((data) => {
setUsers(data);
setStatus('success');
})
.catch(() => setStatus('error'));
}, []);
if (status === 'loading') return <p role="status">Loading…</p>;
if (status === 'error') return <p role="alert">Something went wrong.</p>;
if (users.length === 0) return <p>No users yet.</p>;
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
ハンドラーはparameters.msw.handlersを通じてストーリーごとに設定します。MSW v2ではhttp.getがrest.getに取って代わり、HttpResponseクラスがctxユーティリティに取って代わります。
// UserList.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { http, HttpResponse, delay } from 'msw';
import { UserList } from './UserList';
const endpoint = 'https://api.example.com/users';
const meta = { component: UserList } satisfies Meta<typeof UserList>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Success: Story = {
parameters: {
msw: {
handlers: [
http.get(endpoint, () =>
HttpResponse.json([
{ id: 1, name: 'Ada Lovelace' },
{ id: 2, name: 'Alan Turing' },
]),
),
],
},
},
};
すべての状態をモデル化する
多くのチュートリアルはハッピーパスで終わりますが、それは最も興味深くないストーリーです。ネットワーク層モックの真価は、ハンドラーを一つ差し替えるだけでコンポーネントが取りうるすべての状態を再現できる点にあります。APIドリブンなUIのセッションリプレイでは、開発者がストーリーボードに描かなかった状態が頻繁に浮かび上がります。リクエストがハングして永遠に解決しないスピナーや、空のレスポンスが適切な空状態ではなく壊れたレイアウトとしてレンダリングされるケースなどです。Storybook + MSWは、こうした状態をリリース前に記述・アサートするための場です。
| 状態 | ハンドラー | 検証内容 |
|---|---|---|
| ローディング | レスポンス前にawait delay('infinite') | ペンディング中のUIがレンダリングされ、フラッシュしないこと |
| エラー | HttpResponse.json(null, { status: 500 }) | エラーブランチが5xxを適切に処理すること |
| 空 | HttpResponse.json([]) | ゼロ件レイアウトが意図的に設計されており、壊れていないこと |
delay関数は'infinite'モードを受け付け、リクエストを永遠にペンディング状態に保ちます。これはコンポーネントをローディングブランチで確実に固定する方法です。mswからdelayをインポートし、非同期リゾルバー内でawaitします。
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get(endpoint, async () => {
await delay('infinite');
return HttpResponse.json([]);
}),
],
},
},
};
export const Error: Story = {
parameters: {
msw: { handlers: [http.get(endpoint, () => HttpResponse.json(null, { status: 500 }))] },
},
};
export const Empty: Story = {
parameters: {
msw: { handlers: [http.get(endpoint, () => HttpResponse.json([]))] },
},
};
MSWはGraphQLも同様の方法でモックできます(graphql.query('AllUsers', () => HttpResponse.json({ data })))。そのため、このパターンはApollo・urql・React Queryにも変更なく適用できます。
閲覧からテストへ
モック化されたストーリーを眺めるだけではドキュメントに過ぎません。play関数を追加することで、自動化可能なコンポーネントテストに昇格します。storybook/test(Storybook 8の@storybook/testに取って代わった現行モジュール)からexpectをインポートし、レンダリング結果をアサートします。
import { expect } from 'storybook/test';
// Successストーリーの場合:
play: async ({ canvas }) => {
await expect(await canvas.findByText('Ada Lovelace')).toBeInTheDocument();
},
// Loadingストーリーの場合:
play: async ({ canvas }) => {
await expect(canvas.getByRole('status')).toBeInTheDocument();
},
無限ディレイのLoadingストーリーでは、スピナーが表示されていることをアサートします。リクエストは設計上永遠にペンディングするため、解決後のUIをawaitしてはいけません。これらのストーリーはVitestアドオンを通じて実行され、StorybookのUI・ターミナル・CIからPlaywrightのChromiumブラウザ上でコンポーネントテストとして動作します。MSWハンドラーは環境に依存しないため、同じ成功・エラー・空のハンドラーがsetupServer経由でスタンドアロンのVitestテストのバックエンドとしても機能し、Chromaticは各ストーリーのスナップショットをビジュアルリグレッションテストに活用できます。
ネットワーク層でモックを行い、4つの状態すべてをモデル化し、各状態にplay関数を付与する。これにより、ストーリーのフォルダーが、「永遠にローディング」や「壊れた空状態」のバグを本番リリース前に検出するライブテストスイートへと変わります。まずは既存のアプリコンポーネントにローディング・エラー・空のストーリーを追加することから始めてみてください。そこで記述したハンドラーは、そのままVitestテストでも再利用できます。
よくある質問
msw-storybook-addonをインストールしてもコンポーネントがStorybookで「Loading…」のまま固まるのはなぜですか?
MSWハンドラーがリクエストにマッチしていないか、アドオンのローダーが正しく設定されていないことが原因です。.storybook/previewのloaders配列にmswLoaderが追加されてinitialize()が呼び出されていること、npx msw initでpublicが生成されstaticDirsに記載されていること、parameters.msw.handlersのハンドラーがリクエストのURLとメソッドに正確にマッチしていることを確認してください。URLが一致しない場合、リクエストはハンドルされずコンポーネントは永遠にペンディング状態になります。
MSWによるネットワーク層モックとfetchフックのスタブ化の違いは何ですか?
MSWによるネットワーク層モックは実際のアウトゴーイングリクエストをインターセプトしてレスポンスを返すため、コンポーネントは実際のフェッチコードパスをそのまま実行でき、同じハンドラーがブラウザ・setupServer経由のNode・Chromaticで共通して機能します。フックのスタブ化はuseQueryやfetchをあらかじめ用意したデータに差し替えるため、ストーリーが特定のデータライブラリやコンポーネントの内部構造に密結合し、Nodeテストでは再利用できません。
msw-storybook-addonはrest.getやres(ctx.json())といったMSW v1のハンドラーと互換性がありますか?
互換性はありません。バージョン2.0.0以降、このアドオンはMSW 2.0.0以上を必要とし、MSW v2ではrestネームスペースとres(ctx.json())リゾルバー構文が廃止されました。http.getとHttpResponseクラスを使用してハンドラーを書き直してください(例:http.get(url, () => HttpResponse.json(data)))。MSW v1のコードは現行のアドオンでは動作しないため、MSW公式の1.xから2.xへの移行ガイドに従って移行する必要があります。
無限ディレイのローディングストーリーをテストとして実行すると停止するのはなぜですか?
await delay('infinite')を使用するストーリーは設計上リクエストを永遠にペンディングするため、解決後のUIをawaitするplay関数は完了しません。解決を待つのではなく、ペンディング中のUIが表示されていること(例:role statusを持つスピナーがレンダリングされること)のみをアサートしてください。NodeやVitestのテストをクリーンに完了させる必要がある場合は、そのシナリオでinfiniteモードの代わりにdelay(1000)のような有限のディレイを使用してください。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k