servicio-pedidos funciona, pero solo lo sabemos porque hemos seguido seis pasos con curl. En un monolito, una suite de pruebas que arranca la aplicación entera contra una base de datos cubre casi todo; en microservicios eso deja de ser posible: el comportamiento de "crear un pedido" depende de tres servicios, un broker y dos bases de datos, y no podemos levantar todo eso en cada ejecución de cada repositorio. La respuesta es repartir la confianza en niveles: pruebas unitarias del dominio, pruebas de componente del servicio aislado con dobles, pruebas de integración con las dependencias reales de ese servicio (PostgreSQL, RabbitMQ) en contenedores efímeros, pruebas de contrato que garantizan que Pedidos y Catálogo siguen entendiéndose sin desplegar juntos, y muy pocas pruebas de punta a punta. En esta lección montamos cada nivel con código real sobre lo construido en 04-02 y 04-04, y fijamos la estrategia de TechCorp.

Contenido

  1. La pirámide de pruebas adaptada a microservicios
  2. Herramientas y organización de la suite
  3. Pruebas unitarias del dominio
  4. Pruebas de componente con Supertest y dobles
  5. Pruebas de integración con Testcontainers: outbox y consumidor de la saga
  6. Pruebas de contrato dirigidas por el consumidor con Pact
  7. Pruebas de eventos: el contrato del sobre y del payload
  8. Pruebas de punta a punta: pocas y con propósito
  9. Dobles de prueba, datos deterministas y la estrategia de TechCorp

  1. La pirámide de pruebas adaptada a microservicios

Nivel Qué prueba Qué NO necesita Velocidad Coste de mantener Dónde corre
Unitaria Una función o módulo del dominio (Pedido, transicionar, traductorProducto) Ni BD, ni red, ni Express ms Bajo Portátil y CI, en cada commit
De componente (o de servicio) El servicio entero en proceso (crearApp) con sus dependencias externas sustituidas por dobles Ni BD real, ni otros servicios, ni puerto decenas de ms Bajo-medio Portátil y CI, en cada commit
De integración El servicio contra sus dependencias reales de infraestructura (PostgreSQL, RabbitMQ) en contenedores efímeros Otros servicios segundos Medio CI en cada PR; portátil bajo demanda
De contrato Que las expectativas del consumidor sobre un proveedor (Pedidos → Catálogo) se cumplen, sin desplegar los dos El otro servicio en ejecución segundos Medio CI de ambos repositorios
De punta a punta (E2E) Un flujo de negocio completo con todos los servicios desplegados minutos Alto: frágiles, lentas, difíciles de diagnosticar Un entorno de pruebas, antes de promocionar a producción

La forma sigue siendo una pirámide: muchas unitarias y de componente, algunas de integración y de contrato, muy pocas E2E. Las E2E son las únicas que prueban el sistema real, pero cada una depende de todo (siete servicios, dos BD, el broker, datos coherentes) y falla por motivos ajenos a lo que quiere probar; con veinte servicios, una suite E2E amplia se convierte en el cuello de botella de todos los despliegues. Las pruebas de contrato son la pieza que hace posible reducirlas: dan la garantía de integración entre pares de servicios a coste de prueba unitaria.

  1. Herramientas y organización de la suite

Herramienta Uso Nivel
Jest (o Vitest, equivalente) Runner, aserciones, mocks Todos
Supertest Peticiones HTTP a una app Express sin abrir puerto Componente
Testcontainers (@testcontainers/postgresql, @testcontainers/rabbitmq) Arranca contenedores reales desde la prueba y los destruye al acabar Integración
Pact (@pact-foundation/pact) Contratos consumidor-proveedor Contrato
Ajv Validar payloads de eventos contra JSON Schema Eventos
Docker Compose Levantar el sistema completo (05-01) E2E
npm install -D jest supertest @testcontainers/postgresql @testcontainers/rabbitmq @pact-foundation/pact ajv

Organización en servicio-pedidos y scripts que sustituyen al "test": "jest" de 04-02:

pruebas/
├── unitarias/        dominio.pedido.test.js, traductorProducto.test.js
├── componente/       pedidos.api.test.js
├── integracion/      outbox.test.js, consumidorSaga.test.js
├── contrato/         catalogo.consumidor.pact.test.js
├── eventos/          pedidoCreado.esquema.test.js
├── dobles/           pedidoRepositorioEnMemoria.js, catalogoClienteDoble.js, clientesClienteDoble.js
└── fixtures/         productos.js (p-501, p-777), clientes.js (c-1024), peticionPedido.js
"scripts": {
  "test": "jest pruebas/unitarias pruebas/componente pruebas/eventos",
  "test:integracion": "jest pruebas/integracion --runInBand",
  "test:contrato": "jest pruebas/contrato --runInBand",
  "test:todo": "npm test && npm run test:integracion && npm run test:contrato"
}

