MSW(Mock Service Worker)でAPI モックテスト実践


はじめに

フロントエンド開発において、APIとの通信をテストすることは避けて通れない。しかし、実際のバックエンドサーバーに依存したテストは不安定で遅い。

Mock Service Worker(MSW)は、Service Workerを活用してネットワークレベルでAPIリクエストをインターセプトし、モックレスポンスを返すライブラリだ。従来のaxiosやfetchのモックと異なり、アプリケーションコードを一切変更せずにAPIモックが実現できる。

この記事では、MSW 2.xの導入からREST/GraphQLのハンドラー定義、Vitest・Playwrightとの連携、ネットワーク境界テストのベストプラクティスまで、TypeScriptの実装例付きで解説する。


MSWの基本概念

なぜMSWなのか

従来のAPIモック手法とMSWの違いを理解しておこう。

手法仕組みメリットデメリット
jest.mock / vi.mockモジュール差し替え設定が簡単実装の詳細に依存する
nockNode.js httpモジュールをパッチサーバーサイドで使いやすいブラウザでは使えない
json-serverローカルRESTサーバー実サーバーに近い別プロセスの起動が必要
MSWService Worker / Node.jsインターセプターネットワークレベルでインターセプトService Workerの理解が必要

MSWの最大の強みは「ネットワーク境界でのインターセプト」だ。fetch、axios、GraphQLクライアントなど、どのHTTPクライアントを使っていても同じハンドラーでモックできる。

MSW 2.xのアーキテクチャ

MSW 2.xは以下の2つの動作モードを持つ。

  • ブラウザモード: Service Workerを登録してリクエストをインターセプト
  • Node.jsモード: @mswjs/interceptorsでNode.jsのHTTPモジュールをインターセプト

テスト環境(Vitest、Jest)ではNode.jsモード、ブラウザでの開発時にはブラウザモードを使い分ける。


セットアップ

インストール

# npmの場合
npm install msw --save-dev

# pnpmの場合
pnpm add -D msw

# yarnの場合
yarn add -D msw

ブラウザモード用のService Worker生成

# publicディレクトリにService Workerファイルを生成
npx msw init public/ --save

このコマンドでpublic/mockServiceWorker.jsが生成される。このファイルはMSWが管理するため、手動で編集しないこと。

ディレクトリ構成

以下のような構成を推奨する。

src/
├── mocks/
│   ├── handlers/
│   │   ├── user.ts        # ユーザー関連のハンドラー
│   │   ├── post.ts        # 投稿関連のハンドラー
│   │   └── index.ts       # ハンドラーの集約
│   ├── fixtures/
│   │   ├── users.ts       # ユーザーのテストデータ
│   │   └── posts.ts       # 投稿のテストデータ
│   ├── browser.ts         # ブラウザモードのセットアップ
│   ├── server.ts          # Node.jsモードのセットアップ
│   └── handlers.ts        # 全ハンドラーのエクスポート

RESTful APIのモック

型定義とフィクスチャ

まずはAPIレスポンスの型とテストデータを定義する。

// src/mocks/fixtures/users.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user" | "editor";
  createdAt: string;
}

export const mockUsers: User[] = [
  {
    id: 1,
    name: "田中太郎",
    email: "tanaka@example.com",
    role: "admin",
    createdAt: "2026-01-15T09:00:00Z",
  },
  {
    id: 2,
    name: "鈴木花子",
    email: "suzuki@example.com",
    role: "editor",
    createdAt: "2026-02-20T14:30:00Z",
  },
  {
    id: 3,
    name: "佐藤健一",
    email: "sato@example.com",
    role: "user",
    createdAt: "2026-03-01T10:00:00Z",
  },
];
// src/mocks/fixtures/posts.ts
export interface Post {
  id: number;
  title: string;
  body: string;
  authorId: number;
  status: "draft" | "published" | "archived";
  tags: string[];
  createdAt: string;
  updatedAt: string;
}

export const mockPosts: Post[] = [
  {
    id: 1,
    title: "TypeScriptの型安全性について",
    body: "TypeScriptの型システムは...",
    authorId: 1,
    status: "published",
    tags: ["TypeScript", "programming"],
    createdAt: "2026-03-01T09:00:00Z",
    updatedAt: "2026-03-01T09:00:00Z",
  },
  {
    id: 2,
    title: "React Server Componentsの実践",
    body: "RSCを活用することで...",
    authorId: 2,
    status: "published",
    tags: ["React", "Next.js"],
    createdAt: "2026-03-05T14:00:00Z",
    updatedAt: "2026-03-06T10:00:00Z",
  },
];

