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
- La pirámide de pruebas adaptada a microservicios
- Herramientas y organización de la suite
- Pruebas unitarias del dominio
- Pruebas de componente con Supertest y dobles
- Pruebas de integración con Testcontainers: outbox y consumidor de la saga
- Pruebas de contrato dirigidas por el consumidor con Pact
- Pruebas de eventos: el contrato del sobre y del payload
- Pruebas de punta a punta: pocas y con propósito
- Dobles de prueba, datos deterministas y la estrategia de TechCorp
- 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.
- 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 ajvOrganizació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).
- 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.
- 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í.
- 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.
- 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.
- 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.
- 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.
- 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, leenprocess.env, se pisan en paralelo. SiemprecrearAppcon 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.90sin matcher). Se rompe con cada cambio de precio en los datos del proveedor. Tipos y forma conMatchersV3. - Pactos que piden más de lo que se usa. Si el pacto exige
moneday Pedidos no lo usa, se ata a Catálogo sin motivo. Solo lo que eltraductorProductolee. - Datos aleatorios en las pruebas (
fakerpara 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
TRUNCATEenbeforeEach. - Ignorar la prueba del duplicado. Publicar el mismo
eventoIddos 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
- Conceptos Básicos de Microservicios
- Ventajas y Desventajas de los Microservicios
- Comparación con la Arquitectura Monolítica
- Cuándo Adoptar Microservicios: Criterios de Decisión
- El Caso Práctico del Curso: la Tienda Online de TechCorp
Módulo 2: Diseño de Microservicios
- Principios de Diseño de Microservicios
- Descomposición de Aplicaciones Monolíticas
- Definición de Bounded Contexts
- Gestión de Datos: una Base de Datos por Servicio
- Consistencia Distribuida: Sagas, CQRS y Event Sourcing
Módulo 3: Comunicación entre Microservicios
- APIs RESTful
- Mensajería Asíncrona
- Protocolos de Comunicación: gRPC, GraphQL
- API Gateway y Backend for Frontend
- Descubrimiento de Servicios y Balanceo de Carga
- Contratos y Versionado de APIs
Módulo 4: Implementación de Microservicios
- Elección de Tecnologías y Herramientas
- Desarrollo de un Microservicio Simple
- Gestión de Configuración
- Integración Práctica: Consumir APIs y Publicar Eventos
- Pruebas en Microservicios: Unitarias, de Integración y de Contrato
Módulo 5: Despliegue y Orquestación
- Contenedores y Docker
- Orquestación con Kubernetes
- CI/CD para Microservicios
- Estrategias de Despliegue: Rolling, Blue-Green y Canary
- Service Mesh: Istio y Linkerd
Módulo 6: Monitoreo y Mantenimiento
- Monitoreo y Logging
- Trazabilidad Distribuida con OpenTelemetry
- Gestión de Errores y Recuperación
- Escalabilidad y Rendimiento
- SLOs, Alertas y Gestión de Incidentes
Módulo 7: Seguridad en Microservicios
- Autenticación y Autorización
- Seguridad en la Comunicación
- Prácticas de Seguridad
- Seguridad en Contenedores y Kubernetes
