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: "demo@example.com",
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 demo@example.com, 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.
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
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.