En 05-02 terminamos openapi.yaml: un contrato completo, validado, lintado y publicado en /docs. Es un documento excelente. Y no hay absolutamente nada que garantice que el servidor lo cumpla.

Esa frase merece detenerse. Ahora mismo alguien podría añadir un campo obligatorio a POST /pedidos, renombrar precioEuros a precio, o hacer que un 404 devuelva un cuerpo distinto, y todo pasaría: las pruebas de 03-08 seguirían en verde porque comprueban lo que el código hace, no lo que el contrato promete; Spectral seguiría en verde porque el YAML es sintácticamente correcto; y la SPA, Aroma Móvil y RápidoEnvíos se enterarían en producción.

Ese es el problema de esta lección: cerrar el círculo entre el contrato y la realidad, en las dos direcciones. Que las respuestas reales cumplan el esquema. Que un cambio rompedor en el contrato se detecte antes de fusionarse. Y, de paso, que la SPA pueda desarrollarse contra un mock del contrato sin esperar a que el endpoint exista.

Vamos a añadir al proyecto un mock con Prism, validación de esquemas dentro de las pruebas Supertest que ya tenemos, una puerta de cambios rompedores con oasdiff, y un recorrido de compra de extremo a extremo. Y veremos cuándo Pact resuelve un problema real y cuándo es sobreingeniería cara.

Contenido

  1. Cinco consumidores y una API que cambia
  2. El contrato como artefacto ejecutable
  3. Servidores mock: Prism sobre openapi.yaml
  4. Mocks estáticos frente a dinámicos, y sus límites
  5. Dobles en el consumidor: msw en la SPA
  6. Dobles en el proveedor: nock para RápidoEnvíos
  7. Pruebas de contrato del proveedor: validar las respuestas
  8. Validar también las peticiones
  9. Detectar cambios rompedores con oasdiff
  10. oasdiff como puerta en la integración continua
  11. Contract testing dirigido por el consumidor: Pact
  12. Cuándo Pact compensa y cuándo es sobreingeniería
  13. La tabla de tipos de prueba
  14. Pruebas de extremo a extremo: el recorrido de compra
  15. Entorno efímero, datos sembrados y aislamiento
  16. La colección de Postman en CI con Newman
  17. Carga y seguridad: dónde encajan
  18. Qué se ejecuta en cada momento

  1. Cinco consumidores y una API que cambia

El inventario de quién depende de la API de Tienda Aroma, y qué pasa si algo se rompe:

Consumidor Quién lo desarrolla Cómo se despliega Si rompes el contrato
SPA tiendaaroma.example Equipo de front Continuo; se recarga solo Se arregla en horas
Aroma Móvil Equipo móvil Tiendas de aplicaciones, con revisión Días o semanas, y hay usuarios con versiones viejas para siempre
Panel panel.tiendaaroma.example Equipo interno Continuo Horas, pero bloquea a operaciones
RápidoEnvíos Empresa externa Su propio ciclo Reunión, correos, incidencia comercial
CataBox Tercero desconocido No lo controlas Te enteras por un tuit

La fila de Aroma Móvil es la que hace inevitable todo lo de esta lección: no puedes desplegar a los clientes móviles. Una versión de la app de hace ocho meses sigue llamando a tu API, y seguirá haciéndolo. Cualquier cambio rompedor es permanente para alguien.

Y una precisión sobre "romper el contrato": no hace falta mala fe ni descuido. Las roturas típicas son involuntarias y sutiles:

  • Un refactor de los mapeadores hace que notasCata deje de aparecer cuando está vacío, en lugar de devolver [].
  • Una optimización cambia el orden de los resultados y un consumidor dependía de él.
  • Alguien añade .strict() a un esquema de entrada y una app antigua que enviaba un campo extra empieza a recibir 400.
  • Un enum gana un valor nuevo (tueste: "muy_oscuro") y el cliente TypeScript generado de 05-02 no lo contempla.

Ninguna de estas se detecta leyendo el diff. Se detectan con herramientas.

  1. El contrato como artefacto ejecutable

La idea que organiza la lección: openapi.yaml no es documentación, es código. Todo lo que se puede derivar de él:

graph LR
    O[openapi.yaml] --> M[Mock con Prism<br/>la SPA avanza sin backend]
    O --> V[Validación de respuestas<br/>en las pruebas Supertest]
    O --> D[oasdiff<br/>puerta de cambios rompedores]
    O --> C[Clientes generados<br/>SPA y Aroma Móvil - 05-02]
    O --> P[Colección de Postman<br/>importada - 05-01]
    O --> G[Configuración del gateway<br/>rutas y esquemas - 05-06]
    O --> R[Portal de desarrollador<br/>documentación pública - 05-06]

Siete usos de un mismo fichero. Cada uno de ellos hace más caro mantenerlo mal y más rentable mantenerlo bien, que es exactamente el incentivo que se busca.

  1. Servidores mock: Prism sobre openapi.yaml

Situación concreta: el equipo de la SPA tiene que construir la pantalla "mis pedidos con detalle de envío", que necesita un GET /v1/pedidos/{id}/envio que aún no existe. Sin mock, esperan dos semanas o se inventan datos que luego no coinciden.

Prism (de Stoplight) levanta un servidor HTTP que implementa tu especificación:

npm install --save-dev @stoplight/prism-cli

# Mock en el puerto 4010, con validación de peticiones
npx prism mock openapi.yaml --port 4010 --errors
# La SPA apunta a http://localhost:4010 y llama exactamente igual
curl -s http://localhost:4010/cafes?tueste=claro | jq
{
  "datos": [
    {
      "id": "caf_001",
      "nombre": "Etiopía Yirgacheffe",
      "origen": "Etiopía",
      "tueste": "claro",
      "precioEuros": 14.5,
      "stock": 120,
      "notasCata": ["cítrico", "floral", "té negro"],
      "fechaCreacion": "2026-01-15T08:30:00Z",
      "version": 3
    }
  ],
  "total": 137
}

Ese cuerpo sale del ejemplo primeraPagina que escribimos en 05-02. Aquí se ve por qué insistimos en que los ejemplos fueran coherentes: son lo que consume el equipo de front durante dos semanas, y unos ejemplos con datos absurdos producen una interfaz diseñada para datos absurdos.

Opciones importantes de Prism:

Opción Efecto
--errors Devuelve 422 si la petición no cumple la especificación: parámetro inválido, cuerpo mal formado, falta una cabecera obligatoria
--dynamic Genera datos aleatorios que cumplen el esquema, en lugar de repetir el ejemplo
-h 0.0.0.0 Escucha en todas las interfaces: necesario en Docker o para el equipo móvil
Prefer: example=nombre Cabecera que pide un ejemplo concreto de los definidos
Prefer: code=404 Cabecera que pide una respuesta concreta: así se prueban los errores

Esta última es la que convierte el mock en algo serio:

# Forzar el 409 de stock insuficiente para maquetar el mensaje de error
curl -i -X POST http://localhost:4010/pedidos \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f3c1a90-2d64-4e11-9c88-1b2f4a6d0e55" \
  -H "Prefer: code=409, example=stockInsuficiente" \
  -d '{"clienteId":"cli_842","lineas":[{"cafeId":"caf_001","cantidad":200}]}'

Sin esto, el front maqueta el camino feliz y descubre en producción que no había pensado la pantalla de stock insuficiente. Con esto, cada estado de error se puede probar en el navegador el primer día.

Y --errors da un regalo inesperado: valida al consumidor. Si la SPA olvida la Idempotency-Key, el mock responde 422 en desarrollo, no en producción.

Añadimos el script al proyecto:

{
  "scripts": {
    "mock": "prism mock openapi.yaml --port 4010 --errors",
    "mock:dinamico": "prism mock openapi.yaml --port 4010 --errors --dynamic"
  }
}

Prism tiene un segundo modo que merece mención, prism proxy, que reenvía las peticiones a la API real y valida ambas direcciones contra la especificación, avisando de cada desviación. Es una forma barata de auditar un entorno de preproducción entero.

  1. Mocks estáticos frente a dinámicos, y sus límites

Estático (ejemplos) Dinámico (--dynamic)
De dónde salen los datos Los examples del contrato Generados al azar cumpliendo el esquema
Realismo Alto: los escribió una persona Bajo: "nombre": "string", fechas absurdas
Estabilidad Total: la misma respuesta siempre Cambia en cada llamada
Bueno para Maquetar, capturas, demos, pruebas del front Descubrir supuestos ocultos del cliente
Malo para Detectar que el front asume un orden fijo Cualquier prueba que compare valores

Los dinámicos tienen un uso muy concreto y valioso: romper supuestos. Si la SPA falla con --dynamic, es que asumía algo que el contrato no garantiza —que notasCata nunca está vacío, que el total es menor que 1000, que los nombres son cortos—. Ese fallo en desarrollo es un fallo evitado en producción.

