Las pruebas de las dos lecciones anteriores están verdes: el dominio calcula bien, la matriz de permisos es correcta celda por celda, el conversor de divisas degrada con elegancia. Y aun así, la API de Escena Viva podría estar completamente rota.

Considera este escenario, que ocurre de verdad. Alguien reordena los middlewares en crearAplicacion y coloca la ruta de pedidos antes que express.json(). Resultado: req.body llega undefined, el esquema zod rechaza todo y cada compra devuelve 400. ¿Cuántas pruebas unitarias lo detectan? Ninguna. El controlador funciona perfectamente cuando le pasas un req fabricado a mano, el esquema valida bien lo que se le da, el repositorio guarda correctamente. Todas las piezas están bien; el cableado está mal.

Eso es lo que prueban las pruebas de integración: que las piezas encajan cuando se ejecutan juntas de verdad.

Contenido

  1. Qué integra una prueba de integración
  2. supertest y el diseño del módulo 6
  3. La primera prueba de extremo a extremo
  4. La base de datos en las pruebas
  5. Aislamiento entre pruebas
  6. Autenticar en las pruebas
  7. Probar los caminos que importan
  8. Probar la concurrencia contra la sobreventa
  9. Contrato del formato de error y servicios externos

Qué integra una prueba de integración

Una prueba de integración ejecuta varias capas reales juntas. Una petición a POST /api/pedidos recorre esto:

graph LR
    P["Petición HTTP"] --> M1["helmet · cors · json<br/>id-peticion · registro"]
    M1 --> M2["limiteCompra"] --> M3["autenticar"] --> M4["validar (zod)"]
    M4 --> C["controlador de pedidos"] --> R["repositorio + transacción"]
    R --> BD[("escena_viva")]
    C --> E["manejadorDeErrores"] --> RES["Respuesta JSON"]

Cada flecha es un punto de fallo invisible para una prueba unitaria: middleware mal ordenado o no montado; una ruta con el verbo o el prefijo incorrectos; manejadorDeErrores colocado antes que las rutas y por tanto nunca alcanzado; un esquema zod que valida un campo que el controlador espera con otro nombre; el repositorio devolviendo un documento de Mongoose donde se espera un objeto del dominio; la transacción del módulo 7 que en la práctica no aísla lo que creemos; y el estado HTTP real de cada error de la jerarquía.

supertest y el diseño del módulo 6

npm install --save-dev supertest

Qué hace supertest, exactamente: toma tu aplicación de Express, la arranca en un puerto efímero que le asigna el sistema operativo, hace una petición HTTP real contra ese puerto y cierra el servidor al terminar. Es una petición de verdad, no una simulación: pasa por todos los middlewares, el enrutador, el parseo del cuerpo y el manejador de errores. Y como el puerto es efímero, nunca chocarás con un EADDRINUSE aunque tengas el servidor de desarrollo en el 3000.

Aquí se cobra una decisión del módulo 6 que entonces pudo parecer un capricho: src/app.js exporta una factoría que nunca llama a listen, y es src/servidor.js quien conecta la base de datos, escucha y gestiona el apagado ordenado. Gracias a esa separación, la prueba es una línea:

const app = crearAplicacion({ servirEstaticos: false, registrar: false, limitar: false });
await request(app).get('/api/eventos').expect(200);

Si app.js llamara a listen al cargarse —el error más común en proyectos de Express—, cada fichero de pruebas abriría un servidor en el 3000, el segundo fallaría y el proceso no terminaría nunca. Esa es la razón real de la factoría.

Opción Valor en pruebas Por qué
servirEstaticos / registrar false Los estáticos tocan el disco en cada petición y morgan ensucia la salida de Mocha
limitar false Crítico: con express-rate-limit activo, la prueba de concurrencia recibiría 429 en vez de probar el aforo

La última tiene un matiz: desactivar los límites está bien, pero entonces hay que probarlos en un fichero aparte con limitar: true, o nadie comprobará nunca que funcionan.

La primera prueba de extremo a extremo

// test/integracion/eventos.test.js
'use strict';

const request = require('supertest');
const { expect } = require('chai');
const { crearAplicacion } = require('../../src/app.js');

