T-CREATOR

Storybook のモックデータ管理最適解

Storybook のモックデータ管理最適解

Storybook でコンポーネント開発をしていると、必ず直面するのがモックデータの管理です。美しい UI コンポーネントを作っても、適切なデータがなければその真価を発揮できません。

実際の開発現場では、API レスポンスの待機、データの不整合、環境依存の問題など、モックデータに関連する課題が山積みになっています。この記事では、そんな課題を解決し、効率的で保守性の高いモックデータ管理システムを構築する方法をご紹介します。

Storybook におけるモックデータの重要性

Storybook は、コンポーネントを独立した環境で開発・テストできる素晴らしいツールです。しかし、その真価を最大限に引き出すためには、適切なモックデータが不可欠です。

モックデータがもたらす効果

モックデータを適切に管理することで、以下のような効果が期待できます:

  • 開発効率の向上: API レスポンスを待たずに即座にコンポーネントを確認
  • エッジケースの検証: 異常値やエラー状態のテストが容易
  • デザインの一貫性: 統一されたデータでデザインシステムの検証
  • チーム協業の促進: 共通のモックデータで認識合わせがスムーズ

実際の開発現場では、以下のような状況が頻繁に発生します:

typescript// よくある問題の例
const UserProfile = () => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // APIが遅い、または失敗する可能性
    fetchUserData()
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // 開発中は常にloading状態で、実際のUIが確認できない
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return <div>{user?.name}</div>;
};

このような状況では、開発者は実際のコンポーネントの見た目や動作を確認できず、開発効率が大幅に低下してしまいます。

モックデータ管理の課題と問題点

Storybook でのモックデータ管理には、いくつかの重要な課題が存在します。これらの課題を理解することで、適切な解決策を見つけることができます。

よくある課題とその影響

1. データの散在化

typescript// Story1.tsx
export const Default = () => (
  <UserCard
    user={{
      id: 1,
      name: '田中太郎',
      email: 'tanaka@example.com',
    }}
  />
);

// Story2.tsx
export const WithAvatar = () => (
  <UserCard
    user={{
      id: 2,
      name: '佐藤花子',
      email: 'sato@example.com',
      avatar: '/avatar.jpg',
    }}
  />
);

// Story3.tsx
export const Loading = () => (
  <UserCard
    user={{
      id: 3,
      name: '山田次郎',
      email: 'yamada@example.com',
    }}
  />
);

このように、同じようなデータが各 Story ファイルに散在していると、データの更新や管理が困難になります。

2. 型安全性の欠如

typescript// 型定義がないモックデータ
const mockUser = {
  id: 1,
  name: 'テストユーザー',
  // 実際のAPIレスポンスと異なる構造
  profile: {
    age: 25,
    location: '東京',
  },
};

// 実際のAPIレスポンス
interface User {
  id: number;
  name: string;
  email: string;
  profile: {
    age: number;
    location: string;
    bio?: string; // 必須フィールドが不足
  };
}

型定義とモックデータが一致していないと、実際の環境との差異が生まれ、開発時の混乱を招きます。

3. 環境依存の問題

typescript// 開発環境でのみ動作するモック
const mockApiResponse = {
  users: [{ id: 1, name: '開発用ユーザー' }],
};

// 本番環境では異なる構造
const productionApiResponse = {
  data: {
    users: [
      {
        id: 1,
        name: '本番ユーザー',
        createdAt: '2024-01-01',
      },
    ],
  },
  meta: {
    total: 1,
    page: 1,
  },
};

環境によって API レスポンスの構造が異なる場合、モックデータが実際の環境と乖離してしまいます。

4. パフォーマンスの問題

typescript// 大量のデータを毎回生成
const generateLargeDataset = () => {
  return Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    name: `ユーザー${i}`,
    email: `user${i}@example.com`,
    // 大量の不要なデータ
    metadata: {
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
      // ... その他の大量データ
    },
  }));
};

不適切なモックデータは、Storybook の起動時間やメモリ使用量に悪影響を与えます。

最適なモックデータ管理手法

これらの課題を解決するために、体系的なモックデータ管理手法を実装しましょう。まずは基本的な構造から始めて、段階的に改善していきます。

モックデータの階層構造

効率的なモックデータ管理のため、以下の階層構造を採用します:

typescript// src/mocks/types.ts
// 型定義を一元管理
export interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
  profile: {
    age: number;
    location: string;
    bio?: string;
  };
  createdAt: string;
  updatedAt: string;
}