RESTハンドラーの定義

// src/mocks/handlers/user.ts
import { http, HttpResponse, delay } from "msw";
import { mockUsers, type User } from "../fixtures/users";

const BASE_URL = "https://api.example.com";

// インメモリデータストア(テスト間で状態を保持)
let users = [...mockUsers];

export const userHandlers = [
  // GET /api/users - ユーザー一覧取得
  http.get(`${BASE_URL}/api/users`, async ({ request }) => {
    const url = new URL(request.url);
    const role = url.searchParams.get("role");
    const page = parseInt(url.searchParams.get("page") || "1");
    const limit = parseInt(url.searchParams.get("limit") || "10");

    let filtered = [...users];

    // ロールでフィルタリング
    if (role) {
      filtered = filtered.filter((u) => u.role === role);
    }

    // ページネーション
    const start = (page - 1) * limit;
    const paginated = filtered.slice(start, start + limit);

    await delay(100); // ネットワーク遅延のシミュレーション

    return HttpResponse.json({
      data: paginated,
      total: filtered.length,
      page,
      limit,
    });
  }),

  // GET /api/users/:id - ユーザー詳細取得
  http.get(`${BASE_URL}/api/users/:id`, async ({ params }) => {
    const { id } = params;
    const user = users.find((u) => u.id === Number(id));

    if (!user) {
      return HttpResponse.json(
        { error: "User not found", code: "USER_NOT_FOUND" },
        { status: 404 }
      );
    }

    return HttpResponse.json({ data: user });
  }),

  // POST /api/users - ユーザー作成
  http.post(`${BASE_URL}/api/users`, async ({ request }) => {
    const body = (await request.json()) as Omit<User, "id" | "createdAt">;

    // バリデーション
    if (!body.name || !body.email) {
      return HttpResponse.json(
        {
          error: "Validation failed",
          details: {
            name: !body.name ? "Name is required" : undefined,
            email: !body.email ? "Email is required" : undefined,
          },
        },
        { status: 422 }
      );
    }

    // メール重複チェック
    if (users.some((u) => u.email === body.email)) {
      return HttpResponse.json(
        { error: "Email already exists", code: "DUPLICATE_EMAIL" },
        { status: 409 }
      );
    }

    const newUser: User = {
      ...body,
      id: Math.max(...users.map((u) => u.id)) + 1,
      createdAt: new Date().toISOString(),
    };

    users.push(newUser);

    return HttpResponse.json({ data: newUser }, { status: 201 });
  }),

  // PUT /api/users/:id - ユーザー更新
  http.put(`${BASE_URL}/api/users/:id`, async ({ params, request }) => {
    const { id } = params;
    const body = (await request.json()) as Partial<User>;
    const index = users.findIndex((u) => u.id === Number(id));

    if (index === -1) {
      return HttpResponse.json(
        { error: "User not found" },
        { status: 404 }
      );
    }

    users[index] = { ...users[index], ...body };

    return HttpResponse.json({ data: users[index] });
  }),

  // DELETE /api/users/:id - ユーザー削除
  http.delete(`${BASE_URL}/api/users/:id`, async ({ params }) => {
    const { id } = params;
    const index = users.findIndex((u) => u.id === Number(id));

    if (index === -1) {
      return HttpResponse.json(
        { error: "User not found" },
        { status: 404 }
      );
    }

    users.splice(index, 1);

    return new HttpResponse(null, { status: 204 });
  }),
];

// テスト間でデータをリセットするヘルパー
export function resetUserData(): void {
  users = [...mockUsers];
}

投稿関連のハンドラー

// src/mocks/handlers/post.ts
import { http, HttpResponse } from "msw";
import { mockPosts, type Post } from "../fixtures/posts";

const BASE_URL = "https://api.example.com";

let posts = [...mockPosts];