describe('GET /api/eventos', () => {
  const app = crearAplicacion({ servirEstaticos: false, registrar: false, limitar: false });

  it('devuelve 200 con el catalogo en JSON', async () => {
    const r = await request(app).get('/api/eventos')
      .expect(200).expect('Content-Type', /application\/json/);
    expect(r.body.datos).to.be.an('array').with.lengthOf(3);
  });
  it('cada evento incluye titulo, sala y sesiones', async () => {
    const { body } = await request(app).get('/api/eventos').expect(200);
    const concierto = body.datos.find((e) => e.id === 'evt-001');
    expect(concierto).to.deep.include({ titulo: 'Concierto de Otono', salaId: 'org-almendra' });
    expect(concierto.sesiones).to.have.lengthOf(2);
  });

  it('no expone campos internos del modelo', async () => {
    const { body } = await request(app).get('/api/eventos').expect(200);
    expect(body.datos[0]).to.not.have.any.keys('_id', '__v');
  });
});

La tercera es de las más rentables que existen: comprueba que la serialización no filtra los campos internos de Mongoose. Es un fallo que aparece en cuanto alguien devuelve el documento crudo en lugar del objeto del dominio, y que nadie mira hasta que un cliente pregunta qué es __v.

El repertorio se encadena: .post(ruta), .set(cabecera, valor), .send(cuerpoJSON), .query({ divisa: 'GBP' }), .expect(estado), .expect('Content-Type', /json/) y .expect((res) => ...) para una aserción a medida sobre la respuesta.

Consejo práctico: usa .expect() para el estado y las cabeceras, y Chai para el cuerpo, porque los mensajes de fallo de Chai sobre objetos son mucho más informativos. Y si escribes .expect(200) y el servidor devuelve 500, supertest imprime los estados pero no el cuerpo del error: mientras depuras, guarda la respuesta e imprime respuesta.body.

La base de datos en las pruebas

Esta es la decisión de diseño más importante de la lección.

Opción Ventajas Inconvenientes
Base real dedicada (escena_viva_test) Fidelidad total: índices, transacciones, tipos reales Requiere el servicio instalado; hay que limpiarla; más lenta
mongodb-memory-server Sin instalar nada; aislada; rápida Descarga binarios; puede divergir de la versión de producción
SQLite en memoria (parte Sequelize) Instantánea, cero configuración Dialecto distinto de PostgreSQL: no prueba bloqueos ni tipos reales
Contenedor efímero (Testcontainers) Máxima fidelidad y aislamiento Necesita Docker; arranque lento
Repositorio falso en memoria Rapidísimo No prueba la integración, que es el objetivo

Nuestra elección: una base real dedicada, escena_viva_test. La prueba estrella de esta lección es la de concurrencia contra la sobreventa, que depende del comportamiento transaccional real del motor: con SQLite o con un repositorio falso pasaría siempre sin demostrar nada. Además ya tenemos la semilla idempotente del módulo 7, src/config/index.js es el único punto que lee process.env —cambiar de base es cambiar una variable— y en CI se levanta un servicio con dos líneas.

Creamos .env.test con NODE_ENV=test, MONGODB_URI=mongodb://localhost:27017/escena_viva_test, DATABASE_URL=postgres://escena:escena@localhost:5432/escena_viva_test, un JWT_SECRETO de pruebas, BCRYPT_COSTE=4, NIVEL_REGISTRO=silent y TZ=UTC. Y lo cargamos antes que nada desde el fichero de arranque de Mocha:

// test/ayudas/preparacion.js
'use strict';

const path = require('node:path');
const sinon = require('sinon');

// Carga .env.test ANTES de que src/config/index.js lea process.env
require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env.test') });
const { conectar, desconectar } = require('../../src/db/conexion.js');

exports.mochaHooks = {
  async beforeAll() { await conectar(); },
  afterEach() { sinon.restore(); },
  async afterAll() { await desconectar(); },   // sin esto el proceso no termina
};

El orden importa muchísimo. src/config/index.js lee process.env al cargarse y congela el resultado; si dotenv corre después del primer require de un módulo que importe la configuración, tu prueba se conectará alegremente a la base de desarrollo y la borrará. Cargarlo desde el require de .mocharc.json, antes que cualquier otra cosa, es la garantía. Y como salvaguarda barata, una función comprobarBaseDePruebas() que lanza si configuracion.mongodbUri no incluye _test, invocada antes de cualquier borrado.

