API-Mocking mit MSW und Claude Code: Praxisleitfaden
Realistische API-Mocks mit MSW und Claude Code für Browser, Node-Tests, Auth, Fehlerfälle und CI.
MSW steht für Mock Service Worker. Im Browser fängt es HTTP-Anfragen über einen Service Worker ab; in Node.js-Tests greift es in die Module ein, die Anfragen auslösen. Dadurch können lokale Entwicklung, Vitest und CI dieselben API-Handler verwenden.
Mit Claude Code sollte das Ziel nicht nur eine feste JSON-Antwort sein. Ein guter Mock bildet Authentifizierung, Pagination, Validierung, Fehlerstatus, Netzwerkausfälle und Vertragsänderungen ab. Ein reiner 200 OK-Mock sieht schnell aus, prüft aber nicht die Zustände, in denen echte Oberflächen brechen.
Dieser Leitfaden nutzt die aktuelle MSW-2-API aus der offiziellen Dokumentation: http, HttpResponse, setupWorker und setupServer. Beginne mit MSW Quick start, danach mit Browser integration und Node.js integration. Für Fehlerfälle sind error responses und network errors relevant.
Passende Vertiefungen sind fortgeschrittene Vitest-Techniken, Playwright-E2E-Tests, API-Testautomatisierung und CI/CD-Einrichtung.
Einsatzfälle
| Fall | Was simuliert wird | Risiko ohne Mock |
|---|---|---|
| UI vor fertigem Backend | Liste, Detail, Erstellung, leerer Zustand | Die UI passt später nicht zum Vertrag |
| Auth und Rollen | 401, 403, rollenbezogene Antworten | Admin-Aktionen erscheinen für falsche Nutzer |
| Fehler-UX | 500, 422, Netzwerkausfall, Verzögerung | Endloses Laden oder fehlender Wiederholen-Button |
| Vertragsprüfung in CI | JSON-Form, Pflichtfelder, Statuscodes | API-Drift erreicht Produktion |
Eine brauchbare Anfrage an Claude Code:
Erstelle einen Benutzer-API-Mock mit MSW 2.
Browser-Entwicklung und Vitest in Node sollen dieselbe handlers.ts nutzen.
Enthalten sein müssen Pflicht-Auth, Pagination, Rollenfilter, 422, 404, 500 und ein Netzwerkausfall-Test.
Nutze TypeScript und lasse keine undefinierten Projekttypen zurück.
Architektur
flowchart LR
UI["Browser-UI"] --> Worker["setupWorker"]
Test["Vitest / CI"] --> Server["setupServer"]
Worker --> Handlers["MSW handlers.ts"]
Server --> Handlers
Handlers --> Contract["API-Vertrag: Status / JSON / Auth / Latenz"]
Installation
npm i -D msw vitest typescript
npx msw init public/ --save
Prüfe bei laufendem Dev-Server http://localhost:5173/mockServiceWorker.js. Wenn dort 404 erscheint, kann der Browser keine Anfragen abfangen.
Kopierbare Handler
Das Beispiel stellt eine kleine Benutzer-API bereit: Liste, Detail, Erstellung, Änderung und Löschung. Es enthält Auth, Pagination, Validierung, 404 und Verzögerung. Die absolute URL funktioniert auch mit nativem fetch in Node.
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 });
}),
];
Browser
Nutze setupWorker und warte auf worker.start(), bevor die Anwendung gerendert wird.
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>
);
});
Aktiviere den Mock explizit mit VITE_API_MOCKING=enabled npm run dev. In Produktion dürfen Login, Kauf, Formulare und Monetarisierungs-CTA nicht gegen Scheinantworten laufen.
Vitest und CI
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 }));
});
});
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
Häufige Fehler
Erstens: mockServiceWorker.js ist nicht erreichbar. Bei 404 gibt es im Browser keine Interception.
Zweitens: Testzustand läuft aus. Nach jedem Test sollten server.resetHandlers() und die Datenrücksetzung laufen.
Drittens: onUnhandledRequest: "bypass" in CI. Tests sollten bei nicht gemockten Anfragen fehlschlagen.
Viertens: Auth wird nicht modelliert. Viele Fehler liegen zwischen gültiger Sitzung, abgelaufener Sitzung, fehlendem Recht und falscher Rolle.
Fünftens: Der Vertrag wird nicht geprüft. Prüfe data, meta.total und error.code, nicht nur den HTTP-Status.
Monetarisierungs-CTA
MSW ist besonders nützlich auf Umsatzpfaden: Artikel-CTA, Produktkauf, Kontaktformular, kostenlose Registrierung und Checkout-Vorschau. Simuliere 500, langsame Antworten, abgelaufene Auth und Validierungsfehler vor der Veröffentlichung. Für Claude-Code-Vorlagen und Review-Checklisten eignen sich Produkte oder Claude-Code-Training.
Ergebnis aus der Praxis
Als Masa diese Struktur für einen CTA- und Produktfluss nutzte, brachten HttpResponse.error() und onUnhandledRequest: "error" den größten Nutzen. Ein reiner Erfolgs-Mock übersah einen fehlenden Wiederholen-Button, einen fehlenden Auth-Header und ein entferntes meta.total. Gemeinsame Handler für lokale Entwicklung und CI machten die Fehler reproduzierbar.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Permission Safety Ladder: Zugriff kontrolliert erweitern
Von read-only zu begrenzten Änderungen, Prüfbefehlen und Deploy-Checks mit klarer Kontrolle.
Claude Code Small PR Proof Pack: kleine Änderungen reviewbar machen
Ein Proof Pack für Claude-Code-PRs: Diff, Checks, öffentliche URL, CTA-Pfad und Rollback.
Claude-Code-Review-Gate vor dem Commit
Vor dem Commit mit Claude Code prüfen: Diff, Build, öffentliche URL, Gumroad-Links, Beratung-CTA, fehlende Tests und fremde Dateien.