Tips & Tricks (更新: 2026/6/3)

Claude CodeでMSW APIモックを作る実践ガイド

Claude CodeとMSWでブラウザ・Node・CIに効くAPIモックを作る実践手順。認証や失敗例も解説。

Claude CodeでMSW APIモックを作る実践ガイド

MSWはMock Service Workerの略で、ブラウザではService Worker、Node.jsではリクエスト発行モジュールを使ってHTTP通信を横取りするAPIモックライブラリです。単に「固定JSONを返す道具」ではなく、画面、単体テスト、統合テスト、CIで同じハンドラーを使い回せるのが強みです。

Claude Codeと組み合わせる価値は、モックの量産ではありません。API仕様、認証、エラー、遅延、ページング、契約のズレをまとめて文章で指示し、実装とテストを同じ文脈で見直せることです。薄いモックは一見便利ですが、成功レスポンスだけを返すと本番で壊れるUIを見逃します。

この記事では、MSW 2系の公式ドキュメントで確認したhttpHttpResponsesetupWorkersetupServerを使います。まずMSW Quick startで全体像を押さえ、ブラウザはBrowser integration、テストはNode.js integrationを参照してください。エラー系はError responsesNetwork errorsが実装判断の基準になります。

関連するテスト戦略はVitest上級テクニック、画面全体の検証はPlaywright E2Eテスト、API境界の考え方はAPIテスト自動化ガイド、CI運用はCI/CDセットアップも合わせて確認してください。

使いどころ

MSWを入れるべき場面は、バックエンドが未完成なときだけではありません。むしろ本番APIが存在してからのほうが価値が出ます。

ユースケース何をモックするか失敗すると困ること
バックエンド未完成の画面開発一覧、詳細、作成、空状態画面だけ先に作るとAPI接続時に型が合わない
認証・権限のUI確認401、403、ロール別レスポンス管理者だけのボタンが一般ユーザーに見える
障害時のUX確認500、422、ネットワーク失敗、遅延ローディングが終わらない、再試行ボタンが出ない
CIでの契約チェックJSON形状、必須フィールド、ステータスAPI変更にUIが気づかずリリースされる

初心者が最初に失敗しやすいのは、200 OKだけを返すハンドラーを作ることです。実務では、認証ヘッダーがない、ページ番号が不正、メールアドレスが壊れている、サーバーが500を返す、通信そのものが落ちる、というケースのほうがUI品質に効きます。

Claude Codeには次のように頼むと、成功例だけに寄りにくくなります。

MSW 2系でユーザーAPIのモックを作ってください。
ブラウザ開発とVitestのNode環境で同じhandlers.tsを共有します。
認証必須、ページング、ロール絞り込み、422、404、500、ネットワークエラーのテスト例を含めてください。
コードはTypeScriptで、未定義の型を残さず、コピペして動く形にしてください。

全体像

同じhandlers.tsをブラウザとテストから読む形にすると、モックの二重管理を避けられます。

flowchart LR
  UI["ブラウザUI"] --> Worker["setupWorker"]
  Test["Vitest / CI"] --> Server["setupServer"]
  Worker --> Handlers["MSW handlers.ts"]
  Server --> Handlers
  Handlers --> Contract["API契約: status / JSON / auth / delay"]

インストール

まず開発依存としてMSWを入れます。ブラウザで使う場合は、公開ディレクトリにService Workerスクリプトを生成します。Viteなら通常はpublicです。

npm i -D msw vitest typescript
npx msw init public/ --save

http://localhost:5173/mockServiceWorker.jsを開いて404にならなければ、ブラウザ側の下準備はできています。ここが404のままだと、ハンドラーが正しくてもブラウザでは一切横取りされません。

コピペで動くハンドラー

次の例は、ユーザー一覧、詳細、作成、更新、削除を持つ最小APIです。固定JSONではなく、認証、ページング、入力検証、404、遅延を含めています。Nodeのfetchでも動かしやすいよう、URLは絶対URLにしています。

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

export const API_ORIGIN = "https://api.example.test";