npm test es lo que se ejecuta a cada guardado y en cada commit: sin Docker, en pocos segundos. Las de integración y contrato necesitan Docker y corren en cada PR (05-03).

  1. Pruebas unitarias del dominio

El dominio de 04-04 (dominio/pedido.js, dominio/maquinaEstadosPedido.js) no importa nada de infraestructura, así que se prueba con funciones puras. Los fixtures deterministas se reutilizan en todos los niveles:

// pruebas/fixtures/productos.js — el Map que devuelve catalogoCliente.obtenerProductos (04-04)
const productos = new Map([
  ['p-501', { productoId: 'p-501', nombre: 'Auriculares BT X200', precioUnitario: 59.90, disponible: true }],
  ['p-777', { productoId: 'p-777', nombre: 'Cable USB-C 2 m', precioUnitario: 9.90, disponible: true }]
]);
const cliente = { clienteId: 'c-1024', nombre: 'Ana Ruiz', email: '[email protected]' };
const peticionPedido = {
  clienteId: 'c-1024',
  lineas: [{ productoId: 'p-501', cantidad: 1 }, { productoId: 'p-777', cantidad: 2 }],
  direccionEnvio: { calle: 'Gran Vía 12', codigoPostal: '28013', ciudad: 'Madrid', pais: 'ES' }
};
module.exports = { productos, cliente, peticionPedido };
// pruebas/unitarias/dominio.pedido.test.js
const Pedido = require('../../src/dominio/pedido');
const { transicionar } = require('../../src/dominio/maquinaEstadosPedido');
const { productos, cliente, peticionPedido } = require('../fixtures/productos');

describe('Pedido.crearPedido', () => {
  test('congela nombre y precio y calcula el total sin errores de coma flotante', () => {
    const pedido = Pedido.crearPedido(peticionPedido, { cliente, productos });
    expect(pedido.estado).toBe('PENDIENTE');
    expect(pedido.pedidoId).toMatch(/^ped-[0-9a-f]{8}$/);
    expect(pedido.lineas[0]).toMatchObject({ linea: 1, productoId: 'p-501', nombreProducto: 'Auriculares BT X200', precioUnitario: 59.90, cantidad: 1 });
    expect(pedido.total).toBe(79.70);              // 59.90 + 2 × 9.90; con flotantes saldría 79.69999999999999
  });
});

describe('máquina de estados', () => {
  test.each([
    ['PENDIENTE', 'stock.reservado', 'STOCK_RESERVADO'],
    ['STOCK_RESERVADO', 'pago.confirmado', 'PAGADO'],
    ['PAGADO', 'confirmar', 'CONFIRMADO'],
    ['STOCK_RESERVADO', 'pago.rechazado', 'CANCELADO'],
    ['CONFIRMADO', 'stock.reservado', null],        // evento tardío: ignorado
    ['CANCELADO', 'pago.confirmado', null]          // pago tras cancelación: ignorado (compensación en Pagos)
  ])('%s + %s → %s', (estado, evento, esperado) => {
    expect(transicionar(estado, evento)).toBe(esperado);
  });

  test('aplicarEventoSaga fija el motivo al cancelar', () => {
    const pedido = Pedido.crearPedido(peticionPedido, { cliente, productos });
    Pedido.aplicarEventoSaga(pedido, 'stock.rechazado');
    expect(pedido).toMatchObject({ estado: 'CANCELADO', motivoCancelacion: 'SIN_STOCK' });
  });
});

test.each convierte la tabla TRANSICIONES de 02-05 en una tabla de casos: cada fila del diseño tiene su prueba, incluidas las transiciones prohibidas, que son las que protegen la saga de eventos tardíos.

  1. Pruebas de componente con Supertest y dobles

Aquí es donde se cobra la separación crearApp/servidor.js y la inyección de dependencias de 04-02: la app de Pedidos se construye con un repositorio en memoria y clientes HTTP dobles, y Supertest le envía peticiones sin abrir ningún puerto.