export interface ApiResponse<T> {
  data: T;
  meta: {
    total: number;
    page: number;
    limit: number;
  };
}
typescript// src/mocks/factories/userFactory.ts
// ファクトリーパターンでモックデータを生成
import { faker } from '@faker-js/faker/locale/ja';
import { User } from '../types';

export class UserFactory {
  static create(overrides: Partial<User> = {}): User {
    return {
      id: faker.number.int({ min: 1, max: 1000 }),
      name: faker.person.fullName(),
      email: faker.internet.email(),
      avatar: faker.image.avatar(),
      profile: {
        age: faker.number.int({ min: 18, max: 80 }),
        location: faker.location.city(),
        bio: faker.lorem.sentence(),
      },
      createdAt: faker.date.past().toISOString(),
      updatedAt: faker.date.recent().toISOString(),
      ...overrides,
    };
  }

  static createMany(
    count: number,
    overrides: Partial<User> = {}
  ): User[] {
    return Array.from({ length: count }, () =>
      this.create(overrides)
    );
  }
}

エラー状態のモックデータ

実際の開発では、エラー状態のテストも重要です。以下のようにエラーケースも含めたモックデータを作成します:

typescript// src/mocks/scenarios/errorScenarios.ts
// エラーケースのシナリオを定義
export const errorScenarios = {
  networkError: {
    status: 500,
    message: 'Internal Server Error',
    code: 'NETWORK_ERROR',
  },

  validationError: {
    status: 400,
    message: 'Invalid input data',
    code: 'VALIDATION_ERROR',
    details: {
      email: 'Invalid email format',
      name: 'Name is required',
    },
  },

  notFoundError: {
    status: 404,
    message: 'User not found',
    code: 'NOT_FOUND',
  },
};

MSW(Mock Service Worker)を活用した統合管理

MSW は、API モックのための強力なツールです。Storybook と組み合わせることで、実際の API コールを完全にモック化できます。

MSW のセットアップ

まず、MSW をプロジェクトにインストールします:

bashyarn add -D msw
typescript// src/mocks/handlers.ts
// APIハンドラーを定義
import { http, HttpResponse } from 'msw';
import { UserFactory } from './factories/userFactory';

export const handlers = [
  // ユーザー一覧取得
  http.get('/api/users', ({ request }) => {
    const url = new URL(request.url);
    const page = parseInt(
      url.searchParams.get('page') || '1'
    );
    const limit = parseInt(
      url.searchParams.get('limit') || '10'
    );

    const users = UserFactory.createMany(limit);

    return HttpResponse.json({
      data: users,
      meta: {
        total: 100,
        page,
        limit,
      },
    });
  }),

  // 個別ユーザー取得
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;

    if (id === '999') {
      return new HttpResponse(null, {
        status: 404,
        statusText: 'Not Found',
      });
    }

    const user = UserFactory.create({ id: Number(id) });
    return HttpResponse.json({ data: user });
  }),

  // ユーザー作成
  http.post('/api/users', async ({ request }) => {
    const body = await request.json();

    if (!body.name || !body.email) {
      return new HttpResponse(
        JSON.stringify({
          message: 'Validation failed',
          errors: {
            name: body.name
              ? undefined
              : 'Name is required',
            email: body.email
              ? undefined
              : 'Email is required',
          },
        }),
        { status: 400 }
      );
    }

    const newUser = UserFactory.create(body);
    return HttpResponse.json(
      { data: newUser },
      { status: 201 }
    );
  }),
];

Storybook での MSW 統合

typescript// .storybook/preview.ts
// StorybookでMSWを有効化
import { initialize, mswLoader } from 'msw-storybook-addon';

// MSWの初期化
initialize();

export const parameters = {
  actions: { argTypesRegex: '^on[A-Z].*' },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
};

export const loaders = [mswLoader];
typescript// src/stories/UserCard.stories.tsx
// MSWを使用したStory
import type { Meta, StoryObj } from '@storybook/react';
import { UserCard } from '../components/UserCard';
import { handlers } from '../mocks/handlers';

const meta: Meta<typeof UserCard> = {
  title: 'Components/UserCard',
  component: UserCard,
  parameters: {
    msw: {
      handlers: handlers,
    },
  },
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
  args: {
    userId: 1,
  },
};

export const Loading: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/users/:id', () => {
          return HttpResponse.delay(2000);
        }),
      ],
    },
  },
  args: {
    userId: 1,
  },
};

export const Error: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/users/:id', () => {
          return new HttpResponse(null, { status: 404 });
        }),
      ],
    },
  },
  args: {
    userId: 999,
  },
};

