Skip to content

Commit a13283b

Browse files
記事追加:Vitestと統合可能!StorybookでNext.js v16のコンポーネントテストを行う 後編 (#2334)
* 記事追加:Vitestと統合可能!StorybookでNext.js v16のコンポーネントテストを行う 後編 * レビュー指摘対応 * 脚注からコラムに変更
1 parent 4a98b89 commit a13283b

File tree

1 file changed

+290
-0
lines changed

1 file changed

+290
-0
lines changed
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
---
2+
title: Vitestと統合可能!StorybookでNext.js v16のコンポーネントテストを行う 後編 - App Routerでの設定・モジュールモック -
3+
author: kohei-tsukano
4+
date: 2026-02-18
5+
tags: [Next.js, Storybook, React, Vitest, テスト]
6+
image: true
7+
---
8+
9+
## はじめに
10+
11+
ビジネスソリューション事業部の塚野です。
12+
本記事は「Vitestと統合可能!StorybookでNext.js v16のコンポーネントテストを行う」の後編です。
13+
前編では Storybook の導入や基本的な使い方についてご紹介しました。本記事では Next.js 固有の設定やモジュールモックなどについてまとめていきます。
14+
15+
## next/router、next/navigationのモック
16+
17+
Next.js でページ遷移や URL の参照・更新に関わるパッケージとして `next/router` 、`next/navigation` パッケージがあります。
18+
19+
`next/router` は主に Page Router で、`next/navigation` は App Router で使用されます。Storybook(@storybook/nextjs-vite)では `next/router` パッケージはデフォルトでスタブされ、ルーターオブジェクトはActions タブにイベントを出力するモックに置き換えられます。
20+
21+
`next/navigation` も自動的にスタブされるため、 Story 上でも usePathname、 useSearchParams、 useRouter などを呼び出せます。
22+
ただし、App Routerを使用する場合 Storybook 側に「App Router を使う」ことを明示する必要があります。Story 単位で設定できますが、プロジェクト全体が App Router 前提であれば `.storybook/preview.ts` に書いて全 Story に適用するのが手軽です。
23+
24+
```typescript:.storybook/preview.ts
25+
import type { Preview } from '@storybook/nextjs-vite';
26+
27+
const preview: Preview = {
28+
...
29+
parameters: {
30+
...
31+
nextjs: {
32+
appDirectory: true, // ← App Router を利用する場合 true とする
33+
},
34+
},
35+
};
36+
37+
export default preview;
38+
39+
```
40+
41+
ここで、`next/navigation` パッケージを使用したコンポーネントとその Story を作成してみます。
42+
43+
コンポーネントのコードは読み飛ばしてかまいません。このコンポーネントでは input に入力した値を searchParams として現在の URL を書き換えます。
44+
コンポーネント内では `next/navigation` パッケージの useRouter、 useSearchParams を利用しています。
45+
46+
```tsx:NavigationDemo.tsx
47+
'use client';
48+
49+
import Link from 'next/link';
50+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
51+
import { useState } from 'react';
52+
53+
export function NavigationDemo() {
54+
const pathname = usePathname();
55+
const router = useRouter();
56+
const searchParams = useSearchParams();
57+
const [query, setQuery] = useState(searchParams.get('query') ?? '');
58+
const [currentQuery, setCurrentQuery] = useState(searchParams.get('query') ?? '');
59+
60+
const apply = () => {
61+
const next = new URLSearchParams(searchParams.toString());
62+
query ? next.set('query', query) : next.delete('query');
63+
const queryString = next.toString();
64+
router.replace(queryString ? `?${queryString}` : '?');
65+
setCurrentQuery(query);
66+
};
67+
68+
return (
69+
<div>
70+
<input value={query} onChange={(e) => setQuery(e.target.value)} className="p-2 border border-black" />
71+
<button onClick={apply} className="p-2 border border-black">Apply</button>
72+
<Link href={`${pathname}/link?query=${query}`} className="ml-2 underline">
73+
go to Link
74+
</Link>
75+
<div>current path: {pathname}</div>
76+
<div>current query: {currentQuery || '(empty)'}</div>
77+
</div>
78+
);
79+
};
80+
81+
```
82+
83+
このコンポーネントの Story は以下のように作成しました。
84+
85+
```typescript:NavigationDemo.stories.tsx
86+
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
87+
import { getRouter } from '@storybook/nextjs-vite/navigation.mock'; //useRouter()のMock
88+
import { expect, userEvent, within } from 'storybook/test';
89+
90+
import { NavigationDemo } from './NavigationDemo';
91+
92+
const meta = {
93+
component: NavigationDemo,
94+
parameters: {
95+
nextjs: {
96+
appDirectory: true,
97+
navigation: {
98+
pathname: '/demo/navigation', //Story上でURL Pathの初期値を設定可能
99+
query: { query: 'initial' }, //Story上でクエリパラメータの初期値を設定可能
100+
},
101+
},
102+
},
103+
} satisfies Meta<typeof NavigationDemo>;
104+
105+
export default meta;
106+
type Story = StoryObj<typeof meta>;
107+
108+
export const ReplaceIsCalled: Story = {
109+
async play({ canvasElement }) {
110+
const c = within(canvasElement);
111+
getRouter().replace.mockClear();
112+
113+
await userEvent.clear(await c.findByRole('textbox'));
114+
await userEvent.type(await c.findByRole('textbox'), 'hello');
115+
await expect(c.getByRole('link', { name: 'go to Link' })).toHaveAttribute(
116+
'href',
117+
'/demo/navigation/link?query=hello',
118+
);
119+
await userEvent.click(await c.findByRole('button', { name: 'Apply' }));
120+
121+
//useRouter().replace呼び出しのアサートに相当
122+
await expect(getRouter().replace).toHaveBeenCalledWith('?query=hello');
123+
},
124+
};
125+
126+
```
127+
128+
[![Image from Gyazo](https://i.gyazo.com/c71577d6b847fd171528c2eb8d1bdd62.png)](https://gyazo.com/c71577d6b847fd171528c2eb8d1bdd62)
129+
130+
ここで、Story ごとに pathname や query などを変えたい場合は、meta オブジェクトの`parameters.nextjs.navigation` を上書きします。これにより、URL に依存するコンポーネント(アクティブ状態、検索条件の表示など)を Story 単位で再現できます。
131+
132+
`parameters.nextjs.navigation` は初期状態の再現に便利ですが、「クリックで `router.push()` が呼ばれた」など、呼び出しの検証をしたいケースでは不足します。
133+
134+
そこで使うのが `@storybook/nextjs-vite/navigation.mock` です。これは `next/navigation` のモック実装に加えて、`useRouter()` 相当のルーターオブジェクトを `getRouter()` で取り出せるため、push、 replace、 back などの呼び出しを テストとして assert できます。
135+
136+
このコンポーネントの Story 上で Apply ボタンを押下すると、Actions タブに入力したクエリパラメータが出力され、ルーターオブジェクトがモックできていることが分かります。
137+
138+
`@storybook/nextjs-vite/navigation.mock` 以外のビルトインモックに関してはこちらを参照してください。([Built-in mocked modules | Storybook docs](https://storybook.js.org/docs/get-started/frameworks/nextjs-vite/?renderer=react#built-in-mocked-modules))
139+
140+
:::info
141+
ページ遷移に関わるパッケージとして他に `next/link` パッケージがあります。このパッケージに含まれる Link コンポーネントは pre-fetch 機能を備えた `<a>` タグを拡張したコンポーネントとしてよく使われます。この Link は内部で `next/navigation`、`next/router` のルーターオブジェクトを使用しているため、これらパッケージのモックと同時に Link コンポーネントもモックされるはずです。
142+
143+
しかし、Next.js(15以降〜)+ App Router 設定の Storybook では、Link コンポーネントをクリックしたときに Storybook の iframe が存在しないページへ遷移しようとするケースが報告されています。([storybookjs/storybook | GitHub](https://github.com/storybookjs/storybook/issues/30390))
144+
実際、NavigationDemo 内の「go to Link」ボタンクリックでページ遷移が発生してしまいます(Storybook v10.2.7 執筆時点)。
145+
修正されるまで、Link コンポーネントは後述するモジュールモックを用いて Storybook 上では `<a>` タグにモックするなどの対策が必要でしょう。
146+
:::
147+
148+
## React Server Componentの利用とServer functionsのモック
149+
150+
App Router では、`use client` ディレクティブを付与して明示的に Client Component としない限り、デフォルトとして React Server Components(RSC)としてコンポーネントは扱われます。
151+
特に、async function としている RSC については**そのままでは Storybook で使用できません**。
152+
153+
Storybook v10.2.7(@storybook/nextjs-vite)現在、RSC 対応は Experimental 扱いのため、RSC を Storybook 上でレンダリングする場合は明示的に機能を有効化する設定が必要です。
154+
具体的には `.storybook/main.ts` で `features.experimentalRSC: true` を指定します。
155+
156+
```typescript:main.ts
157+
import type { StorybookConfig } from '@storybook/nextjs-vite';
158+
159+
const config: StorybookConfig = {
160+
framework: '@storybook/nextjs-vite',
161+
features: {
162+
experimentalRSC: true, //RSCを利用するにはexperimentalRSC: trueとする
163+
},
164+
};
165+
166+
export default config;
167+
168+
```
169+
170+
この設定で RSC を Storybook で動作させることはできます。ただしコンポーネント内で `"server actions"` ディレクティブを付けた、 DB 接続やファイルアクセスなどのサーバー関数を呼び出す場合これも Storybook 上では実行ができません。
171+
172+
Next.js でのベストプラクティスとして、 RSC 側ではデータフェッチ関数を直接記述するのではなく、呼び出すサーバー関数を別モジュールに切り出すことが知られています。
173+
174+
Storybook ではコンポーネント内でimportするモジュールをモックできます([Mocking modules | Storybook docs](https://storybook.js.org/docs/writing-stories/mocking-data-and-modules/mocking-modules))。そこでサーバー関数を利用する場合、Storybook ではモジュールごとモックをしてしまい UI 確認用の戻り値に差し替える、という形で運用します。
175+
176+
また、Storybook では、コンポーネント単体の表示確認や振る舞いの検証が目的であるため、実際のサーバー依存処理は実行しないようにモック化した方がよいです。
177+
178+
Storybook v10.2 では、Vite/webpack 環境での推奨手段として `sb.mock()` による モジュールモックが用意されています。
179+
180+
モジュールモックの例として、以下のようなサーバー関数`getGreeting.ts`を用意しました。
181+
182+
```typescript:actions/getGreeting.ts
183+
"server actions"
184+
185+
export async function getGreeting(name: string) {
186+
// 実環境ではDBやAPIなどにアクセスする想定
187+
return `Hello, ${name}!`;
188+
}
189+
190+
```
191+
192+
この関数をモックする場合、`.storybook/preview.ts` にモックを登録します。各 Story 内ではモックの登録はできません。
193+
これにより、Story 実行前に対象モジュールが置き換えられ、Story 単位で戻り値だけを制御できます。
194+
195+
```typescript:.storybook/preview.ts
196+
import type { Preview } from '@storybook/nextjs-vite';
197+
import { sb } from 'storybook/test';
198+
199+
// モック登録は preview.ts で行う
200+
sb.mock(import('../src/server/getGreeting.ts'));
201+
202+
const preview: Preview = {
203+
parameters: {
204+
nextjs: { appDirectory: true },
205+
},
206+
};
207+
208+
export default preview;
209+
210+
```
211+
212+
モック登録の注意点として以下があります。
213+
214+
- Typescript を使用する場合(モックする関数が `.ts` の場合)、`sb.mock()` 内で `import()` を用いて記述すること
215+
- `@` のような alias の使用は不可。必ず `preview.ts` からの相対パスで記述すること
216+
- 拡張子まで含めてパスは記述すること
217+
218+
この設定で `getGreeting.ts` は Storybook 上でモック化ができます。
219+
ただしこの場合、Storybook 上では `getGreeting.ts` の機能は完全に失われます。もし、機能はそのままにスパイ関数化をしたい場合は `sb.mock()` の第2引数に `{ spy: true }` を含めます。
220+
221+
```typescript
222+
sb.mock(import('../src/server/getGreeting.ts'), { spy: true });
223+
```
224+
225+
それではこの関数を利用するコンポーネントと、その Story ファイルを作成し、Storybook 上でこのモック化した関数をどのように使用するのか見ていきます。
226+
227+
```tsx:components/GreetingPanel.tsx
228+
import { getGreeting } from '@/actions/getGreeting';
229+
230+
type Props = { name: string };
231+
232+
export async function GreetingPanel({ name }: Props) {
233+
const message = await getGreeting(name);
234+
235+
return (
236+
<div>
237+
<h3>Greeting</h3>
238+
<p>{message}</p>
239+
</div>
240+
);
241+
}
242+
243+
```
244+
245+
簡単な、`getGreeting` でメッセージを取得しそれを表示するだけのコンポーネントです。
246+
247+
```typescript:components/GreetingPanel.stories.tsx
248+
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
249+
import { expect, mocked } from 'storybook/test';
250+
import { within } from 'storybook/test';
251+
252+
import { GreetingPanel } from './GreetingPanel';
253+
import { getGreeting } from '../server/getGreeting';
254+
255+
const meta = {
256+
component: GreetingPanel,
257+
args: { name: 'Taro' },
258+
} satisfies Meta<typeof GreetingPanel>;
259+
260+
export default meta;
261+
type Story = StoryObj<typeof meta>;
262+
263+
export const Basic: Story = {
264+
// beforeEach()でモック化した関数の戻り値などの設定を行う
265+
async beforeEach() {
266+
mocked(getGreeting).mockResolvedValue('Hello from mocked function!');
267+
},
268+
async play({ canvasElement }) {
269+
const canvas = within(canvasElement);
270+
await expect(getGreeting).toHaveBeenCalledWith('Taro');
271+
await expect(canvas.getByText('Hello from mocked function!')).toBeTruthy();
272+
},
273+
};
274+
275+
```
276+
277+
GreetingPanel の Story を作成しました。
278+
Story 内でモック化した関数を利用する場合、`beforeEach()` 内でモック化関数の戻り値などの設定を行います。
279+
`beforeEach()` は各 Story で実行してもよいですし、`meta` 内 `beforeEach` 要素に記述することですべての Story に適用が可能です。
280+
281+
`mocked()` の引数に `preview.ts` で登録したモックしたい関数を渡し、その戻り値に対して、モックした関数が非同期関数である場合は `mockResolvedValue()` で戻り値を設定します。
282+
モックした関数が同期関数である場合は `mockReturnValue(value)`、モック関数に対して任意の実装を行いたい場合は `mockImplementation(fn)` を利用してください。
283+
284+
## まとめ
285+
286+
ここまで Vitest アドオンを利用したコンポーネントテストやモジュールモックなどを利用した Next.js コンポーネントのテストをご紹介しました。
287+
Storybook ではさらにアドオンを使うことで Visual Regression Test(VRT)やアクセシビリティのテストなども実行可能です。
288+
289+
学習コストは若干感じるものの、CI パイプラインへの統合が可能なことや、デプロイすることでデザイナーとイメージアップに利用できるため、使いこなせればフロントエンド開発において欠かせないツールになると感じました。
290+
Storybook は Next.js だけでなく Vue.js や Angular など幅広いフレームワークに対応しています。ご興味持たれた方は是非導入検討してみてはいかがでしょうか。

0 commit comments

Comments
 (0)