// pruebas/dobles/catalogoClienteDoble.js — mismo contrato que crearCatalogoCliente (04-04)
const { ErrorNegocio } = require('@techcorp/comun-http');
function crearCatalogoClienteDoble(productosConocidos) {
  return {
    llamadas: [],
    async obtenerProductos(ids) {
      this.llamadas.push(ids);
      const faltan = ids.filter((id) => !productosConocidos.has(id));
      if (faltan.length) throw new ErrorNegocio('PRODUCTO_NO_DISPONIBLE', `Productos no disponibles: ${faltan.join(', ')}`, 422);
      return new Map(ids.map((id) => [id, productosConocidos.get(id)]));
    }
  };
}
module.exports = { crearCatalogoClienteDoble };

pedidoRepositorioEnMemoria.js implementa la misma interfaz que crearPedidoRepositorio (guardarConEventos, obtener, buscarClave, guardarClave, procesarUnaVez, transaccion) sobre Maps, y además expone outbox (array) para que las pruebas comprueben qué eventos se habrían publicado. Es el equivalente al repositorio en memoria del ejercicio 2 de 04-02.

// pruebas/componente/pedidos.api.test.js
const request = require('supertest');
const pino = require('pino');
const { crearApp } = require('../../src/app');
const { crearPedidoRepositorioEnMemoria } = require('../dobles/pedidoRepositorioEnMemoria');
const { crearCatalogoClienteDoble } = require('../dobles/catalogoClienteDoble');
const { crearClientesClienteDoble } = require('../dobles/clientesClienteDoble');
const { productos, cliente, peticionPedido } = require('../fixtures/productos');

function montar() {
  const repositorio = crearPedidoRepositorioEnMemoria();
  const app = crearApp({
    repositorio,
    catalogoCliente: crearCatalogoClienteDoble(productos),
    clientesCliente: crearClientesClienteDoble([cliente]),
    logger: pino({ level: 'silent' })
  });
  return { app, repositorio };
}

describe('POST /v1/pedidos', () => {
  test('crea el pedido, responde 202 + Location y deja pedido.creado en el outbox', async () => {
    const { app, repositorio } = montar();
    const res = await request(app).post('/v1/pedidos').set('Idempotency-Key', 'clave-1').send(peticionPedido);

    expect(res.status).toBe(202);
    expect(res.headers.location).toMatch(/^\/v1\/pedidos\/ped-/);
    expect(res.body).toMatchObject({ estado: 'PENDIENTE', total: 79.70 });
    expect(repositorio.outbox).toHaveLength(1);
    expect(repositorio.outbox[0]).toMatchObject({ tipo: 'pedido.creado', carga: { clienteId: 'c-1024', total: 79.70 } });
  });

  test('es idempotente: misma clave y mismo cuerpo → mismo pedido, sin segundo evento', async () => {
    const { app, repositorio } = montar();
    const primera = await request(app).post('/v1/pedidos').set('Idempotency-Key', 'clave-2').send(peticionPedido);
    const segunda = await request(app).post('/v1/pedidos').set('Idempotency-Key', 'clave-2').send(peticionPedido);
    expect(segunda.status).toBe(202);
    expect(segunda.body.id).toBe(primera.body.id);
    expect(repositorio.outbox).toHaveLength(1);
  });

  test('misma clave con otro cuerpo → 422 CLAVE_IDEMPOTENCIA_REUTILIZADA', async () => {
    const { app } = montar();
    await request(app).post('/v1/pedidos').set('Idempotency-Key', 'clave-3').send(peticionPedido);
    const otra = { ...peticionPedido, lineas: [{ productoId: 'p-501', cantidad: 5 }] };
    const res = await request(app).post('/v1/pedidos').set('Idempotency-Key', 'clave-3').send(otra);
    expect(res.status).toBe(422);
    expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
    expect(res.body.codigo).toBe('CLAVE_IDEMPOTENCIA_REUTILIZADA');
  });

  test('sin Idempotency-Key → 400; producto desconocido → 422 PRODUCTO_NO_DISPONIBLE', async () => {
    const { app } = montar();
    expect((await request(app).post('/v1/pedidos').send(peticionPedido)).status).toBe(400);
    const res = await request(app).post('/v1/pedidos').set('Idempotency-Key', 'clave-4')
      .send({ ...peticionPedido, lineas: [{ productoId: 'p-999', cantidad: 1 }] });
    expect(res.status).toBe(422);
    expect(res.body.codigo).toBe('PRODUCTO_NO_DISPONIBLE');
  });
});