type Role = "admin" | "editor" | "viewer";

export type User = {
  id: string;
  name: string;
  email: string;
  role: Role;
};

type CreateUserInput = {
  name: string;
  email: string;
  role?: Role;
};

type ErrorBody = {
  error: {
    code: string;
    message: string;
    requestId: string;
  };
};

type PageMeta = {
  total: number;
  page: number;
  perPage: number;
};

type UserListResponse = {
  data: User[];
  meta: PageMeta;
};

const seedUsers: User[] = [
  { id: "u_1", name: "Aki Tanaka", email: "aki@example.com", role: "admin" },
  { id: "u_2", name: "Bea Sato", email: "bea@example.com", role: "editor" },
  { id: "u_3", name: "Cal Mori", email: "cal@example.com", role: "viewer" },
];

let users: User[] = [...seedUsers];

const jsonError = (status: number, code: string, message: string) =>
  HttpResponse.json(
    { error: { code, message, requestId: "req_mock_001" } },
    { status }
  );

const requireAuth = (request: Request) => {
  const token = request.headers.get("authorization");
  return token === "Bearer demo-token"
    ? null
    : jsonError(401, "UNAUTHORIZED", "Missing or invalid bearer token");
};

const isRole = (value: string | null): value is Role =>
  value === "admin" || value === "editor" || value === "viewer";

export function resetMockData() {
  users = [...seedUsers];
}