Los límites de cualquier mock, y hay que tenerlos muy presentes:

  1. No hay lógica de negocio. El mock acepta un pedido de 500 unidades de un café con 120 de stock. Nunca devolverá stock_insuficiente salvo que se lo pidas con Prefer.
  2. No hay estado. Creas un pedido con POST y GET /pedidos sigue devolviendo el ejemplo de siempre. Los recorridos completos no se pueden probar así.
  3. No hay autenticación real. El mock no valida tokens ni permisos.
  4. Un mock que pasa no demuestra nada sobre la API real. Es la trampa más peligrosa: el front tiene todo en verde contra el mock y falla contra el servidor de verdad.

De ahí la regla: el mock desbloquea el desarrollo en paralelo; no sustituye a ninguna prueba de integración. Y el momento de conectar la SPA contra la API real debe ser lo más temprano posible.

  1. Dobles en el consumidor: msw en la SPA

Prism es un proceso aparte, útil mientras se desarrolla. Para las pruebas automáticas del front se necesita algo que corra dentro del proceso de pruebas: Mock Service Worker (msw), que intercepta las peticiones a nivel de red sin que el código de la aplicación se entere.

// aroma-spa/pruebas/servidor-simulado.js
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const URL_BASE = 'https://api.tiendaaroma.example/v1';

export const manejadores = [
  // Catálogo con dos cafés, filtrable de verdad: el mock SÍ implementa el filtro,
  // porque la prueba quiere comprobar que la SPA lo envía bien.
  http.get(`${URL_BASE}/cafes`, ({ request }) => {
    const url = new URL(request.url);
    const tueste = url.searchParams.get('tueste');

    const catalogo = [
      { id: 'caf_001', nombre: 'Etiopía Yirgacheffe', tueste: 'claro', precioEuros: 14.5, stock: 120 },
      { id: 'caf_002', nombre: 'Colombia Huila', tueste: 'medio', precioEuros: 12.9, stock: 80 },
    ];
    const datos = tueste ? catalogo.filter((c) => c.tueste === tueste) : catalogo;

    return HttpResponse.json({ datos, total: datos.length });
  }),

  // Error de negocio: la SPA debe mostrar un mensaje concreto, no un genérico
  http.post(`${URL_BASE}/pedidos`, async ({ request }) => {
    if (!request.headers.get('Idempotency-Key')) {
      return HttpResponse.json({
        error: { codigo: 'clave_idempotencia_requerida', mensaje: '...', detalles: [] },
      }, { status: 428 });
    }

    const cuerpo = await request.json();
    if (cuerpo.lineas.some((l) => l.cantidad > 100)) {
      return HttpResponse.json({
        error: {
          codigo: 'stock_insuficiente',
          mensaje: 'No hay stock suficiente de "Etiopía Yirgacheffe".',
          detalles: [{ campo: 'lineas[0].cantidad', solicitado: 200, disponible: 120 }],
        },
      }, { status: 409 });
    }

    return HttpResponse.json(
      { id: 'ped_5001', estado: 'pendiente_pago', totalEuros: 29.0 },
      { status: 201, headers: { Location: '/v1/pedidos/ped_5001' } },
    );
  }),
];

export const servidorSimulado = setupServer(...manejadores);
// aroma-spa/pruebas/catalogo.prueba.js
import { servidorSimulado } from './servidor-simulado.js';
import { http, HttpResponse } from 'msw';

before(() => servidorSimulado.listen({ onUnhandledRequest: 'error' }));
afterEach(() => servidorSimulado.resetHandlers());
after(() => servidorSimulado.close());

test('muestra el aviso de límite cuando la API responde 429', async () => {
  // Sobrescribe el manejador SOLO para esta prueba
  servidorSimulado.use(
    http.get('*/cafes', () => HttpResponse.json(
      { error: { codigo: 'limite_peticiones', mensaje: '...', detalles: [] } },
      { status: 429, headers: { 'Retry-After': '30' } },
    )),
  );

  const pantalla = renderizar(<Catalogo />);
  await pantalla.encontrarPorTexto(/demasiadas peticiones/i);
  // Y comprobamos que respeta el Retry-After en lugar de reintentar en bucle (04-04)
});

onUnhandledRequest: 'error' es la opción clave: cualquier petición que la SPA haga y que no esté declarada hace fallar la prueba. Así se descubren llamadas inesperadas —analítica, un endpoint olvidado— en lugar de que se escapen silenciosamente.

El riesgo estructural de msw, y hay que decirlo claro: estos manejadores son una tercera descripción de la API, escrita por el equipo de front, que puede desviarse de la real. Si el backend cambia precioEuros por precio, las pruebas del front siguen en verde. Dos mitigaciones: generar los manejadores desde openapi.yaml con herramientas como msw-auto-mock, o —lo que resuelve el problema de raíz— el contract testing del apartado 11.

  1. Dobles en el proveedor: nock para RápidoEnvíos

El problema simétrico: nuestra API también es consumidora. Llama a RápidoEnvíos para crear un envío y le envía webhooks firmados. Las pruebas de 03-08 no pueden llamar de verdad a un servicio externo: sería lento, frágil y crearía envíos reales.

nock intercepta las peticiones HTTP salientes de Node:

// pruebas/integracion/envios.prueba.js
import './../ayudas/entorno-prueba.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import nock from 'nock';
import request from 'supertest';
import { migrar, sembrar } from '../ayudas/base-datos-prueba.js';
import { tokenDe } from '../ayudas/token.js';

const API_RAPIDOENVIOS = 'https://api.rapidoenvios.example';

test.before(() => {
  // Ninguna petición real sale de las pruebas. Si algún código intenta llamar
  // a un host no interceptado, nock lanza y la prueba falla ruidosamente.
  nock.disableNetConnect();
  nock.enableNetConnect('127.0.0.1');   // salvo Supertest, que llama a sí mismo
});

test.after(() => {
  nock.cleanAll();
  nock.enableNetConnect();
});

test('al pagar un pedido se solicita el envío a RápidoEnvíos', async (t) => {
  const { app } = await import('../../src/app.js');
  migrar(); sembrar();

  // Declaramos qué esperamos que nuestra API envíe, y qué responderá el simulado
  const alcance = nock(API_RAPIDOENVIOS)
    .post('/v2/envios', (cuerpo) => {
      // La aserción sobre la petición SALIENTE es lo valioso de esta prueba:
      assert.equal(cuerpo.referencia, 'ped_5001');
      assert.equal(cuerpo.pesoGramos, 500);
      assert.ok(cuerpo.destino.codigoPostal, 'debe enviarse el código postal');
      return true;
    })
    .matchHeader('authorization', /^Bearer /)
    .matchHeader('idempotency-key', /^[0-9a-f-]{36}$/)   // también reintentamos con seguridad
    .reply(201, { envioId: 'env_9001', seguimiento: 'RE-4471-XA' });

  const respuesta = await request(app)
    .post('/v1/pedidos/ped_5001/pago')
    .set('Authorization', `Bearer ${tokenDe('cli_842', 'cliente')}`)
    .set('Idempotency-Key', crypto.randomUUID())
    .send({ metodo: 'tarjeta', tokenTarjeta: 'tok_prueba_ficticio' })
    .expect(200);

  assert.equal(respuesta.body.estado, 'pagado');
  assert.equal(respuesta.body.seguimiento, 'RE-4471-XA');
  assert.ok(alcance.isDone(), 'no se llamó a RápidoEnvíos');
});

test('si RápidoEnvíos falla, el pago se completa igual y el envío queda pendiente', async () => {
  const { app } = await import('../../src/app.js');
  migrar(); sembrar();

  nock(API_RAPIDOENVIOS).post('/v2/envios').reply(503, { mensaje: 'mantenimiento' });

  const respuesta = await request(app)
    .post('/v1/pedidos/ped_5001/pago')
    .set('Authorization', `Bearer ${tokenDe('cli_842', 'cliente')}`)
    .set('Idempotency-Key', crypto.randomUUID())
    .send({ metodo: 'tarjeta', tokenTarjeta: 'tok_prueba_ficticio' })
    .expect(200);

  // Regla de negocio: el cobro no se revierte porque el transportista esté caído.
  // El envío se encola y se reintenta. Esta prueba documenta esa decisión.
  assert.equal(respuesta.body.estado, 'pagado');
  assert.equal(respuesta.body.envio.estado, 'pendiente_solicitud');
});

nock.disableNetConnect() es una práctica que merece adoptarse siempre: garantiza que ninguna prueba llame a internet. Una suite que depende de la red es una suite que falla los viernes por la tarde por motivos ajenos.