Aislamiento entre pruebas

Las pruebas no pueden depender del orden en que se ejecutan. Si la prueba B solo pasa después de la A, tienes una bomba de relojería: el día que alguien añada .only, ejecute un solo fichero o active el paralelismo, B fallará sin motivo aparente.

// test/ayudas/base-datos.js
async function limpiar() {
  comprobarBaseDePruebas();
  const colecciones = await mongoose.connection.db.collections();
  await Promise.all(colecciones.map((c) => c.deleteMany({})));  // sin destruir índices
}
async function sembrarDatosDePrueba() {
  await limpiar();
  await sembrar({ silencioso: true });  // 7 sesiones, aforo 3000, vendidas 1811
}

Su uso en cada fichero es beforeEach(sembrarDatosDePrueba) y afterEach(limpiar). Por qué beforeEach y no before: porque la primera prueba que compre entradas modificará el aforo y la segunda esperaría un número distinto. Sembrar en cada prueba cuesta unos milisegundos y compra determinismo.

Compartir estado entre pruebas es la fuente número uno de pruebas intermitentes, y aparece en tres formas: un objeto de módulo mutado por una prueba y leído por otra, datos en la base que sobreviven a la prueba que los creó, y una caché en memoria (como la de cambio-divisas.js) que no se vacía.

Autenticar en las pruebas

Aquí está la promesa del módulo 8: casi todas las rutas interesantes exigen un usuario autenticado. ¿Cómo consigue la prueba un token válido?

La mala idea es hacer login desde la prueba con credenciales escritas en el código: mete credenciales en el repositorio (un hábito que acaba con una contraseña real en un commit), es lento porque cada login ejecuta bcrypt a propósito, acopla toda la batería al endpoint de login —si se rompe fallan cincuenta pruebas en vez de una— y es frágil ante cualquier cambio de la política de contraseñas.

La buena idea es una ayuda que crea el usuario que la prueba necesita y firma un token con el mismo servicio que usa la aplicación:

// test/ayudas/autenticacion.js
'use strict';

const { Usuario } = require('../../src/modelos/usuario.js');
const { firmarAcceso } = require('../../src/servicios/tokens.js');

let contador = 0;

/** Crea un usuario con el rol pedido y devuelve un token de acceso válido. */
async function crearUsuarioAutenticado({ rol = 'asistente', salaId = null } = {}) {
  contador += 1;
  const usuario = await Usuario.create({
    nombre: `Usuario de prueba ${contador}`,
    correo: `prueba-${rol}-${contador}@escenaviva.test`,
    contrasenaHash: '$2b$12$000000000000000uGf3nFEQhZ1lPCpNvBrjLzcVX2CqOMy',
    rol, salaId, verificado: true,   // hash irrelevante: nunca haremos login con él
  });
  const token = firmarAcceso({ id: usuario.id, rol: usuario.rol, salaId: usuario.salaId });
  return { usuario, token, cabecera: ['Authorization', `Bearer ${token}`] };
}

const comoAsistente = () => crearUsuarioAutenticado({ rol: 'asistente' });
const comoOrganizador = (s = 'org-almendra') => crearUsuarioAutenticado({ rol: 'organizador', salaId: s });
const comoAdministrador = () => crearUsuarioAutenticado({ rol: 'administrador' });

module.exports = { comoAsistente, comoOrganizador, comoAdministrador };

Se usa desplegando el array en .set(...): const lucia = await comoAsistente() y después request(app).post('/api/pedidos').set(...lucia.cabecera), o comoOrganizador('org-almendra') para editar un evento del Teatro Almendra. Verás decenas de ejemplos en el resto de la lección.

Por qué esto es correcto y no una trampa: usamos el mismo firmarAcceso que usa producción, así que el token pasa exactamente por la misma verificación que cualquier token real —firma HS256 con el mismo secreto, caducidad, formato de la carga—. No nos saltamos la autenticación; nos saltamos el login, que es una operación distinta y que se prueba aparte, una sola vez, en test/integracion/autenticacion.test.js: credenciales válidas, 401 con la misma latencia si el correo no existe (el señuelo de bcrypt), 401 con contraseña incorrecta y no revelar cuál de los dos falló.