Estas pruebas cubren la ruta, la validación, el caso de uso, el dominio y el formato de errores juntos, en milisegundos y sin infraestructura. Lo que no cubren (y no deben intentar cubrir) es si el SQL de guardarConEventos es correcto o si el fetch a Catálogo funciona: eso es de los dos niveles siguientes. Para el cliente HTTP real (catalogoCliente.js) existe una alternativa a los dobles inyectados: interceptar la red con nock (nock('http://catalogo.test').get('/v1/productos').query({ ids: 'p-501,p-777' }).reply(200, {...})), útil para probar el mapeo de timeouts y 5xx a DEPENDENCIA_NO_DISPONIBLE. TechCorp usa dobles inyectados para las pruebas de componente y reserva nock para probar los clientes en sí.

  1. Pruebas de integración con Testcontainers: outbox y consumidor de la saga

Testcontainers arranca un PostgreSQL y un RabbitMQ reales en Docker desde la propia prueba, con puertos aleatorios, y los destruye al terminar. Así probamos el SQL de verdad, FOR UPDATE SKIP LOCKED de verdad y la topología AMQP de verdad, sin depender de nada preinstalado.

// pruebas/integracion/outbox.test.js
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { RabbitMQContainer } = require('@testcontainers/rabbitmq');
const pino = require('pino');
const { crearPoolPostgres } = require('../../src/infra/postgres');
const { crearPedidoRepositorio } = require('../../src/repositorios/pedidoRepositorio');
const { crearRelayOutbox } = require('../../src/mensajeria/relayOutbox');
const { aplicarMigraciones } = require('../../scripts/migrar');
const { conectar } = require('@techcorp/comun-http/mensajeria/topologia');
const Pedido = require('../../src/dominio/pedido');
const { productos, cliente, peticionPedido } = require('../fixtures/productos');

jest.setTimeout(120000);                    // la primera vez descarga imágenes
let pg, mq, bd, repositorio, amqp;

beforeAll(async () => {
  [pg, mq] = await Promise.all([new PostgreSqlContainer('postgres:16').start(), new RabbitMQContainer('rabbitmq:3-management').start()]);
  bd = crearPoolPostgres({ url: pg.getConnectionUri(), logger: pino({ level: 'silent' }) });
  await aplicarMigraciones(bd);                                       // las mismas migraciones que en producción
  repositorio = crearPedidoRepositorio(bd);
  amqp = await conectar(mq.getAmqpUrl());                             // declara techcorp.eventos (03-02)
});
afterAll(async () => { await amqp.conexion.close(); await bd.cerrar(); await Promise.all([pg.stop(), mq.stop()]); });

test('el relay publica pedido.creado exactamente una vez y marca publicado_en', async () => {
  // Cola de prueba enlazada a pedido.creado: hacemos de "Inventario"
  await amqp.canal.assertQueue('prueba.pedidos', { exclusive: true });
  await amqp.canal.bindQueue('prueba.pedidos', 'techcorp.eventos', 'pedido.creado');

  const pedido = Pedido.crearPedido(peticionPedido, { cliente, productos });
  await repositorio.guardarConEventos(pedido, [{ tipo: 'pedido.creado', carga: Pedido.datosParaConsumidores(pedido) }]);

  const canalConfirm = await amqp.conexion.createConfirmChannel();
  const relay = crearRelayOutbox({ bd, canalConfirm, intervaloMs: 100, logger: pino({ level: 'silent' }) });
  expect(await relay.publicarPendientes()).toBe(1);
  expect(await relay.publicarPendientes()).toBe(0);                  // segunda pasada: nada pendiente

  const msg = await amqp.canal.get('prueba.pedidos', { noAck: true });
  const sobre = JSON.parse(msg.content.toString());
  expect(sobre).toMatchObject({ tipo: 'pedido.creado', version: 1, carga: { pedidoId: pedido.pedidoId, total: 79.70 } });
  expect(sobre.eventoId).toMatch(/^evt-/);
  const { rows } = await bd.consultar('SELECT publicado_en FROM outbox WHERE agregado_id = $1', [pedido.pedidoId]);
  expect(rows[0].publicado_en).not.toBeNull();
});

Para que sea posible, relayOutbox.js expone publicarPendientes además de iniciar/parar (un cambio de una línea respecto a 04-04) y scripts/migrar.js exporta aplicarMigraciones(bd). La prueba del consumidor de la saga (consumidorSaga.test.js) sigue el mismo esquema: guarda un pedido PENDIENTE, arranca crearConsumidorSaga({ canal, repositorio }), publica un stock.reservado con publicarEvento, espera con un pequeño polling (hasta 2 s) a que repositorio.obtener() devuelva STOCK_RESERVADO, y publica el mismo sobre otra vez (mismo eventoId) para comprobar que eventos_procesados tiene una fila y el estado no cambia. Son las pruebas más caras del repositorio (10-20 s la suite), y las que más bugs de SQL y de AMQP atrapan.

  1. Pruebas de contrato dirigidas por el consumidor con Pact