La segunda prueba ilustra algo que solo se puede probar con dobles: el comportamiento ante el fallo de una dependencia. Provocar un 503 real de RápidoEnvíos es imposible; simularlo, trivial.

  1. Pruebas de contrato del proveedor: validar las respuestas

Llegamos al núcleo. La pregunta es: ¿las respuestas reales de nuestra API cumplen openapi.yaml?

La técnica: extraer los esquemas del contrato, compilarlos con AJV y validar contra ellos el cuerpo de las respuestas dentro de las pruebas Supertest que ya existen.

Fichero nuevo pruebas/ayudas/contrato.js:

// pruebas/ayudas/contrato.js
// Valida cuerpos de respuesta contra los esquemas de openapi.yaml.
// Es la pieza que impide que el contrato y la implementación se separen.
import { readFileSync } from 'node:fs';
import Ajv2020 from 'ajv/dist/2020.js';       // OpenAPI 3.1 usa JSON Schema 2020-12
import addFormats from 'ajv-formats';
import YAML from 'yaml';
import assert from 'node:assert/strict';

const especificacion = YAML.parse(readFileSync('openapi.yaml', 'utf8'));

const ajv = new Ajv2020({
  strict: false,        // OpenAPI añade palabras que AJV no conoce (example, xml...)
  allErrors: true,      // queremos TODOS los errores, no solo el primero
  validateFormats: true,
});
addFormats(ajv);        // habilita date-time, uuid, email, uri-reference...

// Registramos todos los esquemas de components para que los $ref internos resuelvan.
for (const [nombre, esquema] of Object.entries(especificacion.components.schemas)) {
  ajv.addSchema(esquema, `#/components/schemas/${nombre}`);
}

/**
 * Comprueba que un cuerpo cumple un esquema de components.schemas.
 * @param {string} nombreEsquema  p. ej. 'ColeccionCafes'
 * @param {unknown} cuerpo        el cuerpo de la respuesta
 */
export function cumpleEsquema(nombreEsquema, cuerpo) {
  const esquema = especificacion.components.schemas[nombreEsquema];
  assert.ok(esquema, `El esquema "${nombreEsquema}" no existe en openapi.yaml`);

  const validar = ajv.compile(esquema);
  const valido = validar(cuerpo);

  if (!valido) {
    const problemas = validar.errors
      .map((e) => `  · ${e.instancePath || '(raíz)'} ${e.message}`)
      .join('\n');
    assert.fail(
      `La respuesta no cumple el esquema "${nombreEsquema}":\n${problemas}\n` +
      `Cuerpo recibido:\n${JSON.stringify(cuerpo, null, 2)}`,
    );
  }
}

/**
 * Localiza en openapi.yaml el esquema declarado para una operación y un código,
 * y valida contra él. Evita tener que nombrar el esquema a mano en cada prueba.
 */
export function cumpleContrato(ruta, metodo, codigo, cuerpo) {
  const operacion = especificacion.paths?.[ruta]?.[metodo.toLowerCase()];
  assert.ok(operacion, `openapi.yaml no describe ${metodo.toUpperCase()} ${ruta}`);

  const respuesta = operacion.responses?.[String(codigo)]
    ?? operacion.responses?.[`${String(codigo)[0]}XX`];
  assert.ok(respuesta, `openapi.yaml no documenta el ${codigo} de ${metodo} ${ruta}`);

  // Resolvemos la $ref de components.responses si la hay
  const resuelta = respuesta.$ref
    ? especificacion.components.responses[respuesta.$ref.split('/').pop()]
    : respuesta;

  const esquema = resuelta.content?.['application/json']?.schema;
  if (!esquema) return;   // respuestas sin cuerpo, como el 204 o el 304

  const nombre = esquema.$ref?.split('/').pop();
  if (nombre) return cumpleEsquema(nombre, cuerpo);

  const validar = ajv.compile(esquema);
  assert.ok(validar(cuerpo), JSON.stringify(validar.errors, null, 2));
}

Y su uso en las pruebas de integración, que apenas cambian:

// pruebas/integracion/cafes.prueba.js — ampliación de las pruebas de 03-08
import './../ayudas/entorno-prueba.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { migrar, sembrar } from '../ayudas/base-datos-prueba.js';
import { tokenDe } from '../ayudas/token.js';
import { cumpleContrato, cumpleEsquema } from '../ayudas/contrato.js';

test('GET /v1/cafes cumple el contrato publicado', async () => {
  const { app } = await import('../../src/app.js');
  migrar(); sembrar();

  const respuesta = await request(app)
    .get('/v1/cafes?tueste=claro&limite=10')
    .set('Authorization', `Bearer ${tokenDe('cli_842', 'cliente')}`)
    .expect(200);

  // Aserciones de comportamiento (las de 03-08, siguen siendo necesarias)
  assert.ok(respuesta.body.datos.every((c) => c.tueste === 'claro'));

  // Aserción de CONTRATO: la forma exacta, contra openapi.yaml
  cumpleContrato('/cafes', 'get', 200, respuesta.body);
});

test('los errores 404 cumplen el esquema Error del catálogo', async () => {
  const { app } = await import('../../src/app.js');
  migrar(); sembrar();

  const respuesta = await request(app)
    .get('/v1/cafes/caf_inexistente')
    .set('Authorization', `Bearer ${tokenDe('cli_842', 'cliente')}`)
    .expect(404);

  cumpleEsquema('Error', respuesta.body);
  // El enum del esquema Error garantiza que el código está en el catálogo:
  // si alguien inventa 'cafe_no_existe', esta línea falla.
  assert.equal(respuesta.body.error.codigo, 'cafe_no_encontrado');
});

test('POST /v1/pedidos devuelve un Pedido conforme al contrato', async () => {
  const { app } = await import('../../src/app.js');
  migrar(); sembrar();

  const respuesta = await request(app)
    .post('/v1/pedidos')
    .set('Authorization', `Bearer ${tokenDe('cli_842', 'cliente')}`)
    .set('Idempotency-Key', crypto.randomUUID())
    .send({ clienteId: 'cli_842', lineas: [{ cafeId: 'caf_001', cantidad: 2 }] })
    .expect(201);

  cumpleContrato('/pedidos', 'post', 201, respuesta.body);
  assert.match(respuesta.headers.location, /^\/v1\/pedidos\/ped_/);
});

Qué detecta esto que las pruebas de 03-08 no detectaban:

Cambio ¿Lo veía 03-08? ¿Lo ve la prueba de contrato?
Renombrar precioEuros a precio Sí, si había una aserción sobre ese campo Sí, siempre: es required
Dejar de devolver version No, salvo aserción explícita Sí: es required en el esquema
Devolver precioEuros: 1450 (céntimos) Solo si había aserción del valor Sí, si el esquema tiene multipleOf: 0.01... y sobre todo lo vería el pattern/rango
Un código de error nuevo fuera del catálogo No : el enum del esquema Error lo rechaza
fechaCreacion sin la Z final No Sí: format: date-time
Añadir un campo nuevo No No, y es correcto: es un cambio compatible (02-07)

La última fila es tan importante como las demás. Recuerda que en 05-02 dejamos deliberadamente los esquemas de salida sin additionalProperties: false. Si los cerráramos, cada campo nuevo rompería estas pruebas y el equipo acabaría desactivándolas.

Una advertencia sobre la cobertura: estas pruebas solo validan los endpoints y códigos que hayas probado. Si nunca escribes una prueba que provoque el 429, nadie comprueba que ese cuerpo cumpla el contrato. Una manera barata de subir la cobertura es un envoltorio que valide todas las respuestas automáticamente:

// pruebas/ayudas/peticion.js
// Envoltorio de Supertest que valida el contrato en CADA respuesta, sin recordarlo.
import request from 'supertest';
import { cumpleContrato } from './contrato.js';

export function peticion(app, plantillaRuta) {
  const agente = request(app);
  const original = agente.get.bind(agente);

  return {
    get(url) {
      return original(url).expect((res) => {
        cumpleContrato(plantillaRuta, 'get', res.status, res.body);
      });
    },
    // ... post, patch, delete equivalentes
  };
}

  1. Validar también las peticiones

El contrato tiene dos lados. También conviene comprobar que los cuerpos que documentamos como válidos lo son de verdad para el servidor, y que los inválidos se rechazan:

// pruebas/integracion/contrato-entradas.prueba.js
import { readFileSync } from 'node:fs';
import YAML from 'yaml';
import test from 'node:test';
import request from 'supertest';
import { tokenDe } from '../ayudas/token.js';

const especificacion = YAML.parse(readFileSync('openapi.yaml', 'utf8'));