export const postHandlers = [
  http.get(`${BASE_URL}/api/posts`, ({ request }) => {
    const url = new URL(request.url);
    const status = url.searchParams.get("status");
    const tag = url.searchParams.get("tag");
    const authorId = url.searchParams.get("authorId");

    let filtered = [...posts];

    if (status) {
      filtered = filtered.filter((p) => p.status === status);
    }
    if (tag) {
      filtered = filtered.filter((p) => p.tags.includes(tag));
    }
    if (authorId) {
      filtered = filtered.filter((p) => p.authorId === Number(authorId));
    }

    return HttpResponse.json({ data: filtered, total: filtered.length });
  }),

  http.get(`${BASE_URL}/api/posts/:id`, ({ params }) => {
    const post = posts.find((p) => p.id === Number(params.id));

    if (!post) {
      return HttpResponse.json(
        { error: "Post not found" },
        { status: 404 }
      );
    }

    return HttpResponse.json({ data: post });
  }),

  http.post(`${BASE_URL}/api/posts`, async ({ request }) => {
    const body = (await request.json()) as Omit<
      Post,
      "id" | "createdAt" | "updatedAt"
    >;

    const newPost: Post = {
      ...body,
      id: Math.max(...posts.map((p) => p.id)) + 1,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };

    posts.push(newPost);
    return HttpResponse.json({ data: newPost }, { status: 201 });
  }),

  http.patch(`${BASE_URL}/api/posts/:id`, async ({ params, request }) => {
    const body = (await request.json()) as Partial<Post>;
    const index = posts.findIndex((p) => p.id === Number(params.id));

    if (index === -1) {
      return HttpResponse.json(
        { error: "Post not found" },
        { status: 404 }
      );
    }

    posts[index] = {
      ...posts[index],
      ...body,
      updatedAt: new Date().toISOString(),
    };

    return HttpResponse.json({ data: posts[index] });
  }),
];

export function resetPostData(): void {
  posts = [...mockPosts];
}

ハンドラーの集約

// src/mocks/handlers/index.ts
import { userHandlers } from "./user";
import { postHandlers } from "./post";

export const handlers = [...userHandlers, ...postHandlers];

GraphQLのモック

MSWはGraphQLのモックにも対応している。

GraphQL型定義

// src/mocks/handlers/graphql.ts
import { graphql, HttpResponse } from "msw";
import { mockUsers } from "../fixtures/users";
import { mockPosts } from "../fixtures/posts";

// GraphQLスキーマに基づく型
interface GetUsersQuery {
  users: {
    id: number;
    name: string;
    email: string;
    role: string;
  }[];
}

interface GetUserQuery {
  user: {
    id: number;
    name: string;
    email: string;
    role: string;
    posts: {
      id: number;
      title: string;
      status: string;
    }[];
  } | null;
}

interface CreateUserMutation {
  createUser: {
    id: number;
    name: string;
    email: string;
  };
}

export const graphqlHandlers = [
  // Query: users
  graphql.query("GetUsers", ({ variables }) => {
    const { role, limit = 10 } = variables;

    let filtered = [...mockUsers];
    if (role) {
      filtered = filtered.filter((u) => u.role === role);
    }

    return HttpResponse.json({
      data: {
        users: filtered.slice(0, limit),
      },
    });
  }),

  // Query: user(id)
  graphql.query("GetUser", ({ variables }) => {
    const user = mockUsers.find((u) => u.id === variables.id);

    if (!user) {
      return HttpResponse.json({
        data: { user: null },
        errors: [
          {
            message: "User not found",
            extensions: { code: "NOT_FOUND" },
          },
        ],
      });
    }

    const userPosts = mockPosts
      .filter((p) => p.authorId === user.id)
      .map((p) => ({
        id: p.id,
        title: p.title,
        status: p.status,
      }));

    return HttpResponse.json({
      data: {
        user: { ...user, posts: userPosts },
      },
    });
  }),

  // Mutation: createUser
  graphql.mutation("CreateUser", async ({ variables }) => {
    const { input } = variables;

    if (!input.name || !input.email) {
      return HttpResponse.json({
        data: null,
        errors: [
          {
            message: "Validation failed",
            extensions: {
              code: "VALIDATION_ERROR",
              fields: {
                name: !input.name ? "Required" : null,
                email: !input.email ? "Required" : null,
              },
            },
          },
        ],
      });
    }

    const newUser = {
      id: mockUsers.length + 1,
      name: input.name,
      email: input.email,
    };

    return HttpResponse.json({
      data: { createUser: newUser },
    });
  }),

  // Mutation: deleteUser
  graphql.mutation("DeleteUser", ({ variables }) => {
    const user = mockUsers.find((u) => u.id === variables.id);

    if (!user) {
      return HttpResponse.json({
        data: null,
        errors: [
          {
            message: "User not found",
            extensions: { code: "NOT_FOUND" },
          },
        ],
      });
    }

    return HttpResponse.json({
      data: { deleteUser: { success: true, id: variables.id } },
    });
  }),
];

