🔖

Storybookで複雑なNext.jsコンポーネントを表示する:subpath importsを使った効果的なモック戦略

に公開

はじめに

Next.jsアプリケーションでStorybookを使用する際、useSearchParamssignInなどの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 />;
  },
],

課題

  1. 型安全性の欠如: any型を使用することで型チェックが無効化
  2. 保守性の低下: モックロジックがStoriesファイルに散在
  3. 再利用性の欠如: 同じモックを他のコンポーネントで使い回せない

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はmoduleResolutionbundlerNodeNext、または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