Mock de API com MSW e Claude Code: guia prático
Crie mocks de API realistas com MSW e Claude Code para navegador, testes Node, autenticação, erros e CI.
MSW significa Mock Service Worker. No navegador, ele intercepta requisições HTTP com um Service Worker; em testes Node.js, intercepta os módulos que fazem requisições no processo atual. Assim, desenvolvimento local, Vitest e CI podem usar os mesmos handlers de API.
Com Claude Code, o objetivo não é apenas gerar um JSON fixo. Um mock útil cobre autenticação, paginação, validação, status de erro, falha de rede e desvio de contrato. Se o mock só retorna 200 OK, a interface parece pronta, mas os casos que quebram em produção continuam invisíveis.
Este guia usa a API atual do MSW 2: http, HttpResponse, setupWorker e setupServer. Consulte MSW Quick start, Browser integration e Node.js integration. Para falhas, veja error responses e network errors.
Para aprofundar, veja também técnicas avançadas de Vitest, testes E2E com Playwright, automação de testes de API e configuração de CI/CD.
Casos de uso
| Caso | O que simular | Risco se faltar |
|---|---|---|
| UI antes do backend | Lista, detalhe, criação, estado vazio | A tela não combina com a API real |
| Autenticação e papéis | 401, 403, resposta por papel | Ação de admin aparece para usuário comum |
| Experiência em erro | 500, 422, falha de rede, atraso | Carregamento infinito ou botão de tentar de novo ausente |
| Contrato em CI | Formato JSON, campos obrigatórios, status | Mudança de API chega à produção sem aviso |
Peça ao Claude Code com precisão:
Crie um mock de API de usuários com MSW 2.
O mesmo handlers.ts deve ser usado no navegador e no Vitest em Node.
Inclua autenticação obrigatória, paginação, filtro por papel, 422, 404, 500 e teste de falha de rede.
Use TypeScript sem deixar tipos de aplicação indefinidos.
Arquitetura
flowchart LR
UI["Interface no navegador"] --> Worker["setupWorker"]
Test["Vitest / CI"] --> Server["setupServer"]
Worker --> Handlers["MSW handlers.ts"]
Server --> Handlers
Handlers --> Contract["Contrato API: status / JSON / auth / latência"]
Instalação
npm i -D msw vitest typescript
npx msw init public/ --save
Abra http://localhost:5173/mockServiceWorker.js com o servidor local rodando. Se retornar 404, o navegador não conseguirá interceptar as requisições.
Handlers copiáveis
O exemplo abaixo implementa lista, detalhe, criação, atualização e exclusão de usuários. Ele inclui autenticação, paginação, validação, 404 e latência. A URL absoluta facilita o uso com fetch nativo em 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: "[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];
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
Use setupWorker e espere worker.start() antes de renderizar a aplicação.
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>
);
});
Ative explicitamente com VITE_API_MOCKING=enabled npm run dev. Em produção, login, compra, formulário e CTA de monetização precisam chamar serviços reais.
Vitest e 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
Armadilhas comuns
A primeira é não publicar mockServiceWorker.js. Se ele retorna 404, o navegador não intercepta nada.
A segunda é vazar estado entre testes. Chame server.resetHandlers() e reinicie os dados em memória após cada caso.
A terceira é usar onUnhandledRequest: "bypass" em CI. Em testes, requisições não mockadas devem falhar.
A quarta é ignorar autenticação. Muitos defeitos aparecem entre sessão válida, sessão expirada, falta de permissão e papel errado.
A quinta é não validar o contrato. Verifique data, meta.total e error.code, não apenas o status HTTP.
CTA de monetização
MSW é especialmente útil em caminhos de receita: CTA de artigo, compra de produto, formulário de contato, teste gratuito e prévia de checkout. Simule 500, lentidão, autenticação expirada e erro de validação antes de publicar. Para transformar isso em prompts e checklists de Claude Code, veja produtos ou treinamento Claude Code.
Resultado prático
Quando Masa testou essa estrutura em um fluxo de CTA e produto, HttpResponse.error() e onUnhandledRequest: "error" foram os pontos mais valiosos. Um mock só de sucesso não detectava botão de tentar novamente ausente, cabeçalho de autenticação perdido nem remoção de meta.total. Compartilhar handlers entre desenvolvimento local e CI tornou as falhas reproduzíveis.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Como pedir ao Claude Code para mexer em um único arquivo
Do desastre em que um 'deixa melhor' alterou 40 linhas nasceu um template de prompt que limita o escopo, valida e permite reverter.
Recuperar de negações de permissão no Claude Code sem enfraquecer guardrails
Transforme um comando negado em plano seguro com motivo, alternativa, provas e critérios de nova tentativa.
Claude Code Harness Smoke Test: prova de 15 minutos antes de confiar em um agente
Um smoke test para escopo, áreas bloqueadas, comandos de prova, URL pública e CTAs de receita no Claude Code.