Vitest連携

サーバーセットアップ

// src/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);

Vitestのグローバルセットアップ

// vitest.setup.ts
import { beforeAll, afterAll, afterEach } from "vitest";
import { server } from "./src/mocks/server";

// テスト開始前にMSWサーバーを起動
beforeAll(() => {
  server.listen({
    onUnhandledRequest: "warn", // モックされていないリクエストを警告
  });
});

// 各テスト後にハンドラーをリセット
afterEach(() => {
  server.resetHandlers();
});

// 全テスト完了後にサーバーを停止
afterAll(() => {
  server.close();
});
// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    setupFiles: ["./vitest.setup.ts"],
    environment: "jsdom",
  },
});

テストの実装

// src/api/__tests__/userApi.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { http, HttpResponse } from "msw";
import { server } from "../../mocks/server";
import { resetUserData } from "../../mocks/handlers/user";

// テスト対象のAPI関数
async function fetchUsers(
  params?: Record<string, string>
): Promise<{ data: any[]; total: number }> {
  const url = new URL("https://api.example.com/api/users");
  if (params) {
    Object.entries(params).forEach(([key, value]) =>
      url.searchParams.set(key, value)
    );
  }
  const response = await fetch(url.toString());
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

async function createUser(
  data: Record<string, string>
): Promise<{ data: any }> {
  const response = await fetch("https://api.example.com/api/users", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });
  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error);
  }
  return response.json();
}

describe("User API", () => {
  beforeEach(() => {
    resetUserData();
  });

  describe("fetchUsers", () => {
    it("全ユーザーを取得できる", async () => {
      const result = await fetchUsers();

      expect(result.data).toHaveLength(3);
      expect(result.total).toBe(3);
      expect(result.data[0].name).toBe("田中太郎");
    });

    it("ロールでフィルタリングできる", async () => {
      const result = await fetchUsers({ role: "admin" });

      expect(result.data).toHaveLength(1);
      expect(result.data[0].role).toBe("admin");
    });

    it("ページネーションが正しく動作する", async () => {
      const result = await fetchUsers({ page: "1", limit: "2" });

      expect(result.data).toHaveLength(2);
      expect(result.total).toBe(3);
      expect(result.page).toBe(1);
    });
  });

  describe("createUser", () => {
    it("新しいユーザーを作成できる", async () => {
      const result = await createUser({
        name: "山田花子",
        email: "yamada@example.com",
        role: "user",
      });

      expect(result.data.id).toBe(4);
      expect(result.data.name).toBe("山田花子");
    });

    it("バリデーションエラーを返す", async () => {
      await expect(
        createUser({ name: "", email: "" })
      ).rejects.toThrow("Validation failed");
    });

    it("メール重複でエラーを返す", async () => {
      await expect(
        createUser({
          name: "テスト",
          email: "tanaka@example.com",
          role: "user",
        })
      ).rejects.toThrow("Email already exists");
    });
  });

  describe("エラーハンドリング", () => {
    it("サーバーエラーをハンドリングできる", async () => {
      // テスト固有のハンドラーで上書き
      server.use(
        http.get("https://api.example.com/api/users", () => {
          return HttpResponse.json(
            { error: "Internal server error" },
            { status: 500 }
          );
        })
      );

      await expect(fetchUsers()).rejects.toThrow("HTTP 500");
    });

    it("ネットワークエラーをハンドリングできる", async () => {
      server.use(
        http.get("https://api.example.com/api/users", () => {
          return HttpResponse.error();
        })
      );

      await expect(fetchUsers()).rejects.toThrow();
    });

    it("タイムアウトをシミュレーションできる", async () => {
      server.use(
        http.get("https://api.example.com/api/users", async () => {
          // 長時間の遅延をシミュレーション
          await new Promise((resolve) => setTimeout(resolve, 10000));
          return HttpResponse.json({ data: [] });
        })
      );

      const controller = new AbortController();
      setTimeout(() => controller.abort(), 100);

      await expect(
        fetch("https://api.example.com/api/users", {
          signal: controller.signal,
        })
      ).rejects.toThrow();
    });
  });
});