test('todos los ejemplos de requestBody del contrato son aceptados por la API', async () => {
  const { app } = await import('../../src/app.js');
  migrar(); sembrar();

  const ejemplos = especificacion.paths['/pedidos'].post
    .requestBody.content['application/json'].examples;

  for (const [nombre, ejemplo] of Object.entries(ejemplos)) {
    const respuesta = await request(app)
      .post('/v1/pedidos')
      .set('Authorization', `Bearer ${tokenDe('cli_842', 'cliente')}`)
      .set('Idempotency-Key', crypto.randomUUID())
      .send(ejemplo.value);

    // Un ejemplo del contrato que produce 400 es un error DEL CONTRATO:
    // estás publicando en la documentación un cuerpo que tu API rechaza.
    assert.notEqual(respuesta.status, 400,
      `El ejemplo "${nombre}" del contrato es rechazado por la API: ` +
      JSON.stringify(respuesta.body));
  }
});

Esta prueba parece menor y atrapa un fallo muy común y muy dañino: el ejemplo de la documentación que no funciona. Es lo primero que copia y pega quien se integra contigo, y si falla, tu API pierde credibilidad en los primeros cinco minutos.

  1. Detectar cambios rompedores con oasdiff

Las pruebas anteriores comprueban que el servidor cumple el contrato actual. Falta la otra pregunta: ¿el contrato nuevo rompe a alguien respecto del anterior?

oasdiff compara dos versiones de una especificación y clasifica las diferencias:

# Instalación (Go, o binario, o imagen Docker)
go install github.com/tufin/oasdiff@latest

# Resumen de diferencias entre la versión publicada y la de esta rama
oasdiff diff openapi-produccion.yaml openapi.yaml --format text

# Solo los cambios ROMPEDORES: esto es lo que interesa en CI
oasdiff breaking openapi-produccion.yaml openapi.yaml

Salida típica cuando alguien mete la pata:

2 breaking changes: 2 error, 0 warning

error, in components/schemas/Cafe property/notasCata request property became required
    in API GET /cafes
error, in API POST /pedidos request property 'lineas/items/cantidad' max was decreased
    from 99 to 20

La clasificación que hace oasdiff coincide, no por casualidad, con las reglas de 02-07:

Cambio ¿Rompedor? Motivo
Añadir un endpoint No Nadie lo llamaba
Añadir un campo opcional a una petición No Los clientes antiguos no lo envían
Añadir un campo a una respuesta No Los clientes deben ignorar lo desconocido
Hacer obligatorio un campo de petición Los clientes antiguos no lo envían → 400
Eliminar un campo de una respuesta Alguien lo estaba leyendo
Eliminar un valor de un enum de respuesta Un cliente podía tenerlo mapeado
Añadir un valor a un enum de respuesta Rompedor "suave" El cliente generado puede no contemplarlo
Añadir un valor a un enum de petición No Es ampliar lo aceptado
Restringir un rango (maximum menor) Peticiones antes válidas ahora fallan
Ampliar un rango (maximum mayor) No Es aceptar más
Cambiar el tipo de un campo Rotura clásica
Eliminar un endpoint Obvio
Marcar como deprecated No Solo avisa; la retirada es el cambio
Añadir un código de error nuevo Depende Si el cliente hace switch exhaustivo, le afecta

Ese cuadro es la traducción operativa de la compatibilidad hacia atrás. Y lo importante es que ya no depende de que el revisor del pull request lo recuerde: lo comprueba una herramienta.

Nótese el matiz de las dos filas del enum: la dirección importa. Ampliar lo que aceptas es seguro; ampliar lo que devuelves puede romper a un cliente que hiciera switch sobre los valores conocidos. Es exactamente el caso del tueste: "muy_oscuro" del apartado 1, y por eso el contrato de 05-02 avisa en la descripción del campo de que pueden añadirse valores nuevos.

  1. oasdiff como puerta en la integración continua

Para comparar hay que tener una referencia. Dos estrategias:

  1. Contra la rama principal, con git show main:openapi.yaml. Simple y suficiente para la mayoría de equipos.
  2. Contra la especificación publicada en producción, descargada de https://api.tiendaaroma.example/docs/openapi.json. Más correcto —lo que importa es lo que hay desplegado, no lo que hay en main— y algo más frágil.

Un script que sirve en local y en CI:

#!/usr/bin/env bash
# herramientas/comprobar-contrato.sh
# Falla si esta rama introduce cambios rompedores respecto de la rama principal.
set -euo pipefail

BASE="${1:-main}"
TEMPORAL="$(mktemp -d)"

git show "${BASE}:openapi.yaml" > "${TEMPORAL}/base.yaml" 2>/dev/null || {
  echo "No hay openapi.yaml en ${BASE}: primera versión, nada que comparar."
  exit 0
}

echo "== Diferencias respecto de ${BASE} =="
oasdiff diff "${TEMPORAL}/base.yaml" openapi.yaml --format text || true

echo "== Comprobación de cambios rompedores =="
if oasdiff breaking "${TEMPORAL}/base.yaml" openapi.yaml --fail-on ERR; then
  echo "Sin cambios rompedores."
else
  cat <<'AVISO'

CAMBIO ROMPEDOR DETECTADO.

Según la política de versionado (02-07), un cambio rompedor exige una de estas tres vías:

  1. Reformular el cambio de forma compatible (campo opcional, valor por defecto,
     campo nuevo en lugar de renombrar el existente).
  2. Iniciar el ciclo de deprecación: mantener lo antiguo, marcarlo `deprecated`,
     emitir `Deprecation` y `Sunset`, y avisar a los consumidores.
  3. Abrir /v2, con al menos 6 meses de convivencia.

Si el cambio es intencionado y está acordado, añade la etiqueta "cambio-rompedor"
al pull request y documenta la decisión en un ADR de docs/decisiones/.
AVISO
  exit 1
fi

Con oasdiff en la tubería, la conversación cambia de naturaleza: en lugar de discutir en la revisión si un cambio rompe algo, la herramienta lo dice y la discusión pasa a ser qué hacer al respecto. Esta es la puerta que 05-05 integrará en ci.yml.

  1. Contract testing dirigido por el consumidor: Pact

Hasta aquí, el contrato lo define el proveedor (nosotros) y los consumidores se adaptan. Es el modelo correcto para una API pública. Pero hay otro modelo que resuelve un problema distinto.

Imagina que Tienda Aroma crece y aparecen inventario-service, pagos-service y recomendaciones-service, que se llaman entre sí. Preguntas incómodas: ¿qué campos de la respuesta de inventario-service usa realmente cada consumidor? ¿Puedo eliminar uno? La respuesta honesta suele ser "no lo sé, por si acaso no toco nada", y así los servicios se fosilizan.

Pact invierte la dirección: cada consumidor declara qué necesita, y el proveedor verifica que lo cumple.

sequenceDiagram
    participant C as SPA consumidora
    participant B as Pact Broker
    participant P as API Tienda Aroma
    C->>C: Prueba del consumidor contra un mock local
    Note over C: Se genera el PACTO<br/>llamo a GET /v1/cafes con tueste claro<br/>y necesito los campos id y precioEuros
    C->>B: Publica el pacto con su versión y rama
    P->>B: Descarga todos los pactos de sus consumidores
    P->>P: Reproduce cada interacción contra la API real
    P->>B: Publica el resultado de la verificación
    B-->>C: can-i-deploy: ¿puedo desplegar?
    B-->>P: can-i-deploy: ¿puedo desplegar?

El lado del consumidor:

// aroma-spa/pruebas/contrato/cafes.pacto.js
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const { like, eachLike, string, integer, decimal } = MatchersV3;

const proveedor = new PactV3({
  consumer: 'aroma-spa',
  provider: 'tienda-aroma-api',
});

test('la SPA obtiene el catálogo filtrado por tueste', async () => {
  await proveedor
    .given('existen cafés de tueste claro')     // ESTADO que el proveedor debe montar
    .uponReceiving('una petición del catálogo de tueste claro')
    .withRequest({
      method: 'GET',
      path: '/v1/cafes',
      query: { tueste: 'claro', limite: '20' },
      headers: { Authorization: like('Bearer token-ficticio') },
    })
    .willRespondWith({
      status: 200,
      headers: { 'Content-Type': 'application/json; charset=utf-8' },
      body: {
        // Los matchers describen la FORMA, no los valores concretos.
        // La SPA declara así exactamente qué campos consume.
        datos: eachLike({
          id: string('caf_001'),
          nombre: string('Etiopía Yirgacheffe'),
          tueste: string('claro'),
          precioEuros: decimal(14.5),
          stock: integer(120),
        }),
        total: integer(137),
      },
    })
    .executeTest(async (mockServer) => {
      const api = new ClienteCafes(mockServer.url);
      const resultado = await api.listar({ tueste: 'claro', limite: 20 });
      assert.equal(resultado.datos[0].nombre, 'Etiopía Yirgacheffe');
    });
});