El problema que resuelven: Pedidos depende de GET /v1/productos?ids= de Catálogo. Los dobles del apartado 4 prueban que Pedidos hace lo correcto si Catálogo responde como Pedidos cree; nada garantiza que Catálogo responda así, ni hoy ni después de que su equipo cambie algo. Con Pact, el consumidor escribe sus expectativas, se genera un fichero (el pacto) y el proveedor lo verifica contra su código real en su propio CI. Si Catálogo rompe el contrato, su build falla antes de desplegar; y si Pedidos empieza a depender de un campo nuevo, el pacto lo hace explícito.

Lado consumidor (repositorio de Pedidos): Pact levanta un servidor falso que responde según las interacciones declaradas, y ejecutamos nuestro catalogoCliente real contra él:

// pruebas/contrato/catalogo.consumidor.pact.test.js (servicio-pedidos)
const path = require('node:path');
const { PactV3, MatchersV3: M } = require('@pact-foundation/pact');
const { crearCatalogoCliente } = require('../../src/clientes/catalogoCliente');

const proveedor = new PactV3({ consumer: 'servicio-pedidos', provider: 'servicio-catalogo', dir: path.resolve('pactos') });

test('GET /v1/productos?ids= devuelve datos y noEncontrados', async () => {
  proveedor
    .given('existen los productos p-501 y p-777')             // estado del proveedor: Catálogo sabrá prepararlo
    .uponReceiving('un lote de ids con uno inexistente')
    .withRequest({ method: 'GET', path: '/v1/productos', query: { ids: 'p-501,p-777,p-999' }, headers: { Accept: 'application/json' } })
    .willRespondWith({
      status: 200,
      headers: { 'Content-Type': 'application/json; charset=utf-8' },
      body: {
        // Matchers: Pedidos exige TIPO y forma, no valores exactos (excepto los ids que pidió). Es lo que "tolerant reader" significa en un pacto.
        datos: M.eachLike({ id: M.string('p-501'), nombre: M.string('Auriculares BT X200'), precio: M.decimal(59.90), disponible: M.boolean(true) }),
        noEncontrados: M.eachLike('p-999')
      }
    });

  await proveedor.executeTest(async (servidorFalso) => {
    const cliente = crearCatalogoCliente({ urlBase: servidorFalso.url, timeoutMs: 1000 });
    await expect(cliente.obtenerProductos(['p-501', 'p-777', 'p-999'], { requestId: 'req-test' }))
      .rejects.toMatchObject({ codigo: 'PRODUCTO_NO_DISPONIBLE' });    // p-999 en noEncontrados → error de negocio (04-04)
  });
});

Al pasar, se escribe pactos/servicio-pedidos-servicio-catalogo.json: la lista de interacciones que Pedidos necesita. Fíjate en que solo aparecen los campos que el traductorProducto usa (id, nombre, precio, disponible); moneda no está, así que Catálogo podría cambiarlo sin romper a Pedidos.

Lado proveedor (repositorio de Catálogo): el Verifier arranca la app real de Catálogo (con el repositorio en memoria del ejercicio 2 de 04-02, sembrado según el provider state) y reproduce cada interacción del pacto:

// pruebas/contrato/catalogo.proveedor.pact.test.js (servicio-catalogo)
const { Verifier } = require('@pact-foundation/pact');
const pino = require('pino');
const { crearApp } = require('../../src/app');
const { crearProductosRepositorioEnMemoria } = require('../dobles/productosRepositorioEnMemoria');

test('servicio-catalogo cumple los pactos de sus consumidores', async () => {
  const repositorio = crearProductosRepositorioEnMemoria();
  const servidor = crearApp({ repositorio, logger: pino({ level: 'silent' }) }).listen(0);   // puerto libre
  const url = `http://localhost:${servidor.address().port}`;
  try {
    await new Verifier({
      provider: 'servicio-catalogo',
      providerBaseUrl: url,
      pactUrls: ['../servicio-pedidos/pactos/servicio-pedidos-servicio-catalogo.json'],   // en CI: desde el Pact Broker
      stateHandlers: {
        'existen los productos p-501 y p-777': async () => repositorio.reemplazar([      // reemplazar(): método añadido al fake para sembrar estados
          { _id: 'p-501', nombre: 'Auriculares BT X200', precio: 59.90, publicado: true, categoria: 'audio' },
          { _id: 'p-777', nombre: 'Cable USB-C 2 m', precio: 9.90, publicado: true, categoria: 'accesorios' }
        ])
      }
    }).verifyProvider();
  } finally { servidor.close(); }
});