リクエストの検証

MSWではリクエストの内容を検証することもできる。

// src/api/__tests__/requestValidation.test.ts
import { describe, it, expect, vi } from "vitest";
import { http, HttpResponse } from "msw";
import { server } from "../../mocks/server";

describe("リクエストの検証", () => {
  it("送信されたヘッダーを検証できる", async () => {
    const headerSpy = vi.fn();

    server.use(
      http.get("https://api.example.com/api/users", ({ request }) => {
        headerSpy({
          authorization: request.headers.get("Authorization"),
          contentType: request.headers.get("Content-Type"),
        });
        return HttpResponse.json({ data: [] });
      })
    );

    await fetch("https://api.example.com/api/users", {
      headers: {
        Authorization: "Bearer test-token",
        "Content-Type": "application/json",
      },
    });

    expect(headerSpy).toHaveBeenCalledWith({
      authorization: "Bearer test-token",
      contentType: "application/json",
    });
  });

  it("送信されたリクエストボディを検証できる", async () => {
    const bodySpy = vi.fn();

    server.use(
      http.post("https://api.example.com/api/users", async ({ request }) => {
        const body = await request.json();
        bodySpy(body);
        return HttpResponse.json({ data: { id: 1, ...body } }, { status: 201 });
      })
    );

    await fetch("https://api.example.com/api/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name: "テスト", email: "test@example.com" }),
    });

    expect(bodySpy).toHaveBeenCalledWith({
      name: "テスト",
      email: "test@example.com",
    });
  });
});

ブラウザモードの設定

開発時にブラウザでMSWを使用する場合の設定を示す。

// src/mocks/browser.ts
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";

export const worker = setupWorker(...handlers);
// src/main.ts(アプリケーションのエントリーポイント)
async function enableMocking(): Promise<void> {
  if (process.env.NODE_ENV !== "development") {
    return;
  }

  const { worker } = await import("./mocks/browser");

  await worker.start({
    onUnhandledRequest: "bypass", // モック対象外のリクエストは通過させる
    serviceWorker: {
      url: "/mockServiceWorker.js",
    },
  });
}

enableMocking().then(() => {
  // アプリケーションの初期化
  const app = createApp();
  app.mount("#app");
});

Playwright連携

E2Eテストでもキャッシュの効率化のためにMSWを活用できる。Playwrightと連携する場合は、@mswjs/http-middlewareを使ってローカルサーバーとして動作させる方法がある。ただしより実践的なアプローチとして、Playwrightのネイティブなルーティング機能と組み合わせる方法を紹介する。

Playwright用のMSWセットアップ

// e2e/msw-setup.ts
import { http, HttpResponse } from "msw";
import type { Page } from "@playwright/test";

// Playwrightのrouteを使ってMSWライクなモックを実現
export async function setupMockApi(page: Page): Promise<void> {
  // ユーザー一覧
  await page.route("**/api/users", async (route) => {
    const method = route.request().method();

    if (method === "GET") {
      await route.fulfill({
        status: 200,
        contentType: "application/json",
        body: JSON.stringify({
          data: [
            { id: 1, name: "田中太郎", email: "tanaka@example.com", role: "admin" },
            { id: 2, name: "鈴木花子", email: "suzuki@example.com", role: "user" },
          ],
          total: 2,
        }),
      });
    } else if (method === "POST") {
      const body = route.request().postDataJSON();
      await route.fulfill({
        status: 201,
        contentType: "application/json",
        body: JSON.stringify({
          data: { id: 3, ...body, createdAt: new Date().toISOString() },
        }),
      });
    }
  });

  // ユーザー詳細
  await page.route("**/api/users/*", async (route) => {
    const url = new URL(route.request().url());
    const id = url.pathname.split("/").pop();

    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({
        data: {
          id: Number(id),
          name: "田中太郎",
          email: "tanaka@example.com",
          role: "admin",
        },
      }),
    });
  });
}