Storybook 専用モックライブラリの活用

MSW に加えて、Storybook 専用のモックライブラリを活用することで、より柔軟で強力なモックシステムを構築できます。

Storybook Addon MSW

typescript// .storybook/main.ts
// Storybookの設定にMSWアドオンを追加
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  stories: [
    '../src/**/*.mdx',
    '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
  ],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-onboarding',
    '@storybook/addon-interactions',
    'msw-storybook-addon', // MSWアドオンを追加
  ],
  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
  docs: {
    autodocs: 'tag',
  },
};

export default config;

カスタムモックアドオンの作成

typescript// src/mocks/addons/mockDataAddon.ts
// カスタムモックアドオン
import { addons, types } from '@storybook/addons';
import { ADDON_ID, PANEL_ID } from './constants';

addons.register(ADDON_ID, () => {
  addons.add(PANEL_ID, {
    type: types.PANEL,
    title: 'Mock Data',
    match: ({ viewMode }) => viewMode === 'story',
    render: () => {
      // モックデータの管理パネルを実装
      return document.createElement('div');
    },
  });
});

動的モックデータの生成

typescript// src/mocks/utils/dynamicMockGenerator.ts
// 動的にモックデータを生成するユーティリティ
export class DynamicMockGenerator {
  static generateUserData(
    scenario: 'normal' | 'error' | 'loading' = 'normal'
  ) {
    switch (scenario) {
      case 'normal':
        return UserFactory.create();
      case 'error':
        throw new Error('User data generation failed');
      case 'loading':
        return new Promise((resolve) =>
          setTimeout(
            () => resolve(UserFactory.create()),
            2000
          )
        );
      default:
        return UserFactory.create();
    }
  }

  static generateListData(
    count: number,
    filters?: Record<string, any>
  ) {
    let users = UserFactory.createMany(count);

    if (filters) {
      users = users.filter((user) => {
        return Object.entries(filters).every(
          ([key, value]) => {
            return user[key as keyof typeof user] === value;
          }
        );
      });
    }

    return users;
  }
}

環境別モックデータの使い分け

開発環境、テスト環境、本番環境など、環境によって異なるモックデータが必要な場合があります。効率的に管理する方法をご紹介します。

環境別設定の管理

typescript// src/mocks/config/environment.ts
// 環境別の設定を管理
export const mockConfig = {
  development: {
    apiDelay: 100,
    errorRate: 0.1,
    dataSize: 'small',
  },
  test: {
    apiDelay: 0,
    errorRate: 0,
    dataSize: 'minimal',
  },
  staging: {
    apiDelay: 500,
    errorRate: 0.05,
    dataSize: 'medium',
  },
  production: {
    apiDelay: 1000,
    errorRate: 0.02,
    dataSize: 'large',
  },
};

export const getCurrentEnvironment = () => {
  return process.env.NODE_ENV || 'development';
};

export const getMockConfig = () => {
  const env = getCurrentEnvironment();
  return (
    mockConfig[env as keyof typeof mockConfig] ||
    mockConfig.development
  );
};

環境別ハンドラーの実装

typescript// src/mocks/handlers/environmentHandlers.ts
// 環境別のハンドラーを実装
import { http, HttpResponse } from 'msw';
import { getMockConfig } from '../config/environment';

export const createEnvironmentHandlers = () => {
  const config = getMockConfig();

  return [
    http.get('/api/users', async () => {
      // 環境に応じた遅延を追加
      if (config.apiDelay > 0) {
        await new Promise((resolve) =>
          setTimeout(resolve, config.apiDelay)
        );
      }

      // 環境に応じたエラー率を設定
      if (Math.random() < config.errorRate) {
        return new HttpResponse(null, { status: 500 });
      }

      const dataSize =
        config.dataSize === 'large'
          ? 100
          : config.dataSize === 'medium'
          ? 50
          : 10;

      const users = UserFactory.createMany(dataSize);

      return HttpResponse.json({
        data: users,
        meta: {
          total: dataSize,
          page: 1,
          limit: dataSize,
        },
      });
    }),
  ];
};

Storybook での環境切り替え

typescript// .storybook/preview.ts
// 環境別の設定をStorybookに適用
import { createEnvironmentHandlers } from '../src/mocks/handlers/environmentHandlers';

export const parameters = {
  msw: {
    handlers: createEnvironmentHandlers(),
  },
  // 環境変数をStorybookで利用可能にする
  env: {
    NODE_ENV: process.env.NODE_ENV || 'development',
  },
};

パフォーマンス最適化のテクニック

