Testing de API con Claude Code: guía práctica de automatización
Guía práctica de API testing con Claude Code: smoke tests, auth, JSON, contratos, CI y ejemplos ejecutables.
El testing de API comprueba si el servidor cumple su contrato antes de abrir una pantalla en el navegador. En lugar de hacer clic por la interfaz, envía peticiones HTTP directas y valida si el login funciona, si una orden se crea correctamente, si los errores son útiles, si la autenticación se aplica y si la forma del JSON sigue siendo compatible con los clientes.
Cuando se le pide a Claude Code “escribe tests de API”, el resultado inicial suele ser demasiado superficial: una ruta feliz, una respuesta 200 OK y poco más. Eso no protege un producto real. Un buen conjunto de pruebas cubre smoke tests, códigos de estado, JSON shape, autenticación, pruebas negativas, contract tests, datos de prueba y CI.
Esta guía ofrece un flujo práctico y apto para principiantes. Para profundizar, combínala con la guía de diseño de API, la estrategia de versionado de API y el flujo de diagnóstico de errores.
Conviene anclar las decisiones a documentación oficial. Si tu proyecto usa Playwright, consulta Playwright API testing. El ejemplo de abajo usa fetch, documentado en MDN Fetch API. Para contratos revisables, usa la OpenAPI Specification.
Qué debe demostrar un test de API
El objetivo no es tener muchos tests, sino saber rápido si se rompió un contrato. Los tests E2E con navegador siguen siendo valiosos porque cubren el recorrido completo del usuario, pero cuando fallan es más difícil separar si el problema está en la UI, el backend, la sesión, la red, los datos o un proveedor externo. El test de API salta la UI y verifica el borde del servidor.
| Comprobación | Explicación sencilla | Ejemplo |
|---|---|---|
| Smoke test | Confirmar que lo mínimo vive | /health devuelve 204, login devuelve 200 |
| Código de estado | Resultado expresado con HTTP | Crear devuelve 201, sin auth devuelve 401, inexistente devuelve 404 |
| JSON shape | Campos obligatorios y campos prohibidos | Existe sessionId, no se devuelve password |
| Autenticación | Se exige identidad del llamador | Bearer token, cookie o API key |
| Prueba negativa | Enviar datos incorrectos a propósito | Contraseña errónea, orden vacía, webhook sin firma |
| Contract test | Implementación y promesa pública coinciden | Los campos requeridos por OpenAPI siguen presentes |
| Datos de prueba | Cada ejecución parte de un estado conocido | Mock local, base de datos reseteada, ID descartable |
El error clásico es mirar solo 200 OK. Una respuesta puede ser 200 y aun así estar rota: falta un campo, cambió el formato de error, se filtró un secreto o una petición sin autenticación pasó. Esas expectativas tienen que aparecer en el prompt para Claude Code.
Cuatro casos de uso reales
El ejemplo de esta guía junta cuatro flujos que suelen justificar un pequeño suite de API testing.
| Caso | Por qué importa | Qué validar |
|---|---|---|
| Login y sesión como smoke test | Casi todo depende de una sesión válida | 200, sessionId, forma del usuario, sin contraseña |
| API de creación de órdenes | Toca ingresos, inventario, recibos y soporte | 201, header Location, total, consulta de detalle |
| Endpoint de webhook | Servicios externos lo llaman de forma asíncrona y reintentan | Sin firma es 401, evento válido es 202, duplicado es seguro |
| Test de regresión de un bug | Un bug corregido no debe volver sin aviso | 400, 401, 404 y JSON de error estable |
Cuando trabajes con Claude Code, nombra estos flujos de forma explícita. En webhooks, la ruta feliz no basta: añade firma ausente, evento duplicado y recurso inexistente.
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"]
Ejemplo ejecutable con Node fetch
Este archivo no toca producción ni una base de datos real. Levanta un servidor HTTP local y lo prueba con fetch incluido en Node.js. Guárdalo como api-smoke.test.mjs y ejecútalo con Node.js 18 o 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;
}
});
Ejecútalo con:
node api-smoke.test.mjs
Deberías ver cuatro líneas ok. Es un mock local seguro, no un test contra producción. En un repositorio real puedes sustituir makeApp() por tu servidor local, una URL de staging o el fixture request de Playwright. Mantén las mismas comprobaciones: código de estado, JSON shape, autenticación, casos negativos y ausencia de secretos filtrados.
Prompt recomendado para Claude Code
Claude Code necesita objetivos y reglas de fallo. Un prompt concreto evita que se detenga en una sola ruta 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.
Si existe un archivo OpenAPI, dile a Claude Code que lo trate como contrato. Un contract test solo comprueba que la promesa pública y la respuesta real coincidan.
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]
Ese fragmento ya fija varias obligaciones: crear devuelve 201, el cuerpo viene envuelto en order y el objeto tiene campos obligatorios. Revisa implementación, OpenAPI, tests y README como una sola unidad.
Datos de prueba y CI
La estabilidad de los tests de API casi siempre depende de los datos. Si todos usan demo@example.com, el mismo ID de orden y el mismo event ID de webhook, el CI paralelo será frágil. Usa IDs descartables, resetea la base de datos por ejecución o modela el comportamiento con un mock local cuando el flujo sea pequeño.
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
Coloca los smoke tests de API al principio del CI. Así fallan rápido los problemas de auth, JSON y webhook antes de ejecutar tests de navegador más lentos. Si llamas a staging, añade una regla explícita: no imprimir tokens, cookies, firmas de webhook ni API keys en logs.
Errores frecuentes
El primero es comprobar solo 200 OK. También hay que validar campos obligatorios, campos prohibidos, formato de error y código correcto para fallos.
El segundo es compartir datos de prueba. Un usuario demo global vuelve los tests dependientes del orden y de la ejecución paralela.
El tercero es filtrar secretos en logs. Las cabeceras Authorization, cookies y firmas deben enmascararse incluso cuando el test falla.
El cuarto es depender de servicios externos inestables. Pagos, email y CRM deberían mockearse en CI diario y verificarse contra staging en una puerta más pequeña.
El quinto es no incluir pruebas negativas. Sin auth, payload inválido, ID inexistente, rol prohibido y webhook sin firma son parte del contrato.
El sexto es confiar solo en mocks. El mock es rápido, pero puede ocultar diferencias reales de headers, timeouts, errores y estados. Mantén alguna verificación de contrato o staging en integraciones críticas.
Cierre y CTA
Para que Claude Code escriba buenos tests de API, primero define qué promesa quieres proteger: login estable, creación de órdenes, webhook verificado y bugs antiguos cubiertos por regresión. Después pide el código y el comando de CI.
En equipos, el beneficio real está en estandarizar prompts, OpenAPI, criterios de revisión, gates de CI y diagnóstico de fallos. ClaudeCodeLab puede ayudar con Claude Code training and consultation para adaptar este flujo a un repositorio real. Si trabajas solo, empieza con la chuleta gratuita y el prompt de esta guía.
Masa probó este flujo con el servidor local de Node incluido arriba. El resultado práctico fue que login, creación de orden, webhook y regresiones quedaron cubiertos por node api-smoke.test.mjs. Ese comando detectó más que un simple 200 OK: fuga de password, auth ausente, webhook sin firma, payload inválido y eventos duplicados.
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
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.