Si mañana Catálogo renombra precio a importe, esta prueba falla en el CI de Catálogo con un mensaje que dice exactamente qué consumidor y qué interacción se rompen. En 03-06 vimos la teoría (precio como objeto exige /v2/); Pact es quien la hace cumplir. Cómo llegan los pactos de un repositorio a otro (el Pact Broker, y su comprobación can-i-deploy que responde "¿puedo desplegar esta versión de Catálogo sin romper a ningún consumidor?") es parte del pipeline de 05-03; aquí basta saber que existe.

  1. Pruebas de eventos: el contrato del sobre y del payload

Los eventos también son contratos (AsyncAPI de 03-06), y también se rompen en silencio. Dos pruebas baratas:

// pruebas/eventos/pedidoCreado.esquema.test.js — el productor comprueba que lo que publica cumple el esquema publicado
const Ajv = require('ajv');
const esquema = require('../../contratos/esquemas/pedido.creado.v1.json');   // extraído del asyncapi.yaml
const Pedido = require('../../src/dominio/pedido');
const { productos, cliente, peticionPedido } = require('../fixtures/productos');

test('la carga de pedido.creado cumple su JSON Schema v1', () => {
  const validar = new Ajv({ allErrors: true }).compile(esquema);
  const pedido = Pedido.crearPedido(peticionPedido, { cliente, productos });
  expect(validar(Pedido.datosParaConsumidores(pedido))).toBe(true);
  expect(validar.errors).toBeNull();
});

Y en el lado consumidor (por ejemplo, el consumidor de la saga), una prueba de tolerancia: procesar un stock.reservado cuya carga incluye campos desconocidos ({ pedidoId, reservaId, almacen: 'MAD-1', prioridad: 2 }) debe llevar el pedido a STOCK_RESERVADO igual que si no los tuviera. Es la garantía de que el consumidor cumple la regla del tolerant reader de 03-06 y que Inventario puede evolucionar su evento sin coordinar despliegues.

  1. Pruebas de punta a punta: pocas y con propósito

TechCorp tiene una prueba E2E para el flujo de pedido: contra el sistema completo levantado con Docker Compose (05-01), hace POST /api/v1/pedidos a través del gateway (8080), y espera con polling sobre GET /api/v1/pedidos/{id} hasta ver CONFIRMADO (con un límite de 15 s), comprobando de paso que servicio-notificaciones registró un envío. Nada más: no prueba validaciones, ni errores, ni idempotencia (todo eso ya está cubierto abajo en la pirámide). Su valor es detectar problemas de cableado que ningún otro nivel ve: una cola mal enlazada, una variable de entorno que falta, una versión de @techcorp/comun-http incompatible. Se ejecuta antes de promocionar a staging y a producción, no en cada commit.

  1. Dobles de prueba, datos deterministas y la estrategia de TechCorp

Doble Qué es Cuándo usarlo Ejemplo en este módulo
Stub Devuelve respuestas fijas; no comprueba nada Cuando solo necesitas que la dependencia "responda" clientesClienteDoble que siempre devuelve a Ana
Mock Registra llamadas y permite afirmar sobre ellas (toHaveBeenCalledWith) Cuando lo que pruebas es que se llamó a algo y cómo Comprobar que obtenerProductos recibió ['p-501','p-777'] sin duplicados
Fake Implementación funcional simplificada Cuando la dependencia tiene comportamiento (estado) que la prueba necesita pedidoRepositorioEnMemoria con su outbox
Contenedor real La dependencia de verdad, efímera Cuando lo que pruebas es la integración con ella PostgreSQL y RabbitMQ con Testcontainers

Preferimos fakes e inyección a mocks de librerías (jest.mock('pg')): los fakes prueban comportamiento, no llamadas, y sobreviven a refactorizaciones internas. Los datos deterministas son igual de importantes: p-501, p-777, c-1024 y la petición de Ana viven en pruebas/fixtures/ y son los mismos en unitarias, componente, integración, pactos y la semilla de 04-02; ninguna prueba genera datos aleatorios salvo los ids, y las que dependen del tiempo reciben un reloj inyectable.

Estrategia de TechCorp por servicio:

