Mock de API con MSW y Claude Code: guía práctica
Crea mocks de API realistas con MSW y Claude Code para navegador, pruebas Node, autenticación, errores y CI.
MSW significa Mock Service Worker. En el navegador intercepta peticiones HTTP con un Service Worker; en pruebas Node.js intercepta los módulos que emiten peticiones dentro del proceso. La ventaja práctica es que desarrollo local, Vitest y CI pueden usar los mismos controladores de API.
Con Claude Code, el objetivo no debe ser generar una respuesta JSON fija. Un mock útil cubre autenticación, paginación, validación, estados de error, fallos de red y cambios de contrato. Si solo devuelves 200 OK, la interfaz parece terminada, pero no has probado los casos que rompen una pantalla real.
Esta guía usa la API actual de MSW 2: http, HttpResponse, setupWorker y setupServer. Revisa MSW Quick start, Browser integration y Node.js integration. Para errores, usa error responses y network errors.
También conviene leer técnicas avanzadas de Vitest, pruebas E2E con Playwright, automatización de pruebas API y configuración de CI/CD.
Casos de uso
| Caso | Qué simular | Riesgo si falta |
|---|---|---|
| UI antes del backend | Lista, detalle, creación, estado vacío | La UI no coincide con el contrato real |
| Autenticación y permisos | 401, 403, respuestas por rol | Acciones de administrador visibles para usuarios normales |
| Experiencia ante fallos | 500, 422, caída de red, latencia | Carga infinita o botón de reintento ausente |
| Guardia de contrato en CI | Forma JSON, campos obligatorios, estados | Un cambio de API llega a producción sin aviso |
Una petición útil para Claude Code:
Crea un mock de API de usuarios con MSW 2.
Comparte el mismo handlers.ts entre desarrollo en navegador y Vitest en Node.
Incluye autenticación obligatoria, paginación, filtro por rol, 422, 404, 500 y una prueba de error de red.
Usa TypeScript y no dejes tipos de aplicación sin definir.
Arquitectura
flowchart LR
UI["Interfaz en navegador"] --> Worker["setupWorker"]
Test["Vitest / CI"] --> Server["setupServer"]
Worker --> Handlers["MSW handlers.ts"]
Server --> Handlers
Handlers --> Contract["Contrato API: estado / JSON / auth / latencia"]
Instalación
npm i -D msw vitest typescript
npx msw init public/ --save
Abre http://localhost:5173/mockServiceWorker.js con el servidor local activo. Si responde 404, el navegador no podrá interceptar peticiones.
Controladores listos para copiar
Este ejemplo implementa usuarios con lista, detalle, creación, actualización y borrado. Incluye autenticación, paginación, validación, 404 y latencia. La URL es absoluta para que funcione también con fetch nativo en 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 });
}),
];
Navegador
Usa setupWorker y espera a worker.start() antes de renderizar la aplicación.
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>
);
});
Actívalo de forma explícita con VITE_API_MOCKING=enabled npm run dev. No lo dejes activo en producción: login, compra, formularios y CTA de monetización deben usar servicios reales.
Vitest y 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
Errores frecuentes
El primero es no publicar mockServiceWorker.js. Si devuelve 404, el navegador no intercepta nada.
El segundo es compartir estado entre pruebas. Usa server.resetHandlers() y reinicia los datos en memoria después de cada caso.
El tercero es usar onUnhandledRequest: "bypass" en CI. En pruebas debe ser error para detectar peticiones no simuladas.
El cuarto es no modelar autenticación. Muchas incidencias viven entre sesión válida, sesión caducada, permiso insuficiente y rol incorrecto.
El quinto es no comprobar el contrato. Verifica data, meta.total y error.code, no solo el estado HTTP.
CTA de monetización
MSW es muy útil en rutas de ingresos: CTA de artículos, compras, formularios de contacto, pruebas gratuitas y pasos previos al pago. Simula 500, lentitud, sesión caducada y errores de validación antes de publicar. Para convertir este flujo en plantillas y listas de revisión de Claude Code, consulta productos o formación de Claude Code.
Resultado probado
Cuando Masa probó esta estructura en un flujo de CTA y producto, lo más valioso fue combinar HttpResponse.error() con onUnhandledRequest: "error". El mock de solo éxito no detectaba un botón de reintento ausente, una cabecera de autenticación perdida ni la desaparición de meta.total. Compartir controladores entre desarrollo local y CI hizo que los fallos fueran reproducibles.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Escalera de permisos de Claude Code para ampliar acceso sin perder control
Pasa de read-only a ediciones limitadas, comandos de prueba y checks de deploy con menos riesgo.
Claude Code Small PR Proof Pack: cambios pequeños que sí se pueden revisar
Un paquete de prueba para PRs de Claude Code: diff, checks, URL pública, CTA y rollback.
Gate de revisión antes del commit con Claude Code
Cómo revisar con Claude Code antes del commit: diff, build, URL pública, Gumroad, consultoría, tests y archivos ajenos.