はじめに

本記事の対象者は、

  • MSWにおいて、環境変数によるモックの一括適用/解除ではなく、API単位で柔軟にモックを切り替えたい
  • MSWのハンドラー実装を汚さずに、ステータスや遅延時間を設定ファイルから一元管理したい
  • MSWを使って既存の機能は開発サーバーで動かしつつ、未完成のAPIだけをモック化して開発を進めたい

といった方向けです!

※ 本記事ではMSW自体の詳細な使い方やセットアップ方法については説明しておりません。基本的な使い方については、公式サイトや解説記事を見ていただき概要を把握した上で運用の例として本記事をご活用ください!

前提環境について
本記事のコードは React + TypeScript + Vite + MSW v2 となっております。

MSW(Mock Service Worker)とは?

MSWは、Service Workerの仕組みを利用してAPIリクエストをインターセプトし、モックデータを返してくれるライブラリです。

コードへのダミーデータの直書きや、外部モックサーバー(Prismなど)と比べた際の、主なメリットは以下の通りです。

  • プロダクションコードにモック用のロジックが混入しない
    • APIリクエストを飛ばす関数の中に、開発時だけモックデータを返すような条件分岐や早期リターンを差し込む必要がない。
  • 特定のライブラリのモック機能(axios-mock-adapterなど)に依存しない
    • Service Workerがリクエストをブラウザレベルでインターセプトし、ネットワーク通信を模倣するため、アプリ側は実サーバーと通信しているのと全く同じコードのまま動作します。
  • 開発者ツールのNetworkタブで通信をデバッグできる
    • 実際のリクエストとして処理されるため、開発者ツールのNetworkタブに履歴が残る。
    • リクエストヘッダーやレスポンスの中身を本物のAPIと同じように確認でき、開発体験が高い。
  • 複雑な状態管理や動的なAPIの再現が容易
    • ポーリング挙動を確かめる際のリクエスト回数に応じてレスポンスの特定の値を変化させるといった、状態を持つ複雑な振る舞いも柔軟に実装できる。
  • モックコードの共通化・使い回しがしやすい
    • 外部モックサーバーでは静的なJSONのベタ書きで見通しが悪くなりがちだが、MSWはプログラムとしてロジックを組めるため、重複するデータの共通化や既存定義の使い回しを柔軟に行うことができる。
    • 共通のレスポンス形式をラップして複数のエンドポイントで再利用するなど、柔軟な構築が可能。

MSW運用における課題

MSWは非常に便利ですが、プロジェクトが大きくなるにつれて以下のような運用上のしんどさが出てきます。

  • 一部のAPIだけモック化するといった柔軟な制御が面倒
    • 環境変数でモック全体をON/OFFすることは簡単だが、未完成のAPIだけモックにしたいといった部分的な適用を行うには、ハンドラーの登録処理などを細かくいじる必要が出てくる。
  • モックの切り替えにコードの書き換えが必要
    • このAPIだけ実サーバーに繋ぎたい、一時的にエラーにしたい、という時にその都度ソースコードを直接いじって戻す手間が発生する。
  • どのAPIがどんな状態かの全体像が見えにくい
    • エンドポイントが増えると、現在どのAPIがモックされているか、どれに遅延やエラー設定が入っているのかを一目で把握しづらい。
  • レビューや動作確認における共有・説明コストが高い
    • 異常系などのパターンを再現するために、毎回どのファイルをどう操作するかという個別具体的な手順を説明する必要があり、レビューのリードタイムや新規メンバーの学習コストを増大させてしまう。

これらの課題を解決するために、実装コードには手を触れず、設定ファイル一つでモックの振る舞いをコントロールできる形を目指します。

ディレクトリ構成

本記事で解説するモック周りのディレクトリ構成は以下のようになります。
設定ファイル、定数ファイル、ハンドラーの実装が明確に分かれているのが特徴です。

src/
├── main.tsx          # エントリーポイント(環境変数に応じてMSWの起動)
├── App.tsx
└── mocks/
    ├── constants.ts  # APIのパスや論理名等の静的情報を一元管理
    ├── configs.ts    # 各モックのON/OFFやステータスを管理
    ├── setup.ts      # MSWのワーカー起動と、共通のラップ処理
    └── handlers/     # エンドポイントやドメイン単位ごとのモックロジック
        ├── index.ts  # 全ハンドラーの集約
        └── posts.ts  # 投稿関連のモック実装

設定とロジックの分離

この構成の目的は、モックの挙動を変えたい時に、実装コード(ハンドラー)を直接変更しなくて済む状態を作ることです。

コアとなる役割を以下の3つに分離しています。

  1. configs.ts: モックの有効/無効、ステータスコード、遅延時間といった状態(State)のみを管理します。
  2. handlers/: APIごとの振る舞いを記述します。
  3. setup.ts: handlers の各ロジックを共通処理でラップし、configs.ts の設定内容を反映させた状態で MSW ハンドラーを構築・登録します。