Probar los caminos que importan

El flujo completo de compra

describe('flujo de compra de entradas', () => {
  beforeEach(async () => { await sembrarDatosDePrueba(); });
  afterEach(async () => { await limpiar(); });

  it('crea el pedido, devuelve 201 con Location y permite recuperarlo', async () => {
    const lucia = await comoAsistente();
    const creacion = await request(app).post('/api/pedidos').set(...lucia.cabecera)
      .send({ sesionId: 'ses-001-1', cantidad: 2 })
      .expect(201).expect('Content-Type', /json/);

    expect(creacion.body.totalCentimos).to.equal(5000);
    expect(creacion.body.estado).to.equal('pendiente');
    expect(creacion.body.entradas).to.have.lengthOf(2);
    expect(creacion.body.entradas[0].codigo).to.match(/^EV-\d{4}-\d{6}$/);
    expect(creacion.headers.location).to.equal(`/api/pedidos/${creacion.body.id}`);

    // El recurso creado es recuperable en la ubicación anunciada
    const consulta = await request(app).get(creacion.headers.location)
      .set(...lucia.cabecera).expect(200);
    expect(consulta.body.id).to.equal(creacion.body.id);
  });

  it('descuenta el aforo de la sesion tras la compra', async () => {
    const lucia = await comoAsistente();
    const antes = await request(app).get('/api/sesiones/ses-001-1').expect(200);
    await request(app).post('/api/pedidos').set(...lucia.cabecera)
      .send({ sesionId: 'ses-001-1', cantidad: 3 }).expect(201);
    const tras = await request(app).get('/api/sesiones/ses-001-1').expect(200);
    expect(tras.body.libres).to.equal(antes.body.libres - 3);
  });
});

La segunda prueba ata el mundo HTTP con el mundo de los datos: comprueba que la compra tuvo efecto real y persistente, no solo que devolvió 201.

Los rechazos

Cada estado de error de la jerarquía del módulo 6 merece su prueba:

it('devuelve 401 si no se envia token', async () => {
  const { body } = await request(app).post('/api/pedidos')
    .send({ sesionId: 'ses-001-1', cantidad: 2 }).expect(401);
  expect(body.error.codigo).to.equal('NO_AUTENTICADO');
});
it('devuelve 403 si un organizador edita un evento de otra sala', async () => {
  const bruno = await comoOrganizador('org-boveda');
  await request(app).patch('/api/eventos/evt-001')   // pertenece a org-almendra
    .set(...bruno.cabecera).send({ descripcion: 'Sala equivocada' }).expect(403);
});
it('devuelve 404 si la sesion no existe', async () => {
  const lucia = await comoAsistente();
  const { body } = await request(app).post('/api/pedidos').set(...lucia.cabecera)
    .send({ sesionId: 'ses-999-9', cantidad: 2 }).expect(404);
  expect(body.error.codigo).to.equal('SESION_NO_ENCONTRADA');
});
it('devuelve 409 si el aforo es insuficiente', async () => {
  const lucia = await comoAsistente();
  await ajustarAforo('ses-001-1', { libres: 1 });
  const { body } = await request(app).post('/api/pedidos').set(...lucia.cabecera)
    .send({ sesionId: 'ses-001-1', cantidad: 4 }).expect(409);
  expect(body.error.detalles).to.deep.include({ libres: 1, solicitadas: 4 });   // por qué
});
it('devuelve 422 si se piden mas de 6 entradas', async () => {
  const lucia = await comoAsistente();
  await request(app).post('/api/pedidos').set(...lucia.cabecera)
    .send({ sesionId: 'ses-001-1', cantidad: 7 }).expect(422);
});

Faltan tres hermanas evidentes, del mismo molde: el 400 con un cuerpo que no cumple el esquema ({ cantidad: 'dos' }, sin sesionId, cuyo error.detalles debe ser un array con los fallos de zod), el 401 con un token manipulado (Bearer no.es.un.token) y el 403 de un asistente que intenta editar un evento.

La referencia directa insegura

De todas las pruebas del módulo, esta es la que más vale por línea escrita. Es el fallo de seguridad más común de las APIs REST: un identificador en la URL que el servidor sirve sin comprobar de quién es.