Servicio Unitarias Componente Integración (Testcontainers) Contrato Cuándo corren
Catálogo aDto, cursor 3 endpoints, errores, Cache-Control MongoDB: consultas e índices Proveedor de Pedidos y del BFF npm test en cada commit; integración y contrato en cada PR
Pedidos Pedido, transicionar, traductorProducto POST/GET /v1/pedidos, idempotencia PostgreSQL + RabbitMQ: outbox, consumidor saga, clientes_ref Consumidor de Catálogo y Clientes; productor de pedido.* (esquema) Igual
Inventario / Pagos / Notificaciones Reglas de reserva, cobro, plantillas Sus endpoints internos PostgreSQL + RabbitMQ: consumidores idempotentes Consumidores de pedido.* (tolerancia) Igual
Todos Una E2E antes de promocionar a staging/producción

Errores Comunes y Consejos

  • Probar todo con E2E "porque es lo único real". Lentas, frágiles, y cuando fallan nadie sabe por qué. Una o dos, con propósito de cableado.
  • Pruebas de componente que arrancan servidor.js. Abren puertos, leen process.env, se pisan en paralelo. Siempre crearApp con dependencias inyectadas.
  • Mocks de la librería de BD (jest.mock('pg')). Pasan aunque el SQL esté mal. Para el SQL, Testcontainers.
  • Un contrato Pact con valores exactos (precio: 59.90 sin matcher). Se rompe con cada cambio de precio en los datos del proveedor. Tipos y forma con MatchersV3.
  • Pactos que piden más de lo que se usa. Si el pacto exige moneda y Pedidos no lo usa, se ata a Catálogo sin motivo. Solo lo que el traductorProducto lee.
  • Datos aleatorios en las pruebas (faker para precios). Un fallo intermitente cuesta más que diez pruebas. Fixtures fijos.
  • Compartir un contenedor entre suites sin limpiar. Una prueba deja un pedido y otra cuenta filas. Contenedor por suite o TRUNCATE en beforeEach.
  • Ignorar la prueba del duplicado. Publicar el mismo eventoId dos veces es la prueba más barata y más valiosa de un consumidor.

Ejercicios

Ejercicio 1. Escribe la prueba de componente de GET /v1/pedidos/{id} que comprueba: 404 PEDIDO_NO_EXISTE en problem+json para un id desconocido; 200 con cabecera ETag para un pedido creado antes en la misma prueba; y 304 al repetir con If-None-Match igual al ETag recibido.

Ejercicio 2. Escribe la interacción Pact del consumidor para GET /v1/clientes/{id} de Clientes con el estado 'existe el cliente c-1024' y comprueba que clientesCliente.obtenerCliente('c-1024') devuelve { clienteId, nombre, email }. Añade una segunda interacción para el 404 con estado 'no existe el cliente c-0000' y comprueba que se lanza CLIENTE_NO_EXISTE.

Ejercicio 3. El equipo de Inventario pregunta si necesitan una prueba E2E propia para "reservar stock cuando llega pedido.creado". Clasifica lo que quieren probar en los niveles de la tabla del apartado 1 y di qué prueba de cada nivel escribirías (sin código).

Soluciones

Solución 1.

test('GET /v1/pedidos/:id — 404, 200 con ETag y 304', async () => {
  const { app } = montar();
  const noExiste = await request(app).get('/v1/pedidos/ped-00000000');
  expect(noExiste.status).toBe(404);
  expect(noExiste.body.codigo).toBe('PEDIDO_NO_EXISTE');
  expect(noExiste.headers['content-type']).toMatch(/problem\+json/);

  const creado = await request(app).post('/v1/pedidos').set('Idempotency-Key', 'clave-get').send(peticionPedido);
  const primera = await request(app).get(`/v1/pedidos/${creado.body.id}`);
  expect(primera.status).toBe(200);
  expect(primera.headers.etag).toMatch(new RegExp(`^"${creado.body.id}:\\d+"$`));
  expect(primera.body).toMatchObject({ id: creado.body.id, estado: 'PENDIENTE' });

  const repetida = await request(app).get(`/v1/pedidos/${creado.body.id}`).set('If-None-Match', primera.headers.etag);
  expect(repetida.status).toBe(304);
  expect(repetida.text).toBe('');
});

Solución 2.

const proveedor = new PactV3({ consumer: 'servicio-pedidos', provider: 'servicio-clientes', dir: path.resolve('pactos') });