E2Eテストの実装

// e2e/users.spec.ts
import { test, expect } from "@playwright/test";
import { setupMockApi } from "./msw-setup";

test.describe("ユーザー管理画面", () => {
  test.beforeEach(async ({ page }) => {
    await setupMockApi(page);
    await page.goto("/users");
  });

  test("ユーザー一覧が表示される", async ({ page }) => {
    await expect(page.getByText("田中太郎")).toBeVisible();
    await expect(page.getByText("鈴木花子")).toBeVisible();
  });

  test("新しいユーザーを作成できる", async ({ page }) => {
    await page.getByRole("button", { name: "新規ユーザー" }).click();
    await page.getByLabel("名前").fill("山田花子");
    await page.getByLabel("メール").fill("yamada@example.com");
    await page.getByRole("button", { name: "作成" }).click();

    await expect(page.getByText("ユーザーを作成しました")).toBeVisible();
  });

  test("APIエラー時にエラーメッセージが表示される", async ({ page }) => {
    // テスト固有のエラーレスポンス
    await page.route("**/api/users", async (route) => {
      await route.fulfill({
        status: 500,
        contentType: "application/json",
        body: JSON.stringify({ error: "Internal server error" }),
      });
    });

    await page.reload();
    await expect(page.getByText("データの取得に失敗しました")).toBeVisible();
  });
});

ネットワーク境界テスト

MSWの真価は「ネットワーク境界」でテストできる点にある。以下にそのパターンを示す。

認証フローのテスト

// src/api/__tests__/authFlow.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { http, HttpResponse } from "msw";
import { server } from "../../mocks/server";

describe("認証フロー", () => {
  it("トークン期限切れ時にリフレッシュを試みる", async () => {
    let refreshCalled = false;

    server.use(
      // 最初のリクエスト: 401を返す
      http.get(
        "https://api.example.com/api/profile",
        () => {
          return HttpResponse.json(
            { error: "Token expired" },
            { status: 401 }
          );
        },
        { once: true }
      ),

      // リフレッシュトークンリクエスト
      http.post("https://api.example.com/api/auth/refresh", () => {
        refreshCalled = true;
        return HttpResponse.json({
          accessToken: "new-access-token",
          expiresIn: 3600,
        });
      }),

      // リトライ: 正常レスポンスを返す
      http.get("https://api.example.com/api/profile", () => {
        return HttpResponse.json({
          data: { id: 1, name: "テストユーザー" },
        });
      })
    );

    // トークンリフレッシュ付きのfetch関数
    async function fetchWithRefresh(url: string): Promise<any> {
      let response = await fetch(url, {
        headers: { Authorization: "Bearer expired-token" },
      });

      if (response.status === 401) {
        const refreshResponse = await fetch(
          "https://api.example.com/api/auth/refresh",
          { method: "POST" }
        );
        const { accessToken } = await refreshResponse.json();

        response = await fetch(url, {
          headers: { Authorization: `Bearer ${accessToken}` },
        });
      }

      return response.json();
    }

    const result = await fetchWithRefresh(
      "https://api.example.com/api/profile"
    );

    expect(refreshCalled).toBe(true);
    expect(result.data.name).toBe("テストユーザー");
  });
});

レート制限のテスト

describe("レート制限", () => {
  it("429レスポンス後にリトライする", async () => {
    let requestCount = 0;

    server.use(
      http.get("https://api.example.com/api/data", () => {
        requestCount++;
        if (requestCount <= 2) {
          return HttpResponse.json(
            { error: "Too many requests" },
            {
              status: 429,
              headers: { "Retry-After": "1" },
            }
          );
        }
        return HttpResponse.json({ data: "success" });
      })
    );

    async function fetchWithRetry(
      url: string,
      maxRetries = 3
    ): Promise<any> {
      for (let i = 0; i <= maxRetries; i++) {
        const response = await fetch(url);
        if (response.status === 429) {
          const retryAfter = response.headers.get("Retry-After");
          await new Promise((resolve) =>
            setTimeout(resolve, Number(retryAfter) * 1000)
          );
          continue;
        }
        return response.json();
      }
      throw new Error("Max retries exceeded");
    }

    const result = await fetchWithRetry(
      "https://api.example.com/api/data"
    );

    expect(result.data).toBe("success");
    expect(requestCount).toBe(3);
  });
});