export const handlers = [
  http.get(`${API_ORIGIN}/users`, async ({ request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    await delay(120);
    const url = new URL(request.url);
    const page = Number(url.searchParams.get("page") ?? "1");
    const perPage = Number(url.searchParams.get("perPage") ?? "20");
    const role = url.searchParams.get("role");

    if (!Number.isInteger(page) || page < 1) {
      return jsonError(422, "INVALID_PAGE", "page must be a positive integer");
    }

    if (!Number.isInteger(perPage) || perPage < 1 || perPage > 50) {
      return jsonError(422, "INVALID_PER_PAGE", "perPage must be between 1 and 50");
    }

    if (role && !isRole(role)) {
      return jsonError(422, "INVALID_ROLE", "role must be admin, editor, or viewer");
    }

    const filtered = role ? users.filter((user) => user.role === role) : users;
    const start = (page - 1) * perPage;

    return HttpResponse.json({
      data: filtered.slice(start, start + perPage),
      meta: { total: filtered.length, page, perPage },
    });
  }),

  http.get(`${API_ORIGIN}/users/:id`, async ({ params, request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    await delay(80);
    const user = users.find((item) => item.id === String(params.id));

    return user
      ? HttpResponse.json({ data: user })
      : jsonError(404, "USER_NOT_FOUND", "User was not found");
  }),

  http.post(`${API_ORIGIN}/users`, async ({ request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    const body = (await request.json()) as Partial<CreateUserInput>;

    if (!body.name?.trim() || !body.email?.includes("@")) {
      return jsonError(422, "INVALID_INPUT", "name and a valid email are required");
    }

    if (body.role && !isRole(body.role)) {
      return jsonError(422, "INVALID_ROLE", "role must be admin, editor, or viewer");
    }

    const user: User = {
      id: `u_${Date.now()}`,
      name: body.name.trim(),
      email: body.email,
      role: body.role ?? "viewer",
    };

    users = [user, ...users];

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

  http.patch(`${API_ORIGIN}/users/:id`, async ({ params, request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    const index = users.findIndex((item) => item.id === String(params.id));
    if (index === -1) return jsonError(404, "USER_NOT_FOUND", "User was not found");

    const body = (await request.json()) as Partial<CreateUserInput>;

    if (body.email && !body.email.includes("@")) {
      return jsonError(422, "INVALID_EMAIL", "email must include @");
    }

    if (body.role && !isRole(body.role)) {
      return jsonError(422, "INVALID_ROLE", "role must be admin, editor, or viewer");
    }

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

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

  http.delete(`${API_ORIGIN}/users/:id`, async ({ params, request }) => {
    const authError = requireAuth(request);
    if (authError) return authError;

    users = users.filter((item) => item.id !== String(params.id));

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

ブラウザで使う

ブラウザ側ではsetupWorkerを使います。重要なのは、worker.start()が終わってからアプリを描画することです。ここを待たないと、初回リクエストだけ本物のAPIへ飛ぶ競合が起きます。

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

export const worker = setupWorker(...handlers);
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";

async function enableMocking() {
  if (!import.meta.env.DEV || import.meta.env.VITE_API_MOCKING !== "enabled") {
    return;
  }

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

  await worker.start({
    onUnhandledRequest: "bypass",
  });
}

enableMocking().then(() => {
  ReactDOM.createRoot(document.getElementById("root")!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
});

開発時はVITE_API_MOCKING=enabled npm run devのように明示的に有効化します。本番ビルドでモックが動くと、収益導線、ログイン、決済、問い合わせフォームが偽レスポンスで動いたように見えるため危険です。

VitestとCIで使う

Node.jsのテストではsetupServerを使います。onUnhandledRequest: "error"にしておくと、モックされていないAPI呼び出しが混ざった瞬間に落ちるため、契約の漏れに気づけます。

import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { API_ORIGIN, handlers, resetMockData } from "../src/mocks/handlers";

const server = setupServer(...handlers);

function authed(input: string, init: RequestInit = {}) {
  const headers = new Headers(init.headers);
  headers.set("authorization", "Bearer demo-token");
  return fetch(input, { ...init, headers });
}

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));

afterEach(() => {
  server.resetHandlers();
  resetMockData();
});

afterAll(() => server.close());

describe("users API mock", () => {
  it("returns a paginated user list", async () => {
    const response = await authed(`${API_ORIGIN}/users?page=1&perPage=2`);
    const body = (await response.json()) as {
      data: Array<Record<string, unknown>>;
      meta: Record<string, unknown>;
    };

    expect(response.status).toBe(200);
    expect(body.data).toHaveLength(2);
    expect(body.meta).toMatchObject({ total: 3, page: 1, perPage: 2 });
  });

  it("rejects missing auth", async () => {
    const response = await fetch(`${API_ORIGIN}/users`);
    const body = (await response.json()) as { error: { code: string } };

    expect(response.status).toBe(401);
    expect(body.error.code).toBe("UNAUTHORIZED");
  });

  it("simulates a network failure for retry UI", async () => {
    server.use(
      http.get(`${API_ORIGIN}/users`, () => {
        return HttpResponse.error();
      })
    );

    await expect(authed(`${API_ORIGIN}/users`)).rejects.toThrow();
  });

  it("guards against response contract drift", async () => {
    const response = await authed(`${API_ORIGIN}/users`);
    const body = (await response.json()) as {
      data: Array<Record<string, unknown>>;
      meta: Record<string, unknown>;
    };

    expect(Object.keys(body.data[0]).sort()).toEqual(["email", "id", "name", "role"]);
    expect(body.data[0]).toEqual(
      expect.objectContaining({
        id: expect.any(String),
        email: expect.stringContaining("@"),
      })
    );
    expect(body.meta).toEqual(expect.objectContaining({ page: 1, perPage: 20 }));
  });
});

CIでは通常のテストジョブに載せるだけで十分です。バックエンドを起動しなくても、UIが期待するAPI契約は守れます。

name: msw-contract

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run test -- --run

落とし穴

1つ目は、ブラウザのService Workerファイルを公開できていないことです。mockServiceWorker.jsが404なら、MSWは起動しているように見えても通信を横取りできません。

2つ目は、テストごとにserver.resetHandlers()とデータ初期化をしないことです。作成テストで増えたユーザーが次のテストに残ると、順番によって通ったり落ちたりします。

3つ目は、onUnhandledRequest: "bypass"をテストでも使うことです。開発中のブラウザでは便利ですが、CIでは本物のAPIへ漏れても気づけません。テストではerrorを基本にします。

4つ目は、認証を省略することです。実務のUIバグは、ログイン済み、期限切れ、権限不足、ロール違いの境目で起きます。モックにも認証ヘッダーやCookieの判断を入れてください。

5つ目は、契約のズレを許すことです。dataが配列か、meta.totalがあるか、エラーがerror.codeを持つかをテストしないと、バックエンド変更で画面が静かに壊れます。

Claude Codeにレビューさせる観点

実装したあとに、Claude Codeへもう一度「このMSWモックは本番APIの代替として危険な単純化をしていないか」とレビューさせます。ここで見るべきなのは、コード量ではなく境界条件です。たとえば、認証が成功時だけでなく期限切れも表現できるか、作成APIの201とバリデーション失敗の422を画面側が区別できるか、削除後に一覧が変わるか、ページングのtotalがUIのページャーと一致するかを確認します。

レビュー指示は具体的にします。「ハンドラーの抜けを探して」だけでは、Claude Codeは見た目の整形や命名に寄りがちです。「未処理リクエスト、本物のAPIへ漏れる可能性、テスト間の状態共有、認証を省いた分岐、エラーJSONの形、CIで落ちない契約ドリフトを指摘して」と書くと、実務で効く指摘になりやすいです。特に収益導線では、購入ボタン、資料請求、無料登録、ログイン復帰のような画面を優先して見ます。

APIモックは便利ですが、作り込みすぎても危険です。バックエンドのビジネスロジックを完全に再実装し始めると、モック自体が別のシステムになり、保守コストが増えます。MSWに持たせるのは、画面とテストが必要とするHTTP境界の振る舞いまでに留めます。計算や権限判定の細部は、実APIや契約テストで確認するほうが安全です。

契約ドリフトを防ぐ運用

契約ドリフトとは、フロントエンドが期待するAPIの形と、バックエンドが返す実際の形が少しずつズレることです。namedisplayNameに変わる、meta.totalが消える、エラーがmessageだけになる、といった小さな変更でも、画面では一覧が空に見えたり、エラー通知が無言になったりします。

防ぐには、MSWのハンドラーを「便利なダミー」ではなく「UIが信じている契約のサンプル」として扱います。API仕様書やOpenAPIがあるなら、ハンドラー更新時に差分を見ます。仕様書がまだない小さなチームでも、テスト内でObject.keysexpect.objectContainingを使って必須フィールドだけは固定できます。全部のフィールドを過剰に縛る必要はありませんが、UIが表示、分岐、課金導線に使う値は守るべきです。

この運用にすると、Claude Codeへの依頼も変わります。「モックを更新して」ではなく、「バックエンド変更でroleが追加される。MSWハンドラー、契約テスト、権限UIの表示条件を一緒に更新し、未処理リクエストがCIで落ちることも確認して」と頼めます。作業単位がAPI、UI、テストにまたがるため、Claude Codeのレビュー能力を活かしやすくなります。

収益化CTA

MSWの導入は、テストの話だけで終わらせないほうが効果的です。記事CTA、教材購入、問い合わせ、無料登録、決済前確認のような収益導線ほど、500、遅延、認証切れ、バリデーションエラーを先に再現してください。Claude Code用のレビュー観点やプロンプトを整備したい場合は、教材一覧Claude Codeトレーニングから、チームのAPIモック運用に合わせてテンプレート化できます。

まとめ

MSWは「APIがまだないから仮で返す」ためだけの道具ではありません。ブラウザ、Node.jsテスト、CIで同じネットワーク契約を扱い、Claude Codeに失敗ケースまで明示することで、UIの品質を早い段階で上げられます。

Masaがこの記事の構成で実際に試したとき、効果が大きかったのはHttpResponse.error()によるネットワーク失敗と、onUnhandledRequest: "error"による漏れ検出でした。成功レスポンスだけのモックでは見つからなかった再試行ボタンの未表示、認証ヘッダー漏れ、meta.totalの欠落をテスト段階で確認できました。最終的には、ブラウザ開発ではモックを明示的に有効化し、CIでは未処理リクエストを失敗扱いにする運用が一番安定しました。

#Claude Code #MSW #APIモック #テスト #フロントエンド
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。