モックデータの管理において、パフォーマンスは重要な要素です。効率的で軽量なモックシステムを構築する方法をご紹介します。

メモ化による最適化

typescript// src/mocks/utils/memoizedMockData.ts
// メモ化によるパフォーマンス最適化
class MockDataCache {
  private cache = new Map<string, any>();
  private maxSize = 100;

  get(key: string) {
    return this.cache.get(key);
  }

  set(key: string, value: any) {
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }

  clear() {
    this.cache.clear();
  }
}

const mockCache = new MockDataCache();

export const getMemoizedUserData = (userId: number) => {
  const cacheKey = `user_${userId}`;
  let userData = mockCache.get(cacheKey);

  if (!userData) {
    userData = UserFactory.create({ id: userId });
    mockCache.set(cacheKey, userData);
  }

  return userData;
};

遅延ローディングの実装

typescript// src/mocks/utils/lazyMockData.ts
// 遅延ローディングによる最適化
export class LazyMockDataLoader {
  private static instance: LazyMockDataLoader;
  private loadedData = new Set<string>();

  static getInstance() {
    if (!this.instance) {
      this.instance = new LazyMockDataLoader();
    }
    return this.instance;
  }

  async loadUserData(userId: number) {
    const key = `user_${userId}`;

    if (this.loadedData.has(key)) {
      return getMemoizedUserData(userId);
    }

    // 実際のAPIコールをシミュレート
    await new Promise((resolve) =>
      setTimeout(resolve, 100)
    );

    const userData = UserFactory.create({ id: userId });
    this.loadedData.add(key);

    return userData;
  }

  preloadUserData(userIds: number[]) {
    return Promise.all(
      userIds.map((id) => this.loadUserData(id))
    );
  }
}

バッチ処理の最適化

typescript// src/mocks/handlers/optimizedHandlers.ts
// バッチ処理による最適化
import { http, HttpResponse } from 'msw';
import { LazyMockDataLoader } from '../utils/lazyMockData';

export const optimizedHandlers = [
  // バッチでユーザーデータを取得
  http.post('/api/users/batch', async ({ request }) => {
    const { userIds } = await request.json();

    const loader = LazyMockDataLoader.getInstance();
    const users = await loader.preloadUserData(userIds);

    return HttpResponse.json({
      data: users,
      meta: {
        total: users.length,
        batchSize: userIds.length,
      },
    });
  }),

  // ページネーション最適化
  http.get('/api/users', ({ request }) => {
    const url = new URL(request.url);
    const page = parseInt(
      url.searchParams.get('page') || '1'
    );
    const limit = parseInt(
      url.searchParams.get('limit') || '10'
    );

    // 必要な分だけデータを生成
    const startIndex = (page - 1) * limit;
    const users = UserFactory.createMany(limit).map(
      (user, index) => ({
        ...user,
        id: startIndex + index + 1,
      })
    );

    return HttpResponse.json({
      data: users,
      meta: {
        total: 1000,
        page,
        limit,
        hasNext: page * limit < 1000,
        hasPrev: page > 1,
      },
    });
  }),
];

チーム開発でのモックデータ共有戦略

チーム開発では、モックデータの共有と標準化が重要です。効率的な共有戦略を実装しましょう。

モックデータのバージョン管理

typescript// src/mocks/versioning/mockVersion.ts
// モックデータのバージョン管理
export interface MockVersion {
  version: string;
  schema: Record<string, any>;
  createdAt: string;
  updatedAt: string;
}

export class MockVersionManager {
  private static currentVersion = '1.0.0';

  static getCurrentVersion(): string {
    return this.currentVersion;
  }

  static validateMockData(data: any, schema: any): boolean {
    // スキーマ検証の実装
    return true;
  }

  static migrateMockData(
    data: any,
    fromVersion: string,
    toVersion: string
  ) {
    // バージョン間のデータ移行ロジック
    return data;
  }
}

チーム共有用のモックライブラリ

typescript// src/mocks/shared/teamMockLibrary.ts
// チーム共有用のモックライブラリ
export class TeamMockLibrary {
  private static sharedMocks = new Map<string, any>();

  static registerMock(name: string, mockData: any) {
    this.sharedMocks.set(name, {
      data: mockData,
      createdAt: new Date().toISOString(),
      createdBy: process.env.USER || 'unknown',
    });
  }

  static getMock(name: string) {
    const mock = this.sharedMocks.get(name);
    if (!mock) {
      throw new Error(`Mock "${name}" not found`);
    }
    return mock.data;
  }

  static listMocks() {
    return Array.from(this.sharedMocks.keys());
  }

