MSWでAPIモック:Vitestと画面で同じハンドラを使い回す
MSWでネットワーク層からAPIを横取りしてモックする手順。1つのhandlersをVitestのテストとブラウザ開発で共有し、Playwright併用や実APIへの切替まで実例で解説。
「バックエンドまだなんで、フロント先に作っといてください」
その一言で始めたプロジェクトで、僕はモック用のJSONをコンポーネントの中に直接ベタ書きしていました。一覧画面で1つ、詳細で1つ、フォームで1つ。最初は快適でした。
地獄を見たのは結合の日です。本物のAPIにつないだ瞬間、レスポンスの形がベタ書きと微妙に違っていて、画面が一斉に壊れました。name だと思っていたフィールドが displayName だった、ただそれだけのことで。しかもモックがコードの中に散らばっているせいで、テストでは一切使い回せず、同じダミーをもう一度書く羽目に。
このとき知っていれば、と後悔したのが MSW(Mock Service Worker) です。コンポーネントの中ではなく、もっと下の「ネットワークの層」でAPIを横取りする。だから画面のコードを一切汚さず、しかも同じモックをテストでもブラウザでも使い回せます。今日はそのやり方を、実際に動くコードと一緒に書きます。
この記事の要点
- MSWはネットワーク層でHTTPを横取りするモック。画面のコードに
if (モック) {...}を埋め込まずに済む。 - ハンドラ(
handlers.ts)を1つ書けば、Vitestのテストとブラウザ開発の両方で使い回せる。二重管理が消える。 - 成功(200)だけ返すモックは罠。401・422・500・遅延・通信断まで再現してこそUIの欠陥が見つかる。
- ブラウザは
setupWorker、Node(Vitest/CI)はsetupServer。差はその2行だけで、中身は共通。 - Playwrightと併用すれば、E2Eでも同じモックで安定したテストが書ける。実APIへの切替は環境変数1つで済む。
そもそもMSWは何を「横取り」しているのか
MSWはMock Service Workerの略です。名前のとおり、ブラウザでは Service Worker、Node.jsではリクエスト発行モジュールにフックして、アプリが投げたHTTP通信を手前で捕まえて好きなレスポンスを返します。
ここがベタ書きモックとの決定的な差です。普通のモックは、こう書きがちですよね。
// よくあるダメなパターン:画面のコードがモックに汚染される
const users = import.meta.env.DEV
? [{ id: "u_1", name: "ダミー" }] // 開発はダミー
: await fetch("/api/users").then((r) => r.json()); // 本番は本物
これだと、画面のコードに「モックかどうか」の分岐が残ります。本番に紛れ込めば事故です。MSWはこの分岐を画面から追い出します。アプリ側は いつも普通に fetch("/api/users") を呼ぶだけ。横取りするかどうかは、ネットワーク層が外側で決めます。
身近な例えでいうと、郵便の仕分け係を雇うようなものです。あなた(アプリ)は普段どおり手紙を出すだけ。開発中だけ、仕分け係(MSW)が途中で手紙を抜き取って「はい、これが返事です」と差し替える。あなたは仕分け係がいることすら意識しなくていい。
どこで効くのか(成功例だけ返すと損をする)
MSWを入れるべき場面は「バックエンドが未完成のとき」だけではありません。むしろ本番APIが存在してからのほうが、価値が出ます。
| 場面 | 何をモックするか | サボると起きる事故 |
|---|---|---|
| バックエンド未完成の画面開発 | 一覧・詳細・作成・空状態 | 結合時に型が合わず画面が壊れる |
| 認証・権限のUI確認 | 401、403、ロール別レスポンス | 管理者専用ボタンが一般ユーザーに見える |
| 障害時のUX確認 | 500、422、通信断、遅延 | ローディングが終わらない、再試行ボタンが出ない |
| CIでの契約チェック | JSON形状、必須フィールド、ステータス | API変更にUIが気づかずリリースされる |
僕がベタ書き時代にハマったのは、まさに表の右下です。成功レスポンスしか用意していなかったので、500 が返ったときに画面が真っ白になることに、本番まで気づきませんでした。
だからMSWを使うときは、最初から失敗を混ぜます。AIエージェント(Claude Codeなど)に頼むときも、そう指示すると成功例に寄りにくくなります。
MSW 2系でユーザーAPIのモックを作って。
ブラウザ開発とVitestのNode環境で同じhandlers.tsを共有する。
認証必須・ページング・ロール絞り込み・422・404・500・通信断のケースを必ず含めて。
TypeScriptで、型を未定義のまま残さず、コピペで動く形にして。
全体像:1つのハンドラを上下から読む
仕組みを図にすると、こうなります。同じ handlers.ts を、ブラウザ(setupWorker)とテスト(setupServer)の両方から読む。これが二重管理を消すカギです。
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"]
ハンドラは1か所。読む側がブラウザかNodeかで、入口の2行だけ違う。中身のロジック(誰が認証OKか、ページングはどう数えるか)は完全に共通です。
インストールと初期セットアップ
まずMSWを開発依存で入れます。ブラウザで使うなら、公開ディレクトリに Service Worker のスクリプトを生成しておきます。Viteなら置き場所は public です。
npm i -D msw vitest typescript
npx msw init public/ --save
ここで1つだけ確認してください。http://localhost:5173/mockServiceWorker.js をブラウザで開いて、404にならないこと。ここが404のままだと、ハンドラが完璧でもブラウザでは1件も横取りされません。僕はこれに30分溶かしたことがあります。動かないと思ったら、たいていこのファイルが置けていない。
コピペで動くハンドラ
ユーザー一覧・詳細・作成・更新・削除を持つ最小APIです。固定JSONではなく、認証、ページング、入力検証、404、遅延を入れています。Nodeの fetch でもそのまま動くよう、URLは絶対URLにしています。src/mocks/handlers.ts として保存してください。
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: "[email protected]", role: "admin" },
{ id: "u_2", name: "Bea Sato", email: "[email protected]", role: "editor" },
{ id: "u_3", name: "Cal Mori", email: "[email protected]", role: "viewer" },
];
let users: User[] = [...seedUsers];
// エラーJSONの形を1か所に固定する(画面側がerror.codeで分岐できる)
const jsonError = (status: number, code: string, message: string) =>
HttpResponse.json(
{ error: { code, message, requestId: "req_mock_001" } } satisfies ErrorBody,
{ status }
);
// 認証ヘッダーを見る門番。トークンが違えば401を返す
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); // 実APIっぽい遅延。ローディング表示の検証に効く
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 },
} satisfies UserListResponse);
}),
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 });
}),
];
これ1ファイルが、このあと出てくるブラウザもテストもPlaywrightも、全部の土台になります。
ブラウザで使う
ブラウザ側は setupWorker にハンドラを渡すだけです。src/mocks/browser.ts を作ります。
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
アプリの起動側で、worker.start() が終わってから画面を描画します。ここを待たないと、最初の1リクエストだけ本物のAPIへ飛ぶ競合が起きます(そして「たまに失敗する」という一番つらいバグになります)。
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
async function enableMocking() {
// 開発時かつ明示的にONのときだけモックを起動する
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で使う
ここがMSWのいちばんおいしいところです。さっきの handlers.ts を、テストからもそのまま読みます。書き換えるのは入口の setupServer 1行だけ。
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("ページングされた一覧を返す", 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("認証なしは401で弾く", 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("通信断を再現して再試行UIを試す", async () => {
// このテストだけハンドラを上書きしてネットワークエラーを返す
server.use(
http.get(`${API_ORIGIN}/users`, () => {
return HttpResponse.error();
})
);
await expect(authed(`${API_ORIGIN}/users`)).rejects.toThrow();
});
it("レスポンス契約のズレを検知する", 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 }));
});
});
注目してほしいのは、server.use() でそのテストだけハンドラを差し替えている部分です。普段は正常系のハンドラを使い、「通信断のときだけ」一時的に上書きする。afterEach の resetHandlers() で元に戻るので、他のテストには影響しません。
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
テスト全般の設計はVitest上級テクニックに、API境界そのものの考え方はAPIテスト自動化ガイドにまとめてあるので、合わせて読むと噛み合います。
Playwrightと併用する
E2EテストでもMSWは効きます。Playwrightは本物のブラウザを動かすので、さっき作った setupWorker のブラウザ起動をそのまま使えます。E2Eで VITE_API_MOCKING=enabled を立てて開発サーバーを起動すれば、画面はモックを見ます。
これの何が嬉しいか。E2Eが「外部APIの機嫌」に左右されなくなるんです。本物のAPIにつないだE2Eは、相手が落ちていたりレートリミットに当たったりすると、こちらのコードは正しいのに赤くなる。モックに固定すれば、テストが落ちたら原因は必ず自分のコード、という状態が作れます。
import { test, expect } from "@playwright/test";
test("通信エラー時に再試行ボタンが出る", async ({ page }) => {
// ブラウザのMSWを使うため、起動時にモックを有効化しておく
// (playwright.config の webServer で VITE_API_MOCKING=enabled を渡す)
await page.goto("/users");
// 一覧が表示されることを確認(正常系のハンドラが効いている)
await expect(page.getByRole("row")).toHaveCount(4); // ヘッダ行 + 3件
// 通信断のシナリオは、画面側のテスト用フックやクエリで
// server.use 相当のハンドラに切り替えてから検証する
await page.getByRole("button", { name: "再読み込み" }).click();
});
画面全体のE2E設計そのものはPlaywright E2Eテスト、CIへの載せ方はCI/CDセットアップが詳しいです。MSWはその土台のデータ供給役として組み込む、というイメージでちょうどいい。
実APIへの切替
開発が進むと、いよいよ本物のAPIにつなぎます。MSWの良いところは、この切替が環境変数1つで済むことです。
| モード | 設定 | 何が起きるか |
|---|---|---|
| フルモック | VITE_API_MOCKING=enabled | 全リクエストをハンドラが横取り |
| 一部だけ実API | onUnhandledRequest: "bypass" | 未定義のパスは本物へ通す |
| 実APIのみ | VITE_API_MOCKING を外す | worker.start() 自体を呼ばない |
僕がよくやるのは真ん中の「一部だけ実API」です。/users は本物が完成したから外し、まだできていない /billing だけモックに残す。onUnhandledRequest: "bypass" にしておけば、ハンドラに書いていないパスは自動で本物へ流れます。完成したエンドポイントから順にハンドラを消していけば、いつの間にか全部実APIに移行できている、という流れです。
ただし注意点が1つ。テスト(setupServer)では bypass を使わないでください。CIで本物のAPIに漏れても気づけず、外部の状態でテストが不安定になります。テストは必ず error にして、漏れたら落とす。これは徹底します。
僕がやらかした落とし穴5つ
正直に書きます。MSWでも一通り踏み抜きました。
ひとつ目は、さっき書いた mockServiceWorker.js の404。npx msw init を忘れていて、「ハンドラは合ってるのに横取りされない」と小一時間悩みました。ブラウザで動かないときは、まずこのファイルを確認です。
ふたつ目は、テストごとのリセット忘れ。resetHandlers() とデータ初期化をしないと、作成テストで増えたユーザーが次のテストに残ります。実行順で通ったり落ちたりする、再現性のない不具合の温床です。
みっつ目は、テストで onUnhandledRequest: "bypass" を使ったこと。開発ブラウザでは便利ですが、テストでこれをやると、モックし忘れたAPIが本物へ漏れても気づけません。テストは error 一択。
よっつ目は、認証を省いたこと。実務のUIバグは、ログイン済み・期限切れ・権限不足・ロール違いの「境目」で起きます。成功時しか返さないモックでは、その境目を一度もテストできません。だから上のハンドラには requireAuth を全エンドポイントに入れています。
いつつ目は、モックを作り込みすぎたこと。バックエンドのビジネスロジックを完全に再現し始めたら、モック自体が別のシステムになって保守が破綻しました。MSWに持たせるのは「画面とテストが必要とするHTTPの振る舞い」まで。計算や複雑な権限判定の細部は、実APIや契約テストに任せます。
よくある質問
Q. MSWとJestの jest.mock('axios') みたいなモックは何が違うの?
A. レイヤーが違います。jest.mock はライブラリ(axiosやfetchのラッパー)を差し替えるので、HTTPクライアントを変えるとモックも書き直しです。MSWはもっと下のネットワーク層を横取りするため、fetch でも axios でも、クライアントを変えてもハンドラはそのまま使えます。しかもブラウザとテストで共有できます。
Q. ブラウザでハンドラが効きません。何を見ればいい?
A. まず http://localhost:5173/mockServiceWorker.js が404でないか。次に worker.start() を await してから画面を描画しているか。最後にURLが完全一致しているか(末尾スラッシュや絶対/相対の違い)。この3点でほぼ解決します。
Q. setupWorker(ブラウザ)と setupServer(Node)でハンドラは分ける必要がある?
A. 分けません。それがMSWの最大の利点です。ハンドラは1ファイルにして、ブラウザは setupWorker(...handlers)、テストは setupServer(...handlers) と入口だけ変えます。
Q. 特定のテストだけ500を返したい。毎回ハンドラ全部を書き直すの?
A. いいえ。server.use(http.get(..., () => HttpResponse.json(..., { status: 500 }))) で、そのテストだけ上書きできます。afterEach の resetHandlers() で元に戻るので、他のテストは正常系のままです。
Q. POSTのリクエストボディは読める?
A. 読めます。ハンドラ内で await request.json() すればパース済みのオブジェクトが取れます。上の作成APIでは、それで name と email を検証しています。FormDataなら await request.formData() です。
まとめ
MSWは「APIがまだないから仮で返す」ためだけの道具ではありません。ネットワーク層でHTTPを横取りし、1つのハンドラをブラウザ・Vitest・Playwrightで使い回せるのが本質です。画面のコードにモック分岐を埋め込まずに済み、実APIへの切替は環境変数1つ。
ベタ書きモックで結合の日に画面を全壊させた僕からすると、これは早く知りたかった。特に効いたのは HttpResponse.error() の通信断と、onUnhandledRequest: "error" の漏れ検出です。成功レスポンスだけのモックでは絶対に見つからなかった「再試行ボタンの未表示」「認証ヘッダー漏れ」「meta.total の欠落」が、テスト段階で全部出ました。最終的に落ち着いたのは、ブラウザ開発ではモックを明示的にONにし、CIでは未処理リクエストを失敗扱いにする運用です。
チームのAPIモック運用やレビュー観点をテンプレート化したい場合は、教材一覧もどうぞ。最初の1ファイルを handlers.ts に切り出すところから始めれば、二度とコンポーネントの中にダミーJSONを書かずに済みます。
公式リファレンス: MSW Quick start
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。