高度なパターン

レスポンスの動的生成

import { http, HttpResponse, passthrough } from "msw";

// 条件付きパススルー
http.get("https://api.example.com/api/*", ({ request }) => {
  const url = new URL(request.url);

  // 特定のパスのみモックし、それ以外は実サーバーへ
  if (url.pathname.startsWith("/api/v2/")) {
    return passthrough();
  }

  return HttpResponse.json({ version: "v1", mocked: true });
});

レスポンスリゾルバーの合成

import { http, HttpResponse, delay } from "msw";

// 認証チェック付きハンドラーファクトリ
function withAuth(
  resolver: Parameters<typeof http.get>[1]
): Parameters<typeof http.get>[1] {
  return async (info) => {
    const authHeader = info.request.headers.get("Authorization");

    if (!authHeader || !authHeader.startsWith("Bearer ")) {
      return HttpResponse.json(
        { error: "Unauthorized" },
        { status: 401 }
      );
    }

    return resolver(info);
  };
}

// 使用例
const protectedHandlers = [
  http.get(
    "https://api.example.com/api/admin/dashboard",
    withAuth(async () => {
      await delay(50);
      return HttpResponse.json({
        data: { totalUsers: 1000, activeUsers: 750 },
      });
    })
  ),
];

ストリーミングレスポンスのモック

http.get("https://api.example.com/api/stream", () => {
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    start(controller) {
      const events = [
        { type: "start", data: "Processing..." },
        { type: "progress", data: "50% complete" },
        { type: "complete", data: "Done!" },
      ];

      events.forEach((event, index) => {
        setTimeout(() => {
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
          );
          if (index === events.length - 1) {
            controller.close();
          }
        }, index * 100);
      });
    },
  });

  return new HttpResponse(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
    },
  });
});

ベストプラクティス

MSWを効果的に活用するためのベストプラクティスをまとめる。

1. ハンドラーの粒度を適切にする

ハンドラーはエンドポイント単位で分割し、テストごとにserver.use()で上書きする構成が最も管理しやすい。

// 良い例: デフォルトハンドラー + テスト固有の上書き
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // テスト固有のハンドラーをリセット

it("エラーケースをテストする", () => {
  server.use(
    http.get("/api/users", () => HttpResponse.json(null, { status: 500 }))
  );
  // テスト実行...
});

2. onUnhandledRequestを活用する

モックされていないリクエストの検出は、テストの信頼性を高める上で重要だ。

server.listen({
  onUnhandledRequest(request, print) {
    // 静的アセットは無視
    if (request.url.includes("/assets/")) {
      return;
    }
    // それ以外は警告
    print.warning();
  },
});

3. テストデータはフィクスチャとして管理する

テストデータをハンドラー内にハードコードせず、フィクスチャファイルとして外部化することで、テスト間での再利用性と保守性が向上する。

4. ネットワーク遅延を意図的にテストする

本番環境では必ずネットワーク遅延が発生する。delay()を使ってローディング状態のテストを忘れずに行おう。

server.use(
  http.get("/api/users", async () => {
    await delay(2000); // 2秒の遅延
    return HttpResponse.json({ data: [] });
  })
);

// ローディングインジケーターの表示を検証

まとめ

MSW(Mock Service Worker)は、ネットワーク境界でAPIリクエストをインターセプトすることで、従来のモック手法が抱えていた「実装の詳細への依存」という問題を解決する。

本記事で紹介した内容を振り返る。

  • 基本セットアップ: msw 2.xのインストールとディレクトリ構成
  • RESTハンドラー: CRUD操作、バリデーション、エラーレスポンスの定義
  • GraphQLハンドラー: Query/Mutationのモック
  • Vitest連携: サーバーセットアップとテスト実装パターン
  • Playwright連携: E2Eテストでのモック活用
  • ネットワーク境界テスト: 認証フロー、レート制限のテスト
  • 高度なパターン: 条件付きパススルー、ストリーミング、認証ミドルウェア

MSWを導入することで、テストの安定性と速度が大幅に改善される。まずはプロジェクトの単体テストにMSWを導入し、既存のモックを段階的に移行することを推奨する。

関連記事