Use Cases (Actualizado: 2/6/2026)

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.

Testing de API con Claude Code: guía práctica de automatización

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ónExplicación sencillaEjemplo
Smoke testConfirmar que lo mínimo vive/health devuelve 204, login devuelve 200
Código de estadoResultado expresado con HTTPCrear devuelve 201, sin auth devuelve 401, inexistente devuelve 404
JSON shapeCampos obligatorios y campos prohibidosExiste sessionId, no se devuelve password
AutenticaciónSe exige identidad del llamadorBearer token, cookie o API key
Prueba negativaEnviar datos incorrectos a propósitoContraseña errónea, orden vacía, webhook sin firma
Contract testImplementación y promesa pública coincidenLos campos requeridos por OpenAPI siguen presentes
Datos de pruebaCada ejecución parte de un estado conocidoMock 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.

CasoPor qué importaQué validar
Login y sesión como smoke testCasi todo depende de una sesión válida200, sessionId, forma del usuario, sin contraseña
API de creación de órdenesToca ingresos, inventario, recibos y soporte201, header Location, total, consulta de detalle
Endpoint de webhookServicios externos lo llaman de forma asíncrona y reintentanSin firma es 401, evento válido es 202, duplicado es seguro
Test de regresión de un bugUn bug corregido no debe volver sin aviso400, 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.

#Claude Code #API testing #automation #testing #quality assurance
Gratis

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.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.