🔖
Storybookで複雑なNext.jsコンポーネントを表示する:subpath importsを使った効果的なモック戦略
はじめに
Next.jsアプリケーションでStorybookを使用する際、useSearchParams
やsignIn
などのNext.js固有のフックや外部ライブラリに依存するコンポーネントをStorybookで表示するのは困難です。従来のモック方法では複雑になりがちで、保守性に課題がありました。
本記事では、Storybook公式が推奨するSubpath Importsを使用した効果的なモック戦略を紹介します。この方法により、型安全性を保ちながら、再利用可能で保守しやすいモック環境を構築できます。
従来のモック方法の課題
問題のあるアプローチ
// ❌ 従来の問題のあるアプローチ
import * as NextNavigation from 'next/navigation';
import * as NextAuthReact from 'next-auth/react';
// decoratorsでランタイムにモックを設定
decorators: [
(Story) => {
(NextNavigation as any).useSearchParams = mockFunction;
(NextAuthReact as any).signIn = mockFunction;
return <Story />;
},
],
課題
-
型安全性の欠如:
any
型を使用することで型チェックが無効化 - 保守性の低下: モックロジックがStoriesファイルに散在
- 再利用性の欠如: 同じモックを他のコンポーネントで使い回せない
Subpath Importsを使った解決策
Subpath ImportsはNode.jsとTypeScript(5.4以降)でサポートされている機能で、package.jsonのimportsフィールドを使って特定の条件(例: Storybook環境)に応じてインポート先のファイルを切り替える仕組みです。
1. package.jsonでのsubpath imports設定
{
"imports": {
"#next/navigation": {
"storybook": "./src/__mocks__/next-navigation.mock.ts",
"default": "next/navigation"
},
"#next-auth/react": {
"storybook": "./src/__mocks__/next-auth-react.mock.ts",
"default": "next-auth/react"
}
}
}
ポイント:
-
#
で始まるパスはsubpath importsの識別子 -
storybook
条件でStorybookでのみモックファイルを使用 -
default
条件で通常の実行時は元のモジュールを使用
2. 専用モックファイルの作成
Next.js Navigation Mock
// src/__mocks__/next-navigation.mock.ts
import { fn } from '@storybook/test';
export const useSearchParams = fn(() => ({
get: fn(() => null),
}));
export const useRouter = fn(() => ({
push: fn(),
replace: fn(),
back: fn(),
forward: fn(),
refresh: fn(),
prefetch: fn(),
}));
export const usePathname = fn(() => '/');
// 元のモジュールの他のエクスポートも再エクスポート
export * from 'next/navigation';
NextAuth.js Mock
// src/__mocks__/next-auth-react.mock.ts
import { fn } from '@storybook/test';
export const signIn = fn().mockName('signIn');
export const signOut = fn().mockName('signOut');
export const useSession = fn(() => ({
data: null,
status: 'unauthenticated',
}));
export const getSession = fn().mockName('getSession');
export * from 'next-auth/react';
重要なポイント:
-
fn()
でVitest互換のモック関数を作成 -
mockName()
でミニファイ時の関数名を保持 - 元のモジュールの全エクスポートを再エクスポート
3. Storybook設定の更新
main.ts設定
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/nextjs';
const config: StorybookConfig = {
// ... 他の設定
webpackFinal: async (config) => {
if (config.resolve) {
config.resolve.alias = {
...config.resolve.alias,
'#next/navigation': require.resolve('../src/__mocks__/next-navigation.mock.ts'),
'#next-auth/react': require.resolve('../src/__mocks__/next-auth-react.mock.ts'),
};
}
return config;
},
};
TypeScript設定
// tsconfig.json
{
"compilerOptions": {
"moduleResolution": "bundler",
"paths": {
"@tengencho/*": ["./src/*"],
"#next/navigation": ["./src/__mocks__/next-navigation.mock.ts"],
"#next-auth/react": ["./src/__mocks__/next-auth-react.mock.ts"]
}
}
}
4. 簡潔なStoriesファイル
// LoginForm.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
// モックファイルから直接インポート(Storybookでは自動的にモック版が使用される)
import { useSearchParams } from '#next/navigation';
import LoginForm from './LoginForm';
const meta: Meta<typeof LoginForm> = {
title: 'components/parts/login/LoginForm',
component: LoginForm,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
// エラー状態のストーリー
export const WithError: Story = {
beforeEach: async () => {
(useSearchParams as any).mockReturnValue({
get: fn((key: string) => {
if (key === 'hasError') return 'true';
return null;
}),
});
},
};
この方法の利点
1. 型安全性の向上
- TypeScriptの型チェックが正常に動作
- IDEでの自動補完とエラー検出
2. 保守性の向上
- モックロジックが専用ファイルに集約
- 変更時の影響範囲が明確
3. 再利用性
- 同じモックファイルを複数のコンポーネントで使用可能
- テスト環境でも同じモックを利用可能
4. 開発体験の向上
- Storiesファイルがシンプルで読みやすい
- モックの動作をストーリーごとに細かく制御可能
実装時の注意点
1. moduleResolutionの設定
{
"compilerOptions": {
"moduleResolution": "bundler" // または "NodeNext", "Node16"
}
}
subpath importsはmoduleResolution
がbundler
、NodeNext
、またはNode16
の場合のみ正しく動作します。
2. 外部モジュールのモック
外部モジュール(例:uuid
)は直接モックできないため、ラッパーモジュールを作成します:
// lib/uuid.ts
import { v4 } from 'uuid';
export const uuidv4 = v4;
// lib/uuid.mock.ts
import { fn } from '@storybook/test';
import * as actual from './uuid';
export const uuidv4 = fn(actual.uuidv4).mockName('uuidv4');
3. 条件の順序
package.jsonでの条件の順序は重要です。default
は必ず最後に配置してください:
{
"#module": {
"storybook": "./mock.ts",
"test": "./test-mock.ts",
"default": "./actual.ts" // 必ず最後
}
}
Discussion