Mock d'API avec MSW et Claude Code : guide pratique
Créez des mocks d'API réalistes avec MSW et Claude Code pour navigateur, tests Node, erreurs, auth et CI.
MSW signifie Mock Service Worker. Dans le navigateur, il intercepte les requêtes HTTP avec un Service Worker. Dans les tests Node.js, il intercepte les modules qui émettent les requêtes. Le bénéfice est simple : le développement local, Vitest et la CI peuvent partager les mêmes gestionnaires d’API.
Avec Claude Code, il ne faut pas demander seulement une réponse JSON fixe. Un mock utile couvre l’authentification, la pagination, la validation, les statuts d’erreur, les pannes réseau et les dérives de contrat. Sinon, l’interface semble prête alors qu’elle n’a testé que le chemin heureux.
Ce guide utilise l’API MSW 2 confirmée dans la documentation officielle : http, HttpResponse, setupWorker et setupServer. Consultez MSW Quick start, Browser integration et Node.js integration. Pour les échecs, référez-vous à error responses et network errors.
Pour compléter, lisez aussi techniques avancées Vitest, tests E2E Playwright, automatisation des tests API et configuration CI/CD.
Cas d’usage
| Cas | Ce que l’on simule | Risque si absent |
|---|---|---|
| Interface avant le backend | Liste, détail, création, état vide | Contrat incompatible lors du branchement réel |
| Authentification et rôles | 401, 403, données par rôle | Actions admin visibles au mauvais utilisateur |
| Expérience en panne | 500, 422, panne réseau, lenteur | Chargement infini ou bouton de reprise absent |
| Garde de contrat en CI | Forme JSON, champs requis, statuts | Changement d’API livré sans alerte |
Demandez à Claude Code quelque chose de précis :
Crée un mock d'API utilisateurs avec MSW 2.
Le même handlers.ts doit être partagé entre le navigateur et Vitest dans Node.
Ajoute authentification obligatoire, pagination, filtre de rôle, 422, 404, 500 et un test de panne réseau.
Utilise TypeScript sans laisser de types applicatifs manquants.
Architecture
flowchart LR
UI["Interface navigateur"] --> Worker["setupWorker"]
Test["Vitest / CI"] --> Server["setupServer"]
Worker --> Handlers["MSW handlers.ts"]
Server --> Handlers
Handlers --> Contract["Contrat API : statut / JSON / auth / latence"]
Installation
npm i -D msw vitest typescript
npx msw init public/ --save
Vérifiez http://localhost:5173/mockServiceWorker.js pendant que le serveur local tourne. Si la réponse est 404, le navigateur ne pourra pas intercepter les requêtes.
Gestionnaires copiables
Cet exemple fournit une petite API utilisateurs : liste, détail, création, mise à jour et suppression. Il inclut auth, pagination, validation, 404 et latence. L’URL absolue fonctionne aussi avec fetch natif dans 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 });
}),
];
Navigateur
Utilisez setupWorker et attendez worker.start() avant de rendre l’application.
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>
);
});
Activez-le explicitement avec VITE_API_MOCKING=enabled npm run dev. En production, les parcours de connexion, achat, formulaire et CTA doivent appeler les vrais services.
Vitest et 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
Pièges courants
Le premier piège est l’absence de mockServiceWorker.js. Si le fichier est en 404, aucune interception navigateur n’a lieu.
Le deuxième est la fuite d’état entre tests. Exécutez server.resetHandlers() et réinitialisez les données en mémoire après chaque test.
Le troisième est onUnhandledRequest: "bypass" en CI. En test, une requête non simulée doit échouer.
Le quatrième est l’oubli de l’authentification. Les bugs réels apparaissent souvent entre session valide, session expirée, droit insuffisant et mauvais rôle.
Le cinquième est l’absence de garde de contrat. Vérifiez data, meta.total et error.code, pas seulement le statut HTTP.
CTA de monétisation
MSW est très utile sur les chemins de revenus : CTA d’article, achat de produit, formulaire de contact, essai gratuit et étape avant paiement. Simulez 500, lenteur, session expirée et erreurs de validation avant publication. Pour transformer ce flux en modèles Claude Code et listes de revue, consultez les produits ou la formation Claude Code.
Résultat vérifié
Quand Masa a testé cette structure sur un parcours de CTA et de produit, HttpResponse.error() et onUnhandledRequest: "error" ont apporté le plus de valeur. Un mock de succès seul ne voyait pas le bouton de reprise manquant, l’en-tête d’authentification absent ni la disparition de meta.total. Le partage des mêmes gestionnaires entre local et CI a rendu les échecs reproductibles.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Échelle de sécurité des permissions Claude Code
Passer du read-only aux éditions limitées, preuves et checks de déploiement sans perdre le contrôle.
Claude Code Small PR Proof Pack : rendre les petits changements reviewables
Un pack de preuve pour PR Claude Code : diff, vérifications, URL publique, CTA et rollback.
Gate de review avant commit avec Claude Code
Review avant commit avec Claude Code : diff, build, URL publique, liens Gumroad, CTA consultation, tests manquants et fichiers hors scope.