El lado del proveedor:

// pruebas/contrato/verificar-pactos.js
import { Verifier } from '@pact-foundation/pact';
import { servidor } from '../../src/servidor.js';
import { migrar, sembrar, sembrarSoloClaros } from '../ayudas/base-datos-prueba.js';

await new Verifier({
  provider: 'tienda-aroma-api',
  providerBaseUrl: 'http://localhost:3001',
  pactBrokerUrl: process.env.PACT_BROKER_URL,
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  providerVersion: process.env.GIT_COMMIT,
  publishVerificationResult: true,

  // Los "estados" del consumidor se traducen aquí en datos reales.
  // Esta es la parte que cuesta mantener y donde Pact se complica.
  stateHandlers: {
    'existen cafés de tueste claro': async () => {
      migrar(); sembrarSoloClaros();
    },
    'el pedido ped_5001 está pendiente de pago': async () => {
      migrar(); sembrar();
    },
  },

  // Los tokens de los pactos son ficticios: aquí se sustituyen por válidos.
  requestFilter: (req, res, next) => {
    req.headers.authorization = `Bearer ${tokenDe('cli_842', 'cliente')}`;
    next();
  },
}).verifyProvider();

Lo que Pact da y OpenAPI no puede dar:

  • Saber qué campos se usan de verdad. Si ningún pacto menciona notasCata, puedes eliminarlo con confianza. Es la única forma fiable de saberlo.
  • can-i-deploy. Antes de desplegar, el broker responde si la versión que quieres desplegar es compatible con las versiones desplegadas de todos sus consumidores. Es una puerta de despliegue con información real, no con suposiciones.
  • Interacciones concretas, no formas abstractas. El pacto dice "cuando pido esto, con estos parámetros y en este estado, necesito esta respuesta".

  1. Cuándo Pact compensa y cuándo es sobreingeniería

Pact tiene un coste alto y conviene ser honesto sobre él: hay que operar un broker, escribir y mantener los stateHandlers —la parte que más duele—, coordinar dos repositorios y formar a dos equipos.

Situación ¿Pact? Razón
Microservicios internos, varios equipos Es su caso de uso exacto: consumidores conocidos y controlables
Dos equipos de la misma empresa (front y back) Quizá Compensa si el despliegue es independiente y las roturas son frecuentes
API pública con consumidores desconocidos No No puedes obligar a CataBox a publicar un pacto. Manda el contrato OpenAPI del proveedor
Aplicación móvil con versiones antiguas vivas No El pacto refleja la versión actual de la app, no la de hace ocho meses
Un equipo, un consumidor, monolito No Las pruebas de integración cubren lo mismo por mucho menos
Proveedor externo (RápidoEnvíos) No No controlas su ciclo. Usa nock y pruebas de integración contra su sandbox

Para Tienda Aroma hoy la respuesta es no, y conviene razonarla: dos de los cinco consumidores están fuera de nuestro control (RápidoEnvíos y CataBox), uno tiene versiones antiguas permanentes (Aroma Móvil), y la API es esencialmente pública. En ese escenario, el contrato del proveedor manda y openapi.yaml con oasdiff cubre el 90 % del valor por el 10 % del coste.

La respuesta cambiaría el día que Tienda Aroma se parta en microservicios internos con equipos separados. Entonces Pact entre pedidos-service e inventario-service sería exactamente la herramienta adecuada.

Y una idea que resume la elección: Pact responde "¿qué necesitan mis consumidores?"; OpenAPI responde "¿qué prometo yo?". Con consumidores conocidos, la primera pregunta es más útil. Con consumidores desconocidos, es imposible de responder, y solo queda la segunda.

  1. La tabla de tipos de prueba

Unitaria Integración Contrato (proveedor) Contrato (Pact) Mock (consumidor) Extremo a extremo
Qué prueba Una función o servicio La API completa en proceso Que las respuestas cumplen OpenAPI Que se cumple lo que pide cada consumidor Que el cliente maneja bien las respuestas El sistema real desplegado
Qué levanta Nada app + SQLite temporal Igual que integración La API + el broker Nada, se intercepta Todo: API, Redis, base de datos
Velocidad Milisegundos Décimas de segundo Igual Segundos Milisegundos Minutos
Fragilidad Muy baja Baja Baja Media Baja Alta
Cuándo se ejecuta Al guardar Al guardar / PR PR PR de ambos lados PR del front Tras desplegar
Qué NO detecta Nada de integración Deriva del contrato Que el consumidor lo use bien Consumidores no participantes Que la API real cumpla Nada, pero falla por causas ajenas
Cuántas tener Muchas Bastantes Una por endpoint y código Una por interacción real Las del front Pocas
En Tienda Aroma Sí (03-08) Sí (03-08) Sí, esta lección No, por ahora Sí, en la SPA Sí, un puñado

La fila "cuántas tener" es la pirámide de pruebas de 03-08 vista desde el contrato. La regla no ha cambiado: muchas rápidas abajo, pocas lentas arriba. Lo que esta lección añade es una capa nueva —el contrato— que es casi tan barata como las de integración porque se monta encima de ellas: no son pruebas nuevas, son aserciones extra en las que ya existen.

  1. Pruebas de extremo a extremo: el recorrido de compra

Las pruebas de extremo a extremo corren contra el sistema realmente desplegado: proceso de verdad, base de datos de verdad, Redis de verdad, red de verdad. Son las únicas que detectan que la variable de entorno está mal en preproducción, que el balanceador se come una cabecera o que la migración no se aplicó.

Y son caras y frágiles. Por eso: pocas y bien elegidas. El criterio es cubrir los recorridos que, si se rompen, hacen que el negocio pare. En Tienda Aroma, uno: la compra.

// pruebas/e2e/recorrido-compra.prueba.js
// Se ejecuta contra un entorno DESPLEGADO, no contra `app` en proceso.
// URL_API se inyecta desde CI: preproducción, o local con docker-compose.
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';

const URL = process.env.URL_API ?? 'http://localhost:3000/v1';

async function llamar(ruta, opciones = {}) {
  const respuesta = await fetch(`${URL}${ruta}`, {
    ...opciones,
    headers: { 'Content-Type': 'application/json', ...opciones.headers },
  });
  const cuerpo = respuesta.status === 204 ? null : await respuesta.json();
  return { estado: respuesta.status, cuerpo, cabeceras: respuesta.headers };
}