test('GET /v1/clientes/{id}: existente y no existente', async () => {
  proveedor.given('existe el cliente c-1024').uponReceiving('la consulta de c-1024')
    .withRequest({ method: 'GET', path: '/v1/clientes/c-1024', headers: { Accept: 'application/json' } })
    .willRespondWith({ status: 200, headers: { 'Content-Type': 'application/json; charset=utf-8' },
      body: { id: 'c-1024', nombre: M.string('Ana Ruiz'), email: M.email('[email protected]'), direcciones: M.eachLike({ id: M.string('dir-1'), calle: M.string('Gran Vía 12') }) } });
  proveedor.given('no existe el cliente c-0000').uponReceiving('la consulta de c-0000')
    .withRequest({ method: 'GET', path: '/v1/clientes/c-0000', headers: { Accept: 'application/json' } })
    .willRespondWith({ status: 404, headers: { 'Content-Type': 'application/problem+json' }, body: { status: 404, codigo: 'CLIENTE_NO_EXISTE', detail: M.string() } });

  await proveedor.executeTest(async (servidor) => {
    const cliente = crearClientesCliente({ urlBase: servidor.url, timeoutMs: 1000 });
    await expect(cliente.obtenerCliente('c-1024', { requestId: 'req-test' })).resolves.toMatchObject({ clienteId: 'c-1024', nombre: 'Ana Ruiz', email: '[email protected]' });
    await expect(cliente.obtenerCliente('c-0000', { requestId: 'req-test' })).rejects.toMatchObject({ codigo: 'CLIENTE_NO_EXISTE' });
  });
});

El pacto documenta también el formato de error que Pedidos espera (codigo: 'CLIENTE_NO_EXISTE' en problem+json), de modo que Clientes no puede cambiar el codigo sin que su CI lo detecte.

Solución 3. No necesitan una E2E propia. Descomposición: (1) unitaria: la regla "reservar solo si cantidad - reservado >= pedida" y el cálculo de expira_en, sin BD; (2) componente: el manejador de pedido.creado con un repositorio de reservas en memoria, comprobando que produce stock.reservado (o stock.rechazado) en su outbox; (3) integración: con Testcontainers, que la restricción reservado <= cantidad de 02-04 rechaza de verdad la sobre-reserva concurrente y que el mismo eventoId dos veces no reserva dos veces; (4) contrato de eventos: que su consumidor tolera campos nuevos en pedido.creado y que su stock.reservado cumple el esquema que Pedidos consume. La única E2E existente (crear pedido → CONFIRMADO) ya pasa por Inventario y detectaría un fallo de cableado; una segunda E2E solo para reservas no añadiría cobertura, solo tiempo.

Conclusión

Hemos repartido la confianza en servicio-pedidos y servicio-catalogo en niveles, cada uno con su coste y su propósito: pruebas unitarias del dominio (Pedido, transicionar con test.each sobre la tabla de transiciones), de componente con Supertest contra crearApp y dobles inyectados (POST /v1/pedidos → 202, idempotencia, errores problem+json), de integración con PostgreSQL y RabbitMQ reales mediante Testcontainers (el relay del outbox publica una vez y marca publicado_en; el consumidor de la saga es idempotente), de contrato con Pact (Pedidos declara lo que necesita de GET /v1/productos?ids=, Catálogo lo verifica en su CI, con el Pact Broker y can-i-deploy como enganche para 05-03), de eventos (JSON Schema del payload y tolerancia a campos nuevos) y una única E2E de cableado. Y hemos fijado las reglas: fakes e inyección antes que mocks de librerías, fixtures deterministas (p-501, p-777, c-1024) compartidos por todos los niveles, y una tabla de estrategia por servicio.

Con esto termina el módulo de implementación: hemos elegido las herramientas, construido servicio-catalogo y servicio-pedidos con la misma plantilla, disciplinado la configuración, unido los servicios por HTTP y por eventos siguiendo los contratos de los módulos 2 y 3, y protegido todo con pruebas a distintos niveles. Lo que tenemos son procesos Node.js que se ejecutan con npm run dev en un portátil, con sus dependencias en contenedores sueltos. El módulo 5 los lleva a producción: empaquetar cada servicio en una imagen Docker y levantar el sistema completo con Docker Compose (05-01), desplegarlo y escalarlo en Kubernetes con los ConfigMaps y Secrets que 04-03 dejó preparados (05-02), automatizar pruebas, pactos y despliegue en un pipeline de CI/CD (05-03), desplegar sin cortes con estrategias rolling, blue-green y canary (05-04) y, por último, delegar parte de la comunicación entre servicios en un service mesh (05-05). Empieza por los contenedores.

Curso de Microservicios

Módulo 1: Introducción a los Microservicios

Módulo 2: Diseño de Microservicios

Módulo 3: Comunicación entre Microservicios

Módulo 4: Implementación de Microservicios

Módulo 5: Despliegue y Orquestación

Módulo 6: Monitoreo y Mantenimiento

Módulo 7: Seguridad en Microservicios

Módulo 8: Casos de Estudio y Ejemplos Prácticos

© Copyright 2026. Todos los derechos reservados