これにより、開発、レビューやデバッグの際実装にコードを直接いじることなく、あらかじめハンドラーに定義しておいた様々なパターン(正常系、各種エラーステータス、通信遅延など)を configs.ts から簡単に切り替えて確認できるようになります。

本構成において、APIリクエストがモックと実サーバーのどちらへ向かうかの挙動は以下の通りです。

  • 環境変数でモック全体をOFFにしている場合
    • MSW自体が起動せず、すべてのリクエストはそのまま実サーバーへ飛ぶ
  • 対象APIのハンドラー(モック実装)が存在しない場合
    • MSWにブロックされることはなく、そのまま実サーバーへ飛ぶ
  • ハンドラーは存在するが、configs.ts で設定が存在しない、または無効(enabled: false)にしている場合
    • MSWにブロックされることはなく、そのまま実サーバーへ飛ぶ
  • ハンドラー(モック実装)が存在し、かつ configs.ts で有効(enabled: true)にしている場合
    • MSWがリクエストをインターセプトし、設定通りのステータスや遅延でモックデータを返す

実装コードの解説

それでは、各ファイルの実装を見ていきます。

1. 静的情報の定義:constants.ts

APIのパスや論理名(label)といった静的な情報を一元管理します。文字列のハードコードを避け、プロジェクト全体で一貫した値を参照できるようにします。

// src/mocks/constants.ts
import { HttpMethod } from '@/mocks/configs';

export type MockEndpointDefinition = {
  path: string;
  labels: {
    [Method in HttpMethod]?: string;
  };
};

export const MOCK_ENDPOINTS = {
  POSTS: {
    path: '/api/posts',
    labels: {
      get: '記事一覧取得API',
      post: '新規記事作成API',
    },
  },
  POST_DETAIL: {
    path: '/api/posts/:postId',
    labels: {
      get: '記事詳細取得API',
    },
  },
} as const satisfies Record<string, MockEndpointDefinition>;

2. モックの状態管理:configs.ts

モックの状態管理設定ファイルです。
純粋にモックのON/OFFやステータスコードの定義だけを行うダッシュボードとして機能させます。

今回はサンプルとして enabled(有効/無効)、status(ステータスコード)、delay(遅延)の3つを持たせていますが、プロジェクトの要件に応じて自由にオプションを追加し、よしなに拡張可能です。

// src/mocks/configs.ts
import { MOCK_ENDPOINTS } from '@/mocks/constants';

export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch';

// 1つのAPIエンドポイントが持つオプション
export interface MockOption {
  enabled: boolean;
  status?: number;
  delay?: number;
}

// APIパスごとの設定
export interface EndpointMockConfig {
  get?: MockOption;
  post?: MockOption;
  put?: MockOption;
  delete?: MockOption;
  patch?: MockOption;
}

// ここで全てのAPIの状態を一元管理
export const mockConfigs: Record<string, EndpointMockConfig> = {
  [MOCK_ENDPOINTS.POSTS.path]: {
    get: { enabled: true, status: 200 },
    post: { enabled: true, status: 201, delay: 500 },
  },
  [MOCK_ENDPOINTS.POST_DETAIL.path]: {
    get: { enabled: true, status: 200, delay: 500 },
  },
};

3. モックの実装:handlers/posts.ts

各エンドポイントごとのロジックを記述します。
configs.tsoption の値(ステータスコードなど)に応じて、返すレスポンスを切り替えます。

// src/mocks/handlers/posts.ts
import { HttpResponse } from 'msw';
import { MOCK_ENDPOINTS } from '@/mocks/constants';
import { MockEndpoint } from '@/mocks/handlers';

export const postEndpoints: MockEndpoint[] = [
  {
    label: MOCK_ENDPOINTS.POSTS.labels.get,
    method: 'get',
    path: MOCK_ENDPOINTS.POSTS.path,
    handler: (option) => () => {
      switch (option.status ?? 200) {
        case 200:
          /**
           * 本記事ではコードをシンプルにするため割愛しますが、
           * プロダクションコードのレスポンス型をimportし、HttpResponse.json<T>(...) で型を統一することで、
           * 実APIとの仕様乖離を型エラーで検知しやすくなります。
           */
          return HttpResponse.json([
            { id: 1, title: '記事タイトル1' },
            { id: 2, title: '記事タイトル2' },
          ]);
        case 400:
          return HttpResponse.json({ error: 'Bad Request' }, { status: 400 });
        case 404:
          return HttpResponse.json({ error: 'Not Found' }, { status: 404 });
        default:
          throw new Error(
            `[MSW] Not Implemented: status ${option.status} is not mocked for GET /api/posts`
          );
      }
    },
  },
  {
    label: MOCK_ENDPOINTS.POSTS.labels.post,
    method: 'post',
    path: MOCK_ENDPOINTS.POSTS.path,
    handler: (option) => () => {
      switch (option.status ?? 201) {
        case 201:
          return HttpResponse.json(
            { id: 3, message: 'Created successfully' },
            { status: 201 }
          );
        case 403:
          return HttpResponse.json({ error: 'Forbidden' }, { status: 403 });
        default:
          throw new Error(
            `[MSW] Not Implemented: status ${option.status} is not mocked for POST /api/posts`
          );
      }
    },
  },
  {
    label: MOCK_ENDPOINTS.POST_DETAIL.labels.get,
    method: 'get',
    path: MOCK_ENDPOINTS.POST_DETAIL.path,
    handler:
      (option) =>
      ({ params }) => {
        const { postId } = params;

        switch (option.status ?? 200) {
          case 200:
            return HttpResponse.json({
              id: Number(postId),
              title: `記事タイトル(ID: ${postId})`,
              content: `記事詳細`,
            });
          case 404:
            return HttpResponse.json(
              { error: 'Post Not Found' },
              { status: 404 }
            );
          default:
            throw new Error(
              `[MSW] Not Implemented: status ${option.status} is not mocked for GET /api/posts/:postId`
            );
        }
      },
  },
];

