Testes de API com Claude Code: guia prático de automação
Guia prático de testes de API com Claude Code: smoke, auth, JSON, contratos, CI e exemplos executáveis.
Testes de API verificam se o servidor cumpre seu contrato antes de qualquer tela do navegador entrar em cena. Eles enviam requisições HTTP diretamente e confirmam se o login funciona, se um pedido pode ser criado, se falhas retornam erros úteis, se a autenticação é exigida e se o formato do JSON continua compatível com frontend, app mobile ou integração parceira.
Quando você pede apenas “escreva testes de API” para o Claude Code, o resultado costuma ser raso: um caminho feliz, uma resposta 200 OK e quase nenhuma proteção contra incidentes reais. Um conjunto útil precisa cobrir smoke tests, códigos de status, JSON shape, autenticação, testes negativos, contract tests, dados de teste e CI.
Este guia mostra um fluxo prático para iniciantes. Para continuar, leia também o guia de design de API, a estratégia de versionamento de API e o fluxo de diagnóstico de erros.
Use documentação oficial como base. Se o projeto já usa Playwright, consulte Playwright API testing. O exemplo abaixo usa fetch, documentado em MDN Fetch API. Para contratos revisáveis, a referência é a OpenAPI Specification.
O que um teste de API deve provar
O objetivo não é ter muitos testes, e sim descobrir cedo quando um contrato foi quebrado. Testes E2E no navegador continuam importantes porque cobrem a jornada completa do usuário, mas uma falha pode vir da UI, rede, sessão, backend, dados ou serviço externo. O teste de API pula a interface e valida diretamente a fronteira do servidor.
| Verificação | Explicação simples | Exemplo |
|---|---|---|
| Smoke test | Confirmar que o mínimo está vivo | /health retorna 204, login retorna 200 |
| Código de status | Resultado expresso por HTTP | Criação 201, sem auth 401, não encontrado 404 |
| JSON shape | Campos obrigatórios e proibidos | sessionId existe, password não volta |
| Autenticação | Identidade do chamador é exigida | Bearer token, cookie ou API key |
| Teste negativo | Enviar entrada ruim de propósito | Senha errada, pedido vazio, webhook sem assinatura |
| Contract test | Implementação bate com a promessa pública | Campos obrigatórios do OpenAPI continuam presentes |
| Dados de teste | Cada execução começa previsível | Mock local, banco resetado, ID descartável |
O erro mais comum é olhar só para 200 OK. A resposta pode ser 200 e ainda estar errada: campo removido, envelope de erro diferente, segredo vazado ou chamada sem autenticação aceita. Essas expectativas precisam aparecer no prompt entregue ao Claude Code.
Quatro casos de uso práticos
O exemplo deste artigo junta quatro fluxos que costumam justificar uma suíte pequena e valiosa.
| Caso | Por que importa | O que validar |
|---|---|---|
| Login e sessão como smoke test | Quase tudo depende de sessão válida | 200, sessionId, formato do usuário, sem senha |
| API de criação de pedidos | Afeta receita, estoque, recibos e suporte | 201, header Location, total, busca do detalhe |
| Endpoint de webhook | Serviços externos chamam de modo assíncrono e repetem | Sem assinatura 401, evento válido 202, duplicado seguro |
| Teste de regressão de bug | Um bug corrigido não deve voltar em silêncio | 400, 401, 404 e JSON de erro estável |
Ao pedir a tarefa ao Claude Code, nomeie esses fluxos diretamente. Em webhooks, o caminho feliz quase não basta: inclua assinatura ausente, event ID duplicado e recurso desconhecido.
flowchart LR
A["OpenAPI or API notes"] --> B["Claude Code prompt"]
B --> C["Local API test"]
C --> D["Negative tests"]
D --> E["CI gate"]
E --> F["Regression safety"]
Exemplo executável com Node fetch
Este arquivo não acessa produção nem banco real. Ele inicia um pequeno servidor HTTP local e testa login, criação de pedido, webhook e casos negativos com o fetch do Node.js. Salve como api-smoke.test.mjs e rode com Node.js 18 ou superior.
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
const TEST_USER = {
id: "user_1",
email: "[email protected]",
password: "correct-horse",
};
const WEBHOOK_SECRET = "whsec_test";
function sendJson(res, status, body, headers = {}) {
if (status === 204) {
res.writeHead(status, headers);
res.end();
return;
}
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
...headers,
});
res.end(JSON.stringify(body));
}
function readJson(req) {
return new Promise((resolve, reject) => {
let raw = "";
req.on("data", (chunk) => {
raw += chunk;
if (raw.length > 1_000_000) req.destroy();
});
req.on("end", () => {
if (!raw) {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch (error) {
reject(error);
}
});
req.on("error", reject);
});
}
function bearerToken(req) {
const value = req.headers.authorization;
if (typeof value === "string" && value.startsWith("Bearer ")) {
return value.slice("Bearer ".length);
}
return "";
}
function validateItems(items) {
if (!Array.isArray(items) || items.length === 0) {
return ["items must be a non-empty array"];
}
return items.flatMap((item, index) => {
const errors = [];
if (typeof item.sku !== "string" || item.sku.length === 0) {
errors.push(`items[${index}].sku must be a non-empty string`);
}
if (!Number.isInteger(item.quantity) || item.quantity <= 0) {
errors.push(`items[${index}].quantity must be a positive integer`);
}
if (!Number.isInteger(item.priceCents) || item.priceCents < 0) {
errors.push(`items[${index}].priceCents must be a non-negative integer`);
}
return errors;
});
}
function makeApp() {
const sessions = new Map();
const orders = new Map();
const webhookEvents = new Set();
let orderSeq = 1;
return async function handler(req, res) {
const method = req.method ?? "GET";
const url = new URL(req.url ?? "/", "http://localhost");
let body = {};
if (["POST", "PUT", "PATCH"].includes(method)) {
try {
body = await readJson(req);
} catch {
return sendJson(res, 400, {
error: { code: "invalid_json", message: "Request body is not valid JSON" },
});
}
}
const currentUser = () => {
const token = bearerToken(req);
return token ? sessions.get(token) : undefined;
};
if (method === "GET" && url.pathname === "/health") {
return sendJson(res, 204, null);
}
if (method === "POST" && url.pathname === "/login") {
if (body.email !== TEST_USER.email || body.password !== TEST_USER.password) {
return sendJson(res, 401, {
error: { code: "invalid_credentials", message: "Email or password is wrong" },
});
}
const sessionId = `sess_${randomUUID()}`;
sessions.set(sessionId, { id: TEST_USER.id, email: TEST_USER.email });
return sendJson(res, 200, {
sessionId,
expiresIn: 3600,
user: { id: TEST_USER.id, email: TEST_USER.email },
});
}
if (method === "GET" && url.pathname === "/me") {
const user = currentUser();
if (!user) {
return sendJson(res, 401, {
error: { code: "unauthorized", message: "Bearer token is required" },
});
}
return sendJson(res, 200, { user });
}
if (method === "POST" && url.pathname === "/orders") {
const user = currentUser();
if (!user) {
return sendJson(res, 401, {
error: { code: "unauthorized", message: "Bearer token is required" },
});
}
const details = validateItems(body.items);
if (details.length > 0) {
return sendJson(res, 400, {
error: { code: "validation_failed", message: "Order payload is invalid", details },
});
}
const totalCents = body.items.reduce(
(sum, item) => sum + item.quantity * item.priceCents,
0,
);
const order = {
id: `ord_${orderSeq++}`,
userId: user.id,
status: "created",
totalCents,
items: body.items,
};
orders.set(order.id, order);
return sendJson(res, 201, { order }, { location: `/orders/${order.id}` });
}
const orderMatch = url.pathname.match(/^\/orders\/([^/]+)$/);
if (method === "GET" && orderMatch) {
const user = currentUser();
if (!user) {
return sendJson(res, 401, {
error: { code: "unauthorized", message: "Bearer token is required" },
});
}
const order = orders.get(orderMatch[1]);
if (!order || order.userId !== user.id) {
return sendJson(res, 404, {
error: { code: "order_not_found", message: "Order was not found" },
});
}
return sendJson(res, 200, { order });
}
if (method === "POST" && url.pathname === "/webhooks/payment") {
if (req.headers["x-webhook-secret"] !== WEBHOOK_SECRET) {
return sendJson(res, 401, {
error: { code: "bad_signature", message: "Webhook signature is invalid" },
});
}
if (typeof body.eventId !== "string" || typeof body.orderId !== "string") {
return sendJson(res, 400, {
error: { code: "validation_failed", message: "eventId and orderId are required" },
});
}
if (webhookEvents.has(body.eventId)) {
return sendJson(res, 200, { received: true, duplicate: true });
}
const order = orders.get(body.orderId);
if (!order) {
return sendJson(res, 404, {
error: { code: "order_not_found", message: "Order was not found" },
});
}
webhookEvents.add(body.eventId);
order.status = "paid";
return sendJson(res, 202, { received: true, duplicate: false });
}
return sendJson(res, 404, {
error: { code: "route_not_found", message: `${method} ${url.pathname} is not supported` },
});
};
}
async function withServer(fn) {
const server = createServer(makeApp());
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
await fn(baseUrl);
} finally {
await new Promise((resolve) => server.close(resolve));
}
}
async function requestJson(baseUrl, path, options = {}) {
const headers = { ...(options.headers ?? {}) };
if (options.token) headers.authorization = `Bearer ${options.token}`;
const init = {
method: options.method ?? "GET",
headers,
};
if (options.body !== undefined) {
headers["content-type"] = "application/json";
init.body = JSON.stringify(options.body);
}
const res = await fetch(`${baseUrl}${path}`, init);
const text = await res.text();
return { res, json: text ? JSON.parse(text) : null };
}
function expectKeys(value, keys) {
for (const key of keys) {
assert.ok(Object.prototype.hasOwnProperty.call(value, key), `missing key: ${key}`);
}
}
async function login(baseUrl) {
const { res, json } = await requestJson(baseUrl, "/login", {
method: "POST",
body: { email: TEST_USER.email, password: TEST_USER.password },
});
assert.equal(res.status, 200);
assert.equal(typeof json.sessionId, "string");
return json.sessionId;
}
async function createOrder(baseUrl, token) {
const { res, json } = await requestJson(baseUrl, "/orders", {
method: "POST",
token,
body: {
items: [
{ sku: "book", quantity: 2, priceCents: 1500 },
{ sku: "video", quantity: 1, priceCents: 4000 },
],
},
});
assert.equal(res.status, 201);
assert.match(res.headers.get("location"), /^\/orders\/ord_/);
expectKeys(json.order, ["id", "status", "totalCents", "items"]);
assert.equal(json.order.totalCents, 7000);
return json.order;
}
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
test("login/session smoke test", async (baseUrl) => {
const health = await fetch(`${baseUrl}/health`);
assert.equal(health.status, 204);
const { res, json } = await requestJson(baseUrl, "/login", {
method: "POST",
body: { email: TEST_USER.email, password: TEST_USER.password },
});
assert.equal(res.status, 200);
expectKeys(json, ["sessionId", "expiresIn", "user"]);
assert.equal(json.user.email, TEST_USER.email);
assert.equal(json.user.password, undefined);
const me = await requestJson(baseUrl, "/me", { token: json.sessionId });
assert.equal(me.res.status, 200);
assert.equal(me.json.user.id, TEST_USER.id);
});
test("order creation API returns a stable JSON shape", async (baseUrl) => {
const token = await login(baseUrl);
const order = await createOrder(baseUrl, token);
const detail = await requestJson(baseUrl, `/orders/${order.id}`, { token });
assert.equal(detail.res.status, 200);
assert.equal(detail.json.order.id, order.id);
assert.equal(detail.json.order.status, "created");
});
test("payment webhook verifies signature and duplicate events", async (baseUrl) => {
const token = await login(baseUrl);
const order = await createOrder(baseUrl, token);
const noSignature = await requestJson(baseUrl, "/webhooks/payment", {
method: "POST",
body: { eventId: "evt_1", orderId: order.id },
});
assert.equal(noSignature.res.status, 401);
assert.equal(noSignature.json.error.code, "bad_signature");
const accepted = await requestJson(baseUrl, "/webhooks/payment", {
method: "POST",
headers: { "x-webhook-secret": WEBHOOK_SECRET },
body: { eventId: "evt_1", orderId: order.id },
});
assert.equal(accepted.res.status, 202);
assert.equal(accepted.json.duplicate, false);
const paidOrder = await requestJson(baseUrl, `/orders/${order.id}`, { token });
assert.equal(paidOrder.json.order.status, "paid");
const duplicate = await requestJson(baseUrl, "/webhooks/payment", {
method: "POST",
headers: { "x-webhook-secret": WEBHOOK_SECRET },
body: { eventId: "evt_1", orderId: order.id },
});
assert.equal(duplicate.res.status, 200);
assert.equal(duplicate.json.duplicate, true);
});
test("regression tests cover auth, validation, and not-found bugs", async (baseUrl) => {
const badLogin = await requestJson(baseUrl, "/login", {
method: "POST",
body: { email: TEST_USER.email, password: "wrong" },
});
assert.equal(badLogin.res.status, 401);
assert.equal(badLogin.json.error.code, "invalid_credentials");
const missingAuth = await requestJson(baseUrl, "/orders", {
method: "POST",
body: { items: [{ sku: "book", quantity: 1, priceCents: 1500 }] },
});
assert.equal(missingAuth.res.status, 401);
const token = await login(baseUrl);
const invalidOrder = await requestJson(baseUrl, "/orders", {
method: "POST",
token,
body: { items: [{ sku: "book", quantity: 0, priceCents: 1500 }] },
});
assert.equal(invalidOrder.res.status, 400);
assert.equal(invalidOrder.json.error.code, "validation_failed");
assert.ok(Array.isArray(invalidOrder.json.error.details));
const missingOrder = await requestJson(baseUrl, "/orders/ord_missing", { token });
assert.equal(missingOrder.res.status, 404);
assert.equal(missingOrder.json.error.code, "order_not_found");
});
await withServer(async (baseUrl) => {
let failed = 0;
for (const { name, fn } of tests) {
try {
await fn(baseUrl);
console.log(`ok - ${name}`);
} catch (error) {
failed += 1;
console.error(`not ok - ${name}`);
console.error(error);
}
}
if (failed > 0) {
process.exitCode = 1;
}
});
Execute assim:
node api-smoke.test.mjs
Você deve ver quatro linhas ok. Este é um mock local seguro, não um teste contra produção. Em um repositório real, substitua makeApp() pelo servidor local da aplicação, uma URL de staging ou o fixture request do Playwright. Mantenha as mesmas verificações: código de status, JSON shape, autenticação, casos negativos e ausência de segredos vazados.
Prompt recomendado para Claude Code
Claude Code precisa de alvo e regras de falha. Um prompt concreto evita que ele pare no caminho feliz.
Add API tests for these flows:
- login and session check
- order creation API
- payment webhook
- regression coverage for the last bug
Must verify:
- success status codes and JSON shape
- missing auth, invalid input, unknown ID, and missing webhook signature
- password, tokens, and secrets are never returned or logged
- test data does not collide across parallel test runs
- a command that CI can run
After the edit, summarize which incidents the tests would catch and which command verifies them.
Se houver OpenAPI, diga para Claude Code tratá-lo como contrato. Contract test significa apenas confirmar que a promessa pública e a resposta real continuam alinhadas.
openapi: 3.1.0
info:
title: Local Orders API
version: 1.0.0
paths:
/orders:
post:
responses:
"201":
description: Order created
content:
application/json:
schema:
type: object
required: [order]
properties:
order:
type: object
required: [id, status, totalCents, items]
Esse trecho já cria obrigações: criação retorna 201, o corpo vem em order, e os campos obrigatórios precisam existir. Revise implementação, OpenAPI, testes e README juntos.
Dados de teste e CI
A estabilidade dos testes de API depende muito dos dados. Se todos usam [email protected], o mesmo ID de pedido e o mesmo event ID de webhook, o CI paralelo fica frágil. Use IDs descartáveis, resete o banco por execução ou modele o fluxo com mock local quando isso bastar.
name: api-tests
on:
pull_request:
push:
branches: [main]
jobs:
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: node api-smoke.test.mjs
Coloque smoke tests de API no começo do CI. Eles capturam falhas de autenticação, JSON e webhook antes dos testes de navegador mais lentos. Se chamar staging, adicione uma regra de log: nunca imprimir tokens, cookies, assinaturas de webhook ou API keys.
Armadilhas comuns
A primeira é verificar só 200 OK. Também valide campos obrigatórios, campos proibidos, formato de erro e códigos de falha.
A segunda é compartilhar dados de teste. Um usuário demo global torna os testes dependentes de ordem e paralelismo.
A terceira é vazar segredos em logs. Authorization, cookies e assinaturas devem ser mascarados.
A quarta é depender de serviços externos instáveis. Pagamento, e-mail e CRM devem ser mockados no CI diário e verificados em staging antes do release.
A quinta é não ter testes negativos. Sem auth, payload inválido, ID inexistente, papel proibido e webhook sem assinatura fazem parte do contrato.
A sexta é confiar apenas em mocks. Mocks são rápidos, mas podem esconder diferenças reais de headers, timeout, status e envelope de erro.
Fechamento e CTA
Bons testes de API com Claude Code começam pela promessa que você quer proteger: login estável, criação de pedidos confiável, webhook verificado e bugs antigos cobertos por regressão. Depois disso, peça o código e o comando de CI.
Para equipes, o ganho real vem da padronização: prompts, OpenAPI, critérios de revisão, gates de CI e diagnóstico de falhas. ClaudeCodeLab pode ajudar com Claude Code training and consultation para adaptar este fluxo a um repositório real. Quem trabalha sozinho pode começar pela folha de referência gratuita e pelo prompt deste artigo.
Masa testou este fluxo com o servidor local de Node acima. O resultado prático foi juntar login, criação de pedido, verificação de webhook e regressões em um comando curto: node api-smoke.test.mjs. Ele detecta mais do que 200 OK: vazamento de password, auth ausente, webhook sem assinatura, payload inválido e evento webhook duplicado.
Artigos relacionados
Guia de TDD com Claude Code: desenvolvimento orientado a testes com Vitest e node:test
Pratique TDD com Claude Code usando exemplos de Vitest, node:test, CI, hooks e templates de prompt.
Code review com Claude Code para equipes
Use Claude Code em reviews de PR com gates de CI, CODEOWNERS, templates e prompts baseados em evidência.
Como automatizar refatorações seguras com Claude Code
Workflow prático para refatorar com Claude Code: antes/depois, testes, git diff, revisão, riscos e prompts prontos para usar.
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.