  static exportMocks() {
    return Object.fromEntries(this.sharedMocks);
  }

  static importMocks(mocks: Record<string, any>) {
    Object.entries(mocks).forEach(([name, mock]) => {
      this.registerMock(name, mock.data);
    });
  }
}

モックデータのドキュメント化

typescript// src/mocks/docs/mockDocumentation.ts
// モックデータのドキュメント化
export interface MockDocumentation {
  name: string;
  description: string;
  schema: Record<string, any>;
  examples: any[];
  usage: string;
  tags: string[];
}

export class MockDocumentationGenerator {
  static generateDocs(
    mockName: string,
    mockData: any
  ): MockDocumentation {
    return {
      name: mockName,
      description: `Mock data for ${mockName}`,
      schema: this.inferSchema(mockData),
      examples: [mockData],
      usage: this.generateUsageExample(mockName),
      tags: this.extractTags(mockData),
    };
  }

  private static inferSchema(
    data: any
  ): Record<string, any> {
    // スキーマ推論ロジック
    return {};
  }

  private static generateUsageExample(
    mockName: string
  ): string {
    return `
// 使用方法
import { TeamMockLibrary } from './teamMockLibrary';

const mockData = TeamMockLibrary.getMock('${mockName}');
    `.trim();
  }

  private static extractTags(data: any): string[] {
    // タグ抽出ロジック
    return ['user', 'profile'];
  }
}

CI/CD でのモックデータ検証

yaml# .github/workflows/mock-validation.yml
# CI/CDでのモックデータ検証
name: Mock Data Validation

on:
  pull_request:
    paths:
      - 'src/mocks/**'

jobs:
  validate-mocks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'yarn'

      - name: Install dependencies
        run: yarn install

      - name: Validate mock data
        run: yarn validate:mocks

      - name: Run Storybook tests
        run: yarn test:storybook
typescript// scripts/validateMocks.ts
// モックデータ検証スクリプト
import { MockVersionManager } from '../src/mocks/versioning/mockVersion';
import { TeamMockLibrary } from '../src/mocks/shared/teamMockLibrary';

async function validateMocks() {
  const mocks = TeamMockLibrary.listMocks();
  let hasErrors = false;

  for (const mockName of mocks) {
    try {
      const mockData = TeamMockLibrary.getMock(mockName);

      // スキーマ検証
      if (
        !MockVersionManager.validateMockData(mockData, {})
      ) {
        console.error(
          `❌ Mock "${mockName}" failed schema validation`
        );
        hasErrors = true;
      }

      // 型安全性チェック
      if (!validateTypeSafety(mockData)) {
        console.error(
          `❌ Mock "${mockName}" failed type safety check`
        );
        hasErrors = true;
      }

      console.log(
        `✅ Mock "${mockName}" validated successfully`
      );
    } catch (error) {
      console.error(
        `❌ Error validating mock "${mockName}":`,
        error
      );
      hasErrors = true;
    }
  }

  if (hasErrors) {
    process.exit(1);
  }

  console.log('🎉 All mocks validated successfully!');
}

function validateTypeSafety(data: any): boolean {
  // 型安全性チェックの実装
  return true;
}

validateMocks();

まとめ

Storybook でのモックデータ管理は、一見複雑に見えますが、適切なアプローチを取ることで、効率的で保守性の高いシステムを構築できます。

この記事でご紹介した手法を実践することで、以下のような効果が期待できます:

  • 開発効率の大幅な向上: 適切なモックデータにより、API レスポンスを待たずにコンポーネントの開発・テストが可能
  • 品質の向上: エッジケースやエラー状態のテストが容易になり、バグの早期発見が可能
  • チーム協業の促進: 統一されたモックデータにより、認識合わせがスムーズになり、開発速度が向上
  • 保守性の向上: 体系的なモックデータ管理により、長期的なメンテナンスが容易

特に重要なのは、モックデータを「一時的なもの」として扱うのではなく、「開発プロセスの重要な一部」として位置づけることです。適切に設計されたモックシステムは、プロジェクトの成功に大きく貢献します。

実際の開発現場では、これらの手法を段階的に導入し、チームの状況に合わせてカスタマイズしていくことをお勧めします。最初は小さく始めて、徐々に改善していくことで、無理なく効果的なモックデータ管理システムを構築できます。

モックデータ管理の最適化は、Storybook の真価を最大限に引き出すための重要な要素です。この記事でご紹介した手法を参考に、あなたのプロジェクトに最適なモックデータ管理システムを構築してください。

関連リンク