4. ハンドラーの集約:handlers/index.ts

ドメインごとに分割したハンドラーを1つの配列にまとめ上げ、ワーカーの起動処理へ渡す準備をします。

// src/mocks/handlers/index.ts
import { HttpResponseResolver } from 'msw';
import { HttpMethod, MockOption } from '@/mocks/configs';
import { postEndpoints } from '@/mocks/handlers/posts';
// 順次拡張
// import { exampleEndpoints } from '@/mocks/handlers/examples';

export interface MockEndpoint {
  label: string;
  method: HttpMethod;
  path: string;
  handler: (option: MockOption) => HttpResponseResolver;
}

// 全ドメインのハンドラーを1つの配列に集約
export const allEndpoints: MockEndpoint[] = [
  ...postEndpoints,
  // 順次拡張
  // ...exampleEndpoints,
];

5. ハンドラー生成:setup.ts

MSWのセットアップと、各ハンドラーに設定を適用するためのラップ処理を行います。mockConfigs の設定値と handlers のロジックを掛け合わせ、MSWに登録するための実行用ハンドラーを生成します。

// src/mocks/setup.ts
import {
  http,
  delay,
  passthrough,
  DefaultBodyType,
  HttpRequestResolverExtras,
  PathParams,
  ResponseResolverInfo,
} from 'msw';
import { setupWorker } from 'msw/browser';

import { mockConfigs } from '@/mocks/configs';
import { MockEndpoint, allEndpoints } from '@/mocks/handlers';

/**
 * 共通ラップ処理
 * 起動時に設定を読み込み、静的で無駄のないMSWハンドラを生成する
 */
const toMswHandler = (endpoint: MockEndpoint) => {
  // 対象オプション取得
  const option = mockConfigs[endpoint.path]?.[endpoint.method];

  // エンドポイント組立
  const fullEndpointPath = `${import.meta.env.VITE_API_BASE ?? ''}${endpoint.path}`;

  // モック無効もしくは設定が存在しない場合スルーするだけの処理を返す
  if (!option?.enabled)
    return http[endpoint.method](fullEndpointPath, () => passthrough());

  // オプションを適用した実行用ハンドラを生成
  const mswHandler = endpoint.handler(option);

  // APIが叩かれた時の処理
  const resolver = async (
    context: ResponseResolverInfo<
      HttpRequestResolverExtras<PathParams>,
      DefaultBodyType
    >
  ) => {
    // 遅延処理
    if (option.delay) await delay(option.delay);

    // 生成済みのハンドラにリクエスト情報を渡して実行
    return mswHandler(context);
  };

  return http[endpoint.method](fullEndpointPath, resolver);
};

// ハンドラー群を生成
const handlers = allEndpoints.map(toMswHandler);

// ハンドラーを登録したワーカーインスタンスを生成
export const worker = setupWorker(...handlers);

6. MSW起動:main.tsx

最後に、レンダリング前にMSWが確実に起動するよう制御します。
MSWが起動する前にloaderなどのデータフェッチ処理が走ってしまい、意図しない挙動が発生する可能性があるためです。

// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';

const isMockEnabled = import.meta.env.VITE_ENABLE_MOCK === 'true';

/**
 * 全体モック設定が有効の場合先にMSWを起動して待つ
 */
if (isMockEnabled) {
  const { worker } = await import('@/mocks/setup');
  await worker.start({ onUnhandledRequest: 'bypass' });
}

/**
 * MSWの準備が終わってからApp本体をインポート
 */
const { default: App } = await import('@/App.tsx');

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

まとめ

本記事では、MSWの設定とロジックを分離し、APIごとにモックを制御しやすくする構成を紹介しました。
この形にしておくと、実装コードに触れずに設定ファイルだけで様々なパターンを柔軟に切り替えられるようになります。
あとはプロジェクトやチームの要件に合わせてよしなに拡張・共通化、ファイル分割等していく形が良いかなと思います。
MSWを導入する際の運用例として参考になれば幸いです!