it('impide que un asistente vea el pedido de otro asistente', async () => {
  const lucia = await comoAsistente();
  const marc = await comoAsistente();
  const pedidoDeLucia = await request(app).post('/api/pedidos').set(...lucia.cabecera)
    .send({ sesionId: 'ses-001-1', cantidad: 2 }).expect(201);

  // Marc conoce el id del pedido de Lucia y lo pide directamente
  const { body } = await request(app).get(`/api/pedidos/${pedidoDeLucia.body.id}`)
    .set(...marc.cabecera)
    .expect(404);   // 404, no 403: no confirmamos que el recurso existe
  expect(body.error.codigo).to.equal('PEDIDO_NO_ENCONTRADO');
});

El 404 en vez de 403 es deliberado y lo decidimos en el módulo 8: responder 403 confirmaría a Marc que ese pedido existe, información que no le corresponde. La prueba fija esa decisión de seguridad para que nadie la deshaga creyendo que mejora los mensajes de error. Su complemento es la prueba de que un administrador sí puede ver el pedido de cualquier asistente.

Probar la concurrencia contra la sobreventa

Esta prueba valida todo el trabajo transaccional del módulo 7, y no existe forma de escribirla que no sea de integración.

it('no sobrevende cuando llegan compras simultaneas por las ultimas entradas', async () => {
  await ajustarAforo('ses-001-1', { libres: 5 });
  // Diez asistentes distintos piden 2 entradas cada uno: 20 solicitadas, 5 disponibles
  const compradores = await Promise.all(Array.from({ length: 10 }, () => comoAsistente()));

  const resultados = await Promise.all(compradores.map((c) =>
    request(app).post('/api/pedidos').set(...c.cabecera)
      .send({ sesionId: 'ses-001-1', cantidad: 2 })));
  const exitos = resultados.filter((r) => r.status === 201);
  const conflictos = resultados.filter((r) => r.status === 409);
  expect(exitos).to.have.lengthOf(2);   // solo caben 2 compras de 2 en 5 localidades
  expect(conflictos).to.have.lengthOf(8);
  conflictos.forEach((r) => expect(r.body.error.codigo).to.equal('AFORO_INSUFICIENTE'));

  const sesion = await request(app).get('/api/sesiones/ses-001-1').expect(200);
  expect(sesion.body.libres).to.equal(1);              // el estado final es coherente
  expect(sesion.body.vendidas).to.be.at.most(sesion.body.aforo);
});

La aserción decisiva es la última: vendidas nunca supera aforo. Si el módulo 7 hubiera implementado la compra con un leer → comprobar → escribir sin transacción ni actualización condicional, esta prueba pondría en evidencia la condición de carrera de inmediato: las diez compras leerían "5 libres", las diez pasarían la comprobación y venderíamos 20 entradas para 5 asientos.

Dos advertencias honestas: la prueba es no determinista en su detalle —el número exacto de éxitos puede variar si la lógica permite compras parciales, así que afirma la invariante y no un reparto concreto— y es lenta, unos cientos de milisegundos con base real. Merece la pena: es la prueba que impide vender una butaca dos veces.

Contrato del formato de error y servicios externos

El formato { error: { codigo, mensaje, estado, detalles } } es un contrato con quien consuma la API, y vale la pena fijarlo con una tabla de casos:

const CASOS = [
  ['401 sin token', () => request(app).post('/api/pedidos').send({}), 401],
  ['404 ruta inexistente', () => request(app).get('/api/no-existe'), 404],
  ['400 cuerpo invalido', () => request(app).post('/api/autenticacion/login').send({}), 400],
];
CASOS.forEach(([nombre, peticion, estado]) => {
  it(`respeta el formato de error en ${nombre}`, async () => {
    const { body } = await peticion().expect(estado);
    expect(body.error).to.have.all.keys('codigo', 'mensaje', 'estado', 'detalles');
    expect(body.error.estado).to.equal(estado);
    expect(body.error.codigo).to.be.a('string').and.match(/^[A-Z_]+$/);
  });
});