test('recorrido completo de compra', async (t) => {
  let token;
  let cafeId;
  let pedidoId;

  await t.test('1. el cliente inicia sesión', async () => {
    const { estado, cuerpo } = await llamar('/sesiones', {
      method: 'POST',
      body: JSON.stringify({
        email: process.env.EMAIL_PRUEBA,       // cuenta ficticia sembrada
        password: process.env.CLAVE_PRUEBA,    // llega del gestor de secretos
      }),
    });
    assert.equal(estado, 200);
    assert.ok(cuerpo.token, 'no se devolvió token');
    token = cuerpo.token;
  });

  await t.test('2. consulta el catálogo y elige un café disponible', async () => {
    const { estado, cuerpo, cabeceras } = await llamar('/cafes?disponible=true&limite=5', {
      headers: { Authorization: `Bearer ${token}` },
    });
    assert.equal(estado, 200);
    assert.ok(cuerpo.datos.length > 0, 'el catálogo está vacío: ¿se sembró la base?');

    // Comprobaciones que SOLO tienen sentido en un entorno real:
    assert.ok(cabeceras.get('etag'), 'falta ETag: ¿el middleware de caché está activo?');
    assert.ok(cabeceras.get('aroma-ratelimit-restantes'), 'falta el rate limiting');
    assert.ok(cabeceras.get('aroma-traza-id'), 'falta la correlación de trazas');

    cafeId = cuerpo.datos.find((c) => c.stock >= 2).id;
  });

  await t.test('3. crea un pedido con clave de idempotencia', async () => {
    const clave = randomUUID();
    const cuerpoPeticion = JSON.stringify({
      clienteId: process.env.CLIENTE_PRUEBA,
      lineas: [{ cafeId, cantidad: 2 }],
    });

    const primera = await llamar('/pedidos', {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': clave },
      body: cuerpoPeticion,
    });
    assert.equal(primera.estado, 201);
    assert.equal(primera.cuerpo.estado, 'pendiente_pago');
    pedidoId = primera.cuerpo.id;

    // Reintento con la MISMA clave: la idempotencia de 02-03, comprobada de verdad
    // (en producción intervienen Redis y varias instancias, no una tabla en memoria)
    const segunda = await llamar('/pedidos', {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': clave },
      body: cuerpoPeticion,
    });
    assert.equal(segunda.estado, 201);
    assert.equal(segunda.cuerpo.id, pedidoId, 'la idempotencia creó un pedido duplicado');
  });

  await t.test('4. paga el pedido', async () => {
    const { estado, cuerpo } = await llamar(`/pedidos/${pedidoId}/pago`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': randomUUID() },
      body: JSON.stringify({ metodo: 'tarjeta', tokenTarjeta: 'tok_sandbox_ficticio' }),
    });
    assert.equal(estado, 200);
    assert.equal(cuerpo.estado, 'pagado');
  });

  await t.test('5. consulta el pedido y comprueba su estado persistido', async () => {
    const { estado, cuerpo } = await llamar(`/pedidos/${pedidoId}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    assert.equal(estado, 200);
    assert.equal(cuerpo.estado, 'pagado');
    assert.equal(cuerpo.totalEuros > 0, true);
    assert.ok(cuerpo._links.factura, 'un pedido pagado debe enlazar su factura');
  });

  t.after(async () => {
    // Limpieza: sin ella, cada ejecución deja rastro y el entorno se degrada.
    if (pedidoId) {
      await llamar(`/pedidos/${pedidoId}/anulacion`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': randomUUID() },
        body: JSON.stringify({ motivo: 'prueba automatizada' }),
      });
    }
  });
});

Fíjate en las tres aserciones de cabeceras del paso 2. Son la razón de ser de esta prueba: ninguna prueba en proceso puede comprobarlas, porque en Supertest no hay proxy inverso, ni Redis, ni las variables de entorno de producción. Si el balanceador elimina Aroma-Traza-Id o el rate limiting no está configurado en preproducción, esto es lo único que lo detecta.

  1. Entorno efímero, datos sembrados y aislamiento

Las pruebas de extremo a extremo tienen fama de frágiles, y casi siempre por la misma causa: el estado compartido. Cuatro reglas que la resuelven.

1. Entorno efímero. El entorno se crea al empezar y se destruye al terminar. Con docker-compose (que veremos en 05-05) es directo:

# El entorno completo, levantado y destruido en la propia ejecución
docker compose -f docker-compose.pruebas.yml up -d --wait
npm run migrar && npm run sembrar
URL_API=http://localhost:3000/v1 node --test pruebas/e2e/
docker compose -f docker-compose.pruebas.yml down -v   # -v borra también los volúmenes

--wait espera a que los healthcheck estén verdes: sin eso, las pruebas arrancan antes que la base de datos y fallan por una carrera, no por un fallo real.

2. Datos sembrados y conocidos, siempre ficticios. El mismo npm run sembrar de 03-05: caf_001, cli_842, ped_5001. Nunca datos reales de clientes en un entorno de pruebas, ni siquiera anonimizados a medias (04-02 y RGPD).

3. Aislamiento entre ejecuciones. Si el entorno es compartido —el caso habitual de preproducción—, cada ejecución debe usar datos propios:

// Prefijo único por ejecución: dos tuberías simultáneas no chocan
const marca = `e2e-${Date.now()}-${randomUUID().slice(0, 8)}`;
const email = `prueba+${marca}@ejemplo.test`;   // el "+" crea alias únicos

4. Idempotencia de la prueba. Debe poder ejecutarse dos veces seguidas con el mismo resultado. Eso implica no depender de un estado dejado por la ejecución anterior y limpiar al final, incluso si falla (t.after se ejecuta siempre).

Errores clásicos que rompen estas cuatro reglas: pruebas que dependen del orden en que corren, una que crea un cliente con email fijo y falla la segunda vez por duplicado, y —la peor— la que consume el stock de caf_001 hasta agotarlo y hace fallar a todas las demás desde ese día.

  1. La colección de Postman en CI con Newman

La colección de 05-01 encaja aquí de forma natural: es una prueba de extremo a extremo con interfaz gráfica para escribirla y depurarla.

npx newman run postman/tienda-aroma-v1.postman_collection.json \
  -e postman/pruebas.postman_environment.json \
  --env-var "urlBase=$URL_PREPRODUCCION" \
  --env-var "claveCliente=$CLAVE_CLIENTE_PRUEBAS" \
  --delay-request 100 \
  --reporters cli,junit \
  --reporter-junit-export informes/newman.xml

¿Newman o pruebas de extremo a extremo en código? No compiten; cubren necesidades distintas:

Newman Pruebas e2e en código
Quién las escribe También QA y personas no desarrolladoras Desarrollo
Depuración Excelente: interfaz gráfica, paso a paso Con el depurador
Lógica compleja Limitada: scripts sueltos Toda la del lenguaje
Revisión en pull request Mala: JSON gigante ilegible en el diff Buena: es código
Reutilización de utilidades Escasa Total

La recomendación práctica para Tienda Aroma: Newman para los smoke tests posteriores al despliegue —rápidos, amplios, fáciles de ampliar por cualquiera— y código para el recorrido de compra, que tiene lógica, limpieza y aserciones finas.

  1. Carga y seguridad: dónde encajan

Dos familias más, que se mencionan para situarlas en el mapa y no se desarrollan aquí.

Pruebas de carga. Ya las conoces de 04-06 con autocannon. Se ejecutan contra preproducción, nunca en cada pull request —tardan y necesitan un entorno estable— sino de forma programada, semanal o antes de un lanzamiento. Lo importante es comparar contra una referencia y vigilar el p99, no el promedio:

autocannon -c 50 -d 30 -H "Authorization: Bearer $TOKEN" \
  "$URL_PREPRODUCCION/cafes?tueste=claro&limite=20"

Pruebas de seguridad. Escáneres tipo OWASP ZAP en modo baseline recorren la API buscando cabeceras ausentes, configuraciones inseguras y vulnerabilidades conocidas; complementan pero no sustituyen los controles de 04-02 ni una revisión manual de la lógica de autorización, que es donde están los fallos graves de una API (el BOLA del OWASP API Top 10 no lo detecta ningún escáner). Junto a ellas, npm audit cubre las dependencias, y se integra en CI en 05-05.

  1. Qué se ejecuta en cada momento

La tabla que ordena todo lo anterior en el tiempo, y que es la entrada directa a 05-05:

Momento Qué se ejecuta Duración objetivo Si falla
Al guardar (local) Lint, formato, pruebas unitarias del fichero tocado < 5 s Lo arreglas al instante
Antes del commit (hook) Lint, formato, pruebas unitarias completas < 30 s El commit no se crea
En el pull request Todo lo anterior + integración + contrato + spectral lint + swagger-cli validate + oasdiff breaking + npm audit + cobertura < 5 min No se puede fusionar
Al fusionar en main Todo lo del PR + construir imagen + publicar en el registro < 10 min No se genera artefacto desplegable
Al desplegar a preproducción Migraciones + e2e + Newman + comprobar /salud/preparado < 10 min No se promociona a producción
Al desplegar a producción Migraciones + smoke tests (subconjunto mínimo) < 2 min Vuelta atrás automática
En producción, continuamente Monitorización sintética: un recorrido cada 5 minutos desde varias regiones Alerta al equipo de guardia
Semanalmente Carga con autocannon, escaneo ZAP, auditoría de dependencias Tarea, no bloqueo

Dos principios que la sostienen:

  • Cuanto más tarde se detecta un fallo, más caro sale. Un 400 mal formado detectado al guardar cuesta un minuto; en producción cuesta una incidencia, una vuelta atrás y una llamada de RápidoEnvíos.
  • Cuanto más tarde en la tubería, menos pruebas y más lentas. Miles de unitarias al guardar; cinco smoke tests en producción. Invertir esa proporción produce tuberías de cuarenta minutos que la gente aprende a saltarse.

Errores Comunes y Consejos

  • Confiar en el mock como si fuera la API. Un front con todo verde contra Prism puede fallar entero contra el servidor real. Conecta contra la API de verdad lo antes posible.
  • Ejemplos irreales en el contrato. Alimentan el mock, la documentación y las pruebas de entrada. Un ejemplo con "nombre": "string" produce una interfaz diseñada para basura.
  • Cerrar los esquemas de salida con additionalProperties: false. Cada campo nuevo rompe las pruebas de contrato y el equipo acaba desactivándolas. Ciérralos solo en las entradas.
  • Escribir las pruebas de contrato aparte. Duplican trabajo y se abandonan. Móntalas como aserciones extra dentro de las pruebas de integración que ya tienes.
  • Muchas pruebas de extremo a extremo. Media hora de tubería, fallos intermitentes y, a las tres semanas, alguien las marca como opcionales. Pocas y críticas.
  • No limpiar tras la prueba de extremo a extremo. El entorno se degrada hasta que las pruebas fallan por datos basura y nadie sabe por qué.
  • Adoptar Pact porque suena bien. Sin consumidores controlables y sin disciplina en los stateHandlers, es un mantenimiento constante sin retorno. Empieza por OpenAPI y oasdiff.
  • oasdiff sin referencia estable. Comparar contra una rama que se mueve produce ruido. Compara contra main o, mejor, contra la especificación publicada en producción.
  • Ignorar la dirección del enum. Añadir un valor a lo que aceptas es seguro; añadirlo a lo que devuelves puede romper clientes generados. Avísalo en la descripción del campo.
  • Consejo: valida el contrato en la respuesta de error, no solo en la de éxito. Los errores son la parte del contrato que más se rompe, porque casi nadie los prueba.
  • Consejo: nock.disableNetConnect() en todas las suites. Una prueba que llama a internet es una prueba que fallará algún viernes por motivos ajenos.
  • Consejo: si un cambio rompedor es inevitable, que sea consciente. Etiqueta el pull request, escribe el ADR, avisa a los consumidores y aplica el ciclo de deprecación de 02-07. La herramienta detecta; el proceso decide.

Ejercicios

Ejercicio 1: clasificar cambios del contrato

Para cada cambio propuesto sobre openapi.yaml, indica si oasdiff breaking lo marcaría como rompedor, justifícalo desde el punto de vista de un consumidor concreto de Tienda Aroma, y propón una alternativa compatible cuando lo sea:

  1. Añadir el campo opcional paisTostado a la respuesta de GET /cafes.
  2. Cambiar limite de máximo 100 a máximo 50.
  3. Añadir el valor descafeinado al enum de tueste en la respuesta.
  4. Renombrar notasCata a notas.
  5. Hacer obligatorio el campo metodo en el cuerpo de POST /pedidos/{id}/pago.
  6. Añadir la respuesta 429 a una operación que no la documentaba.
  7. Cambiar totalEuros de number a string para evitar problemas de coma flotante.

Ejercicio 2: prueba de contrato de un endpoint con error

Escribe la prueba de integración que verifica que POST /v1/pedidos cumple el contrato en su camino de error: cuando se pide más stock del disponible debe responder 409 con codigo: stock_insuficiente, un cuerpo que cumpla el esquema Error y detalles con el campo afectado. Usa las ayudas cumpleEsquema y cumpleContrato del apartado 7, y añade una segunda aserción que compruebe que el stock no se ha modificado.

Ejercicio 3: diseñar la estrategia de pruebas de un endpoint nuevo

Tienda Aroma añade POST /v1/pedidos/{id}/devolucion: un cliente solicita la devolución de un pedido enviado en los 14 días siguientes; la API valida el plazo, crea la solicitud, notifica a RápidoEnvíos para la recogida y envía un correo al cliente.

Diseña la estrategia completa: qué pruebas escribirías de cada tipo (unitaria, integración, contrato, e2e, mock del consumidor), qué comprueba cada una, qué se simula en cada nivel, y en qué momento de la tabla del apartado 18 se ejecuta cada una. Indica también qué no probarías y por qué.

Soluciones

Solución 1

# Cambio ¿Rompedor? Análisis y alternativa
1 Añadir paisTostado a la respuesta No Los consumidores deben ignorar campos desconocidos (02-07), y por eso los esquemas de salida no están cerrados. Un cliente TypeScript generado simplemente no lo conoce. Se puede desplegar sin ceremonia.
2 limite de 100 a 50 Aroma Móvil pide limite=100 en la pantalla de catálogo; tras el cambio recibe 400 parametro_invalido y la pantalla queda vacía para todos los usuarios con esa versión instalada. Alternativa: aceptar hasta 100 pero devolver como máximo 50 elementos, documentándolo; o iniciar la deprecación avisando y cambiar el máximo en /v2.
3 descafeinado en el enum de respuesta (rompedor "suave") El cliente TypeScript de la SPA tiene TuesteEnum con tres valores; al recibir un cuarto puede fallar la deserialización o caer en un default inesperado. Aroma Móvil, con validación estricta, podría descartar el elemento. Alternativa: anunciarlo con antelación, publicar primero el enum ampliado en el contrato para que los clientes se regeneren, y solo después empezar a devolver el valor. Es un buen ejemplo de que el contrato debe cambiar antes que el comportamiento.
4 Renombrar notasCata a notas Es la rotura más clásica: eliminar un campo de la respuesta. Toda la SPA que pinta las notas de cata deja de mostrarlas. Alternativa: añadir notas manteniendo notasCata con el mismo valor, marcar notasCata como deprecated: true con Sunset, esperar seis meses y eliminarlo en /v2. Coste: duplicar un campo durante medio año. Beneficio: nadie se rompe.
5 metodo obligatorio en el pago Una versión antigua de Aroma Móvil que enviaba {} confiando en el método por defecto empieza a recibir 400, y los usuarios no pueden pagar. Alternativa: mantenerlo opcional con un valor por defecto documentado (tarjeta), y hacerlo obligatorio en /v2. Si el defecto es peligroso, la vía correcta es rechazar explícitamente los casos ambiguos con un código de error específico y un ciclo de deprecación.
6 Documentar el 429 que ya existía No No cambia el comportamiento: la API ya podía devolver 429. Es una mejora del contrato, y de las más útiles: oasdiff no lo marca, pero sí evita que un consumidor se lleve una sorpresa. Es la prueba de que el contrato mentía por omisión.
7 totalEuros de number a string , con matiz Un cambio de tipo rompe cualquier cliente tipado. La intención es buena —evitar la coma flotante—, pero la ejecución es rompedora. Alternativa: añadir totalCentimos (entero, exacto) junto al totalEuros existente, documentar que es el campo preferido para aritmética, deprecar totalEuros y eliminarlo en /v2. El principio general: añade el campo correcto, no transformes el existente.

Solución 2

// pruebas/integracion/pedidos-contrato.prueba.js
import './../ayudas/entorno-prueba.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { migrar, sembrar } from '../ayudas/base-datos-prueba.js';
import { tokenDe } from '../ayudas/token.js';
import { cumpleEsquema, cumpleContrato } from '../ayudas/contrato.js';

test('POST /v1/pedidos con stock insuficiente cumple el contrato del 409', async () => {
  const { app } = await import('../../src/app.js');
  migrar();
  sembrar();   // caf_001 queda con stock 120

  const token = `Bearer ${tokenDe('cli_842', 'cliente')}`;

  // 1. Estado inicial: anotamos el stock antes de intentar nada
  const antes = await request(app).get('/v1/cafes/caf_001').set('Authorization', token).expect(200);
  const stockInicial = antes.body.stock;
  assert.equal(stockInicial, 120, 'la siembra no dejó el stock esperado');

  // 2. Pedimos más de lo que hay
  const respuesta = await request(app)
    .post('/v1/pedidos')
    .set('Authorization', token)
    .set('Idempotency-Key', randomUUID())
    .send({ clienteId: 'cli_842', lineas: [{ cafeId: 'caf_001', cantidad: 99 }, { cafeId: 'caf_001', cantidad: 99 }] })
    .expect(409);

  // 3. Aserciones de CONTRATO
  cumpleEsquema('Error', respuesta.body);
  cumpleContrato('/pedidos', 'post', 409, respuesta.body);

  // 4. Aserciones de COMPORTAMIENTO
  assert.equal(respuesta.body.error.codigo, 'stock_insuficiente');
  assert.ok(Array.isArray(respuesta.body.error.detalles));
  assert.ok(respuesta.body.error.detalles.length > 0,
    'un 409 de stock debe decir QUÉ línea falla: sin detalles no es accionable');
  assert.match(respuesta.body.error.detalles[0].campo, /^lineas\[\d+\]/);

  // 5. Un 4xx NO lleva trazaId: solo los 5xx (03-07)
  assert.equal(respuesta.body.error.trazaId, undefined);

  // 6. No se creó ningún recurso
  assert.equal(respuesta.headers.location, undefined);

  // 7. LA ASERCIÓN CLAVE: la transacción se revirtió por completo.
  // Sin esto, un fallo parcial podría haber reservado stock de la primera línea
  // antes de detectar que la segunda no cabía. Es la garantía de 03-05.
  const despues = await request(app).get('/v1/cafes/caf_001').set('Authorization', token).expect(200);
  assert.equal(despues.body.stock, stockInicial,
    'el stock cambió pese a que el pedido falló: la transacción no es atómica');
  assert.equal(despues.body.version, antes.body.version,
    'la versión del recurso cambió: hubo una escritura que no debió ocurrir');
});

Notas sobre el diseño de esta prueba:

  • Dos líneas del mismo café con 99 unidades cada una es un caso mejor que una sola línea de 200: prueba además que el servicio suma las cantidades por café en lugar de validarlas por separado, un fallo real y frecuente.
  • La comprobación de la versión además del stock detecta escrituras que compensan (bajar y volver a subir), que dejarían el stock igual pero la versión distinta.
  • La prueba usa cumpleEsquema('Error', ...) y cumpleContrato('/pedidos', 'post', 409, ...): la primera valida contra el esquema genérico, la segunda comprueba además que openapi.yaml documenta ese 409. Si alguien implementa el error pero olvida documentarlo, la segunda falla. Esa es justamente la deriva que queremos cazar.

Solución 3

Estrategia de pruebas para POST /v1/pedidos/{id}/devolucion

Nivel 1 — Unitarias (servicio, con repositorio en memoria; se ejecutan al guardar):

Prueba Qué comprueba Qué se simula
Plazo válido Un pedido enviado hace 3 días admite devolución Reloj fijado a una fecha conocida
Plazo agotado Hace 15 días → plazo_devolucion_expirado Reloj fijado
Frontera exacta Día 14 a las 23:59 sí, día 15 a las 00:01 no Reloj fijado
Estado incorrecto Un pedido pendiente_pago o pagado sin enviar → 409 Repositorio en memoria
Devolución duplicada Segunda solicitud sobre el mismo pedido → 409 Repositorio en memoria
Cálculo del importe Con devolución parcial de líneas, el importe cuadra al céntimo Ninguno

El reloj es la decisión de diseño clave: la lógica de plazo debe recibir la fecha actual como dependencia inyectada, no llamar a Date.now() por dentro. Sin eso, las pruebas de frontera son imposibles o frágiles.

Nivel 2 — Integración (Supertest sobre app + SQLite temporal + nock; al guardar y en el PR):

Prueba Qué comprueba Qué se simula
Camino feliz 201 con Location, el pedido pasa a devolucion_solicitada RápidoEnvíos con nock; correo con un doble
Autorización Un cliente no puede devolver el pedido de otro → 403
Idempotencia Dos peticiones con la misma Idempotency-Key → una sola devolución
RápidoEnvíos caído 503 del transportista: la devolución se crea igual y queda recogida_pendiente nock con 503
Correo caído El fallo del correo no revierte la devolución; se reencola Doble del servicio de correo
Petición saliente correcta El cuerpo enviado a RápidoEnvíos lleva referencia, dirección y peso Aserción dentro de nock

Las dos pruebas de dependencia caída son las más valiosas y las que nadie escribe: documentan que el fallo de un tercero no debe deshacer una operación del cliente.

Nivel 3 — Contrato (dentro de las de integración; en el PR):

  • cumpleContrato('/pedidos/{id}/devolucion', 'post', 201, cuerpo) en el camino feliz.
  • cumpleEsquema('Error', cuerpo) en el 403, el 409 de plazo y el 409 de duplicado.
  • Antes de escribir el código: añadir la operación a openapi.yaml, incluidos los códigos de error nuevos (plazo_devolucion_expirado) en el enum del esquema Error. Si no está en el contrato, cumpleContrato falla, y eso es lo que se busca: el contrato primero.
  • oasdiff breaking: añadir un endpoint y un valor al enum de error no es rompedor, así que la puerta pasará en verde. Conviene verificarlo igualmente.

Nivel 4 — Mock del consumidor (msw en la SPA; en el PR del front):

  • La pantalla "solicitar devolución" con 201, con 409 de plazo expirado (mensaje específico, no genérico) y con 403.
  • Un manejador con Prefer: code=409 en Prism para maquetar antes de que exista el endpoint.

Nivel 5 — Extremo a extremo (tras desplegar a preproducción):

Ninguna prueba nueva. Y esto es una decisión, no un olvido: el recorrido de compra ya cubre login, catálogo, pedido y pago, que son las piezas que rompen el negocio si fallan. Una devolución es un flujo secundario, requiere un pedido en estado enviado —que exige simular el paso del transportista— y su prueba sería lenta y frágil. Se cubre con integración, que da el 95 % de la confianza por el 5 % del coste.

Excepción: si la devolución mueve dinero de verdad hacia la pasarela, sí merece un smoke test contra el sandbox de la pasarela, ejecutado semanalmente y no en cada despliegue, porque las integraciones de pago son donde más caro sale un fallo.

Qué NO probaría, y por qué:

  • El contenido del correo. Es responsabilidad del servicio de correo. Basta comprobar que se le pide enviarlo con los datos correctos; verificar la plantilla es probar una biblioteca ajena.
  • Que RápidoEnvíos recoja el paquete. Está fuera de nuestro sistema. Probamos que se lo pedimos bien y que sabemos manejar su fallo; lo demás es su contrato con nosotros.
  • Cada combinación de líneas devueltas. El cálculo del importe se prueba unitariamente con tres o cuatro casos representativos y las fronteras. Probar todas las combinaciones en integración es lento y no aporta nada nuevo.
  • La interfaz de la SPA en e2e. Esta es una API; las pruebas de la interfaz son del repositorio del front y se hacen con msw o con un navegador automatizado allí.

Momento de ejecución (según la tabla del apartado 18): unitarias al guardar; integración y contrato al guardar y en el pull request; msw en el PR del front; sin e2e nuevas; el smoke test de la pasarela, semanal.

Conclusión

El círculo está cerrado. openapi.yaml ha dejado de ser un documento que describe intenciones para convertirse en un artefacto del que se derivan siete cosas distintas, y —lo más importante— contra el que se verifica la realidad. Has levantado un mock con Prism que permite a la SPA maquetar el catálogo, la pantalla de stock insuficiente y cada estado de error con Prefer: code=409 semanas antes de que el endpoint exista, sabiendo exactamente dónde están sus límites: sin lógica, sin estado y sin autenticación, un mock desbloquea el desarrollo en paralelo pero no demuestra nada sobre la API real. Has visto los dobles en ambos lados de la conversación: msw interceptando en las pruebas de la SPA, con onUnhandledRequest: 'error' para que ninguna llamada se escape, y nock con disableNetConnect() para probar lo que de otro modo sería imposible —que un 503 de RápidoEnvíos no revierta un cobro ya realizado—.

El núcleo de la lección son las pruebas de contrato del proveedor: la nueva ayuda pruebas/ayudas/contrato.js compila con AJV los components.schemas de OpenAPI 3.1 y valida los cuerpos reales dentro de las pruebas Supertest de 03-08, sin escribir una suite aparte. Eso detecta lo que ninguna aserción manual detectaba: un campo required que desaparece, una fecha sin Z, un código de error que se inventa fuera del catálogo. Y en la otra dirección, oasdiff convierte las reglas de compatibilidad de 02-07 en una puerta automática con herramientas/comprobar-contrato.sh, que distingue lo que rompe de lo que no y —detalle que cuesta caro aprender— sabe que ampliar un enum de entrada es seguro y ampliar uno de salida no lo es. Sobre Pact te llevas un criterio, no una implementación: responde "¿qué necesitan mis consumidores?", una pregunta magnífica cuando los consumidores son servicios internos conocidos e imposible de responder cuando son Aroma Móvil con versiones de hace ocho meses y CataBox. Para Tienda Aroma hoy, el contrato del proveedor manda.

Los artefactos nuevos del proyecto: pruebas/ayudas/contrato.js, pruebas/e2e/recorrido-compra.prueba.js con el recorrido completo login → catálogo → pedido con idempotencia comprobada de verdad → pago → consulta, herramientas/comprobar-contrato.sh, los scripts mock y mock:dinamico, y @stoplight/prism-cli, ajv, ajv-formats, yaml y nock en devDependencies. Y una tabla que ordena todo el trabajo del curso en el tiempo: qué corre al guardar, qué en el pull request, qué al desplegar y qué en producción.

Esa última tabla es literalmente el guion de la lección siguiente. En 05-05, Integración continua y despliegue, la convertimos en una tubería que se ejecuta sola: empaquetaremos la API en un Dockerfile multietapa con usuario no root, HEALTHCHECK sobre /salud y el apagado ordenado ante SIGTERM que escribimos en 03-07; levantaremos el entorno completo con docker-compose.yml incluyendo Redis; escribiremos .github/workflows/ci.yml con las puertas en orden —npm ci, lint, Spectral, pruebas con cobertura, npm audit, oasdiff, construcción y publicación de la imagen, y Newman contra preproducción—; veremos por qué las migraciones deben ser retrocompatibles y qué pasa cuando un DROP COLUMN se encuentra con la versión anterior aún viva; y compararemos recreate, rolling, blue-green y canary, con el papel exacto que juegan /salud y /salud/preparado para que no entre tráfico antes de tiempo.

Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful

Módulo 1: Introducción a las APIs RESTful

Módulo 2: Diseño de APIs RESTful

Módulo 3: Desarrollo de APIs RESTful

Módulo 4: Buenas Prácticas y Seguridad

Módulo 5: Herramientas y Frameworks

Módulo 6: Casos de Estudio y Proyectos

© Copyright 2026. Todos los derechos reservados