Merece añadir una prueba más, que comprueba que la respuesta de error no filtra la pila de llamadas: expect(body.error).to.not.have.property('stack') y expect(JSON.stringify(body)).to.not.include('node_modules'). Filtrar una traza al cliente revela rutas del servidor, versiones de librerías y a veces fragmentos de consultas, y es un fallo silencioso que aparece en cuanto alguien "mejora" el manejador de errores para depurar y olvida quitarlo.

En integración probamos nuestro sistema completo, pero no los servicios de terceros: llamar al proveedor real de tasas en cada ejecución produce pruebas lentas, intermitentes y dependientes de una cuota. La técnica es simular en el borde: mantener toda la integración real y sustituir únicamente la llamada saliente con Sinon.

it('sigue devolviendo el catalogo si el conversor esta caido', async () => {
  sinon.stub(global, 'fetch').rejects(new Error('ECONNREFUSED'));
  const { body } = await request(app).get('/api/eventos?divisa=GBP').expect(200);
  expect(body.datos).to.have.lengthOf(3);
});

Esta prueba tiene un valor enorme: garantiza que una caída de un proveedor externo no tumba el catálogo, un requisito de negocio real que solo una prueba de integración con el borde simulado puede demostrar.

Nota sobre extremo a extremo con navegador. Por encima está el nivel real: un navegador automatizado que abre la web, busca el Festival de Jazz, elige butacas, paga y comprueba que la entrada aparece. Playwright es hoy la referencia en Node, con Cypress como alternativa. Está fuera del alcance de este curso, que es de servidor, y hay una razón de fondo además del temario: son las pruebas más caras y frágiles de la pirámide. La recomendación profesional es tener muy pocas, solo sobre los flujos que hunden el negocio, y resolver el resto en los niveles de abajo, que es lo que hemos hecho.

Errores Comunes y Consejos

  • Apuntar a la base de desarrollo. Se descubre cuando desaparecen los datos. Carga .env.test desde el require de Mocha y comprueba _test antes de cualquier borrado.
  • Olvidar await en supertest. request(app).get('/api/eventos').expect(200) sin await devuelve una promesa: la prueba pasa siempre. Es la trampa de 09-02 disfrazada.
  • Dejar los límites de peticiones activos. La prueba de concurrencia recibe 429 en lugar de 409 y culpas a la transacción.
  • Sembrar en before en vez de beforeEach. Cada compra altera el aforo y la segunda prueba espera un número que ya no es el suyo. Y no hagas login en cada prueba: bcrypt es lento a propósito, para eso está la ayuda de autenticación.
  • No cerrar la conexión al terminar. Con "exit": false el proceso se queda colgado: ese es el afterAll con desconectar(). Y no dependas del orden de un array: Mongo no lo garantiza sin sort, así que busca por id con datos.find(...).
  • Consejo: cuando una prueba de integración falle, imprime respuesta.body antes que nada; el cuerpo del error suele decir exactamente qué pasó. Y ejecuta la batería dos veces seguidas sin limpiar a mano: si la segunda falla, tu limpieza es incompleta.

Ejercicios

Ejercicio 1: los límites de peticiones

Crea test/integracion/limites.test.js con una aplicación construida con limitar: true. Escribe una prueba que repita POST /api/autenticacion/login hasta superar limiteLogin y compruebe que la última devuelve 429 con su código y la cabecera Retry-After. Piensa cómo evitar que este fichero contamine a los demás.

Ejercicio 2: el ciclo de vida del refresco

Escribe las pruebas del refresco rotatorio del módulo 8: que POST /api/autenticacion/refrescar con una cookie válida devuelve 200 y una cookie nueva distinta; que reutilizar la cookie antigua después de rotarla devuelve 401 y revoca la familia entera; y que tras la revocación ni siquiera la cookie nueva funciona.

Ejercicio 3: el organizador y sus informes

Escribe las pruebas de GET /api/eventos/:id/informe, que solo puede consultar el organizador propietario de la sala o un administrador. Cubre 200 para el organizador propietario, 403 para el de otra sala, 403 para el asistente, 401 sin token, 200 para el administrador y 404 para un evento inexistente pedido por un administrador.

Soluciones

Ejercicio 1

// Aplicación PROPIA con límites activos: no se comparte con los demás ficheros
const appLimitada = crearAplicacion({ servirEstaticos: false, registrar: false, limitar: true });

it('devuelve 429 al superar el limite de intentos de login', async () => {
  const credenciales = { correo: '[email protected]', contrasena: 'loQueSea1!' };
  let ultima;
  for (let i = 0; i < 12; i += 1) {
    ultima = await request(appLimitada).post('/api/autenticacion/login').send(credenciales);
    if (ultima.status === 429) break;
  }
  expect(ultima.body.error.codigo).to.equal('DEMASIADAS_PETICIONES');
  expect(ultima.headers).to.have.property('retry-after');   // cuándo reintentar
});

La contaminación se evita usando una instancia propia de la aplicación (cada crearAplicacion crea sus propios contadores de express-rate-limit) y no reutilizándola en otros ficheros. Si el almacén de límites fuera externo —Redis, módulo 10—, habría que vaciarlo en afterEach.

Ejercicio 2

it('revoca la familia si se reutiliza una cookie ya rotada', async () => {
  const inicial = (await iniciarSesion()).headers['set-cookie'];
  const primera = await request(app).post('/api/autenticacion/refrescar')
    .set('Cookie', inicial).expect(200);
  const nueva = primera.headers['set-cookie'];
  expect(nueva[0]).to.not.equal(inicial[0]);

  const reuso = await request(app).post('/api/autenticacion/refrescar')
    .set('Cookie', inicial).expect(401);   // reutilizar la antigua: señal de robo
  expect(reuso.body.error.codigo).to.equal('REFRESCO_REUTILIZADO');
  // La familia entera queda revocada: ni siquiera la nueva vale
  await request(app).post('/api/autenticacion/refrescar').set('Cookie', nueva).expect(401);
});

Es una de las pruebas más valiosas del proyecto: la detección de reutilización es lógica de seguridad sutil que nadie recuerda seis meses después y que un refactor bienintencionado puede desactivar sin que se note.

Ejercicio 3

const RUTA = '/api/eventos/evt-001/informe';

it('devuelve 200 al organizador propietario de la sala', async () => {
  const marta = await comoOrganizador('org-almendra');
  const { body } = await request(app).get(RUTA).set(...marta.cabecera).expect(200);
  expect(body).to.have.property('ingresosCentimos');
});
it('devuelve 404 si el evento no existe, incluso para el administrador', async () => {
  const admin = await comoAdministrador();
  await request(app).get('/api/eventos/evt-999/informe').set(...admin.cabecera).expect(404);
});

Las otras cuatro son del mismo molde: 403 para el organizador de org-boveda, 403 para el asistente, 401 sin token y 200 para el administrador. Fíjate en el orden que revelan estas pruebas: primero autenticación (401), después autorización (403) y solo entonces existencia (404). Si el 404 se comprobara antes que el 403, un asistente podría averiguar qué identificadores de evento existen sondeando la API.

Conclusión

Hemos subido un nivel en la pirámide y hemos ganado lo que las pruebas unitarias no podían dar: la certeza de que las piezas encajan. Ahora sabemos que las rutas están montadas, que los middlewares corren en el orden correcto, que manejadorDeErrores traduce cada error de la jerarquía a su estado HTTP y a su formato de contrato, que el aforo se descuenta de verdad en la base de datos, que un asistente no puede ver el pedido de otro, y que diez compras simultáneas por cinco butacas venden cinco butacas.

Y hemos resuelto la promesa del módulo 8 con test/ayudas/autenticacion.js: una ayuda que crea el usuario con el rol necesario y firma un token válido con el mismo servicio que usa la aplicación, sin una sola contraseña escrita en el código y sin pagar el coste de bcrypt en cada prueba.

Nos queda una pregunta incómoda. Tenemos muchas pruebas, ¿pero cubren lo que importa? ¿Hay ramas del código que ningún it ha ejecutado jamás? ¿Cuánto tarda la batería y qué pasará cuando tarde el triple? ¿Cómo se asegura el equipo de que nadie sube código con las pruebas rojas?

En la lección siguiente, Cobertura y Automatización de las Pruebas, medimos qué estamos probando de verdad con c8, fijamos umbrales que solo pueden subir, organizamos los scripts de npm, aprendemos a cazar una prueba intermitente y dejamos el proyecto listo para que un servidor de integración continua ejecute todo esto sin intervención humana.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados