La API de Tienda Aroma está construida: rutas, representaciones, validación, persistencia, autenticación y errores. Y ahora mismo la única prueba de que cumple el contrato son unos cuantos curl que ejecutaste a mano hace tres lecciones y que nadie volverá a ejecutar. Eso no es garantía: es memoria. Esta lección cierra el módulo convirtiendo esas comprobaciones en pruebas automáticas que se ejecutan con npm test en dos segundos y que fallan en cuanto alguien rompe una promesa del contrato —un Location que desaparece, un total que pasa a contar solo la página, un 403 que se convierte en 401—. Usaremos el runner nativo de Node, sin instalar nada, y Supertest para lanzar peticiones contra la aplicación sin abrir un puerto. Al final tendrás además una lista de verificación del contrato: qué comprobar antes de dar por buena cualquier API REST.

Contenido

  1. Qué prueba cada tipo de prueba
  2. La pirámide aplicada a esta API
  3. El runner nativo: node:test
  4. Prueba unitaria del mapeador
  5. Dobles de prueba e inyección de dependencias
  6. Prueba unitaria del servicio de cafés
  7. Supertest: peticiones sin puerto
  8. Datos de prueba: base de datos aislada
  9. Pruebas de integración de la colección
  10. Pruebas de creación, cabeceras y validación
  11. Pruebas de autenticación y autorización
  12. Cobertura y qué mide realmente
  13. Pruebas de contrato: la idea
  14. Exploración manual y Postman
  15. Lista de verificación del contrato de Tienda Aroma
  16. Balance del módulo 3

  1. Qué prueba cada tipo de prueba

Tipo Qué ejercita Velocidad Qué detecta Qué no detecta
Unitaria Una función o servicio, aislado Milisegundos Errores de lógica y de cálculo Que las piezas encajen
Integración Varias capas juntas, con base de datos Decenas de ms Contrato HTTP, SQL, middleware Fallos de red o despliegue
Extremo a extremo Sistema completo desplegado Segundos Problemas de configuración real Casos límite (son pocas)

Las tres responden a preguntas distintas, y confundirlas produce suites lentas que no detectan nada. La regla operativa: cuanto más abajo, más pruebas y más rápidas.

En este módulo escribiremos unitarias y de integración. Las de extremo a extremo —contra un entorno desplegado de verdad, con su base de datos y su gateway— se retoman en 05-04, junto con las pruebas de contrato y los mocks.

  1. La pirámide aplicada a esta API

Capa Qué probamos en Tienda Aroma Cuántas
Unitarias centimosAEuros, cafeARepresentacion, enlacesDePedido, servicioCafes, esquemas Zod Muchas
Integración GET/POST/PATCH/DELETE /v1/cafes, POST /v1/sesiones, permisos, errores Bastantes
Extremo a extremo Comprar un café de principio a fin contra el entorno de pruebas Pocas

Y una decisión de criterio que ahorra mucho trabajo inútil: no probamos Express, ni Zod, ni SQLite. Esas librerías tienen sus propias pruebas. Probamos nuestras decisiones: que el precio se convierte bien, que el _links de un pedido pagado incluye factura y no pagar, que limite=5000 devuelve 400 y no recorta, que un cliente no ve los pedidos de otro. Todo eso está escrito en el contrato del módulo 2, y por eso las pruebas se escriben casi solas: cada decisión del contrato es una prueba.

  1. El runner nativo: node:test

Desde Node 18 no hace falta Jest, Mocha ni Vitest para lo esencial: Node trae runner y aserciones.

import { describe, it, before, beforeEach, after } from 'node:test';
import assert from 'node:assert/strict';
Pieza Para qué
describe(nombre, fn) Agrupa pruebas relacionadas
it(nombre, fn) Una prueba concreta
before / after Se ejecuta una vez antes/después de todo el grupo
beforeEach / afterEach Antes/después de cada prueba
assert Aserciones

Sobre node:assert/strict: es la variante que usa comparación estricta (===) y hay que usar siempre esa. Con la versión laxa, assert.equal('20', 20) pasa, y eso es justo el tipo de bug que buscamos —recuerda que los query params llegan como texto—.

Las aserciones que usaremos:

assert.equal(respuesta.status, 200);                     // igualdad estricta
assert.deepEqual(cuerpo, { datos: [], total: 0 });       // estructuras completas
assert.ok(cuerpo.total > 0);                             // verdadero
assert.match(cabecera, /rel="next"/);                    // expresión regular
assert.throws(() => servicio.obtener('caf_999'), /no existe/); // lanza

Ejecución:

node --test pruebas/          # todo
node --test --watch pruebas/  # relanza al guardar
node --test pruebas/unitarias/mapeadores.test.js   # un fichero

Node considera fichero de prueba todo lo que encaje con *.test.js, *-test.js o esté dentro de una carpeta test/. Nuestro convenio es *.test.js dentro de pruebas/. Ya lo teníamos en package.json desde 03-01:

"scripts": { "test": "node --test pruebas/" }

  1. Prueba unitaria del mapeador

Empezamos por lo más sencillo y a la vez más rentable: la conversión de dinero, donde un fallo silencioso cuesta dinero de verdad.

// pruebas/unitarias/mapeadores.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
  centimosAEuros,
  eurosACentimos,
  cafeARepresentacion,
  pedidoARepresentacion,
} from '../../src/servicios/mapeadores.js';

describe('conversión entre céntimos y euros', () => {
  it('convierte céntimos a euros', () => {
    assert.equal(centimosAEuros(1450), 14.5);
    assert.equal(centimosAEuros(1290), 12.9);
    assert.equal(centimosAEuros(0), 0);
    assert.equal(centimosAEuros(1), 0.01);
  });

  it('convierte euros a céntimos sin perder un céntimo por coma flotante', () => {
    assert.equal(eurosACentimos(14.5), 1450);
    assert.equal(eurosACentimos(16.75), 1675);
    // El caso crítico: 16.75 * 100 da 1674.9999999999998 en JavaScript.
    // Si eurosACentimos usara Math.floor o un truncado, esto daría 1674.
    assert.equal(eurosACentimos(0.29), 29);
    assert.equal(eurosACentimos(1.005), 101);
  });

  it('la ida y vuelta es estable', () => {
    for (const centimos of [1, 29, 1450, 1675, 99999]) {
      assert.equal(eurosACentimos(centimosAEuros(centimos)), centimos);
    }
  });
});

describe('cafeARepresentacion', () => {
  const cafeInterno = {
    id: 'caf_001',
    nombre: 'Etiopía Yirgacheffe',
    origen: 'Etiopía',
    tueste: 'claro',
    precioCentimos: 1450,
    stock: 120,
    notasCata: ['cítrico', 'floral'],
    descripcion: null,
    fechaCreacion: '2026-01-15T09:00:00Z',
    activo: true,
    version: 1,
  };

  it('expone precioEuros y NO expone precioCentimos', () => {
    const r = cafeARepresentacion(cafeInterno);
    assert.equal(r.precioEuros, 14.5);
    assert.equal(r.precioCentimos, undefined);
  });

  it('no filtra campos internos', () => {
    const r = cafeARepresentacion(cafeInterno);
    // 'activo' y 'version' son internos: no forman parte del contrato.
    assert.equal(r.activo, undefined);
    assert.equal(r.version, undefined);
  });

  it('los campos sin valor están presentes con null', () => {
    const r = cafeARepresentacion(cafeInterno);
    assert.ok('descripcion' in r, 'descripcion debe estar presente aunque sea null');
    assert.equal(r.descripcion, null);
  });

  it('los arrays vacíos son [] y nunca null', () => {
    const r = cafeARepresentacion({ ...cafeInterno, notasCata: undefined });
    assert.deepEqual(r.notasCata, []);
  });

  it('incluye el enlace self', () => {
    const r = cafeARepresentacion(cafeInterno);
    assert.equal(r._links.self.href, '/v1/cafes/caf_001');
  });
});

describe('enlaces de acción de un pedido según su estado', () => {
  const base = {
    id: 'ped_5001',
    clienteId: 'cli_842',
    lineas: [{ cafeId: 'caf_001', nombre: 'Etiopía Yirgacheffe', cantidad: 2, precioCentimos: 1450 }],
    totalCentimos: 2900,
    fechaCreacion: '2026-03-14T10:30:00Z',
  };

  it('pendiente_pago ofrece pagar y anular, pero no factura', () => {
    const { _links } = pedidoARepresentacion({ ...base, estado: 'pendiente_pago' });
    assert.ok(_links.pagar, 'debe ofrecer pagar');
    assert.ok(_links.anular, 'debe ofrecer anular');
    assert.equal(_links.factura, undefined, 'no hay factura sin pago');
    assert.equal(_links.devolver, undefined);
  });

  it('enviado ofrece devolver y factura, pero ya no pagar', () => {
    const { _links } = pedidoARepresentacion({ ...base, estado: 'enviado' });
    assert.ok(_links.devolver);
    assert.ok(_links.factura);
    assert.equal(_links.pagar, undefined, 'un pedido enviado no se puede pagar otra vez');
  });

  it('el método de las acciones es POST', () => {
    const { _links } = pedidoARepresentacion({ ...base, estado: 'pendiente_pago' });
    assert.equal(_links.pagar.method, 'POST');
  });
});
npm test
✔ conversión entre céntimos y euros (2.1ms)
✔ cafeARepresentacion (1.4ms)
✔ enlaces de acción de un pedido según su estado (0.9ms)

ℹ tests 12
ℹ pass 12
ℹ fail 0

Estas doce pruebas se ejecutan en milisegundos, no necesitan base de datos ni servidor, y cubren decisiones del contrato de 02-05 que de otro modo solo estarían escritas en prosa. La prueba de activo y version es especialmente valiosa: es la que detectará el día que alguien "simplifique" el mapeador con un {...cafe} y filtre campos internos.

  1. Dobles de prueba e inyección de dependencias

Para probar servicioCafes sin base de datos hace falta poder sustituir su repositorio. Y hoy no se puede, porque el servicio lo importa directamente:

import { repositorioCafes } from '../repositorios/indice.js';   // fijo

La solución es inyección de dependencias: el servicio recibe su repositorio en vez de importarlo. Convertimos el objeto en una fábrica:

// src/servicios/cafes.js  (refactorizado)
import { errores } from '../errores/error-api.js';
import { eurosACentimos } from './mapeadores.js';
import { repositorioCafes } from '../repositorios/indice.js';

/**
 * Crea el servicio de cafés sobre un repositorio concreto.
 * En producción se usa el de SQLite; en las pruebas, uno falso en memoria.
 */
export function crearServicioCafes(repositorio) {
  return {
    listar(criterios) {
      return repositorio.buscar(criterios);
    },

    obtener(id) {
      const cafe = repositorio.buscarPorId(id);
      if (!cafe) throw errores.noEncontrado('cafe', id);
      return cafe;
    },

    crear(datos) {
      return repositorio.crear({
        nombre: datos.nombre.trim(),
        origen: datos.origen.trim(),
        tueste: datos.tueste,
        precioCentimos: eurosACentimos(datos.precioEuros),
        stock: datos.stock,
        notasCata: datos.notasCata ?? [],
        descripcion: datos.descripcion ?? null,
      });
    },

    borrar(id) {
      if (!repositorio.borrar(id)) throw errores.noEncontrado('cafe', id);
    },
    // ...reemplazar y modificar, igual que antes...
  };
}

/** Instancia por defecto: la que usan los controladores. */
export const servicioCafes = crearServicioCafes(repositorioCafes);

Los controladores no cambian: siguen importando servicioCafes. Pero ahora las pruebas pueden construir su propia instancia.

Sobre los tipos de dobles, porque la terminología se usa mal a menudo:

Doble Qué es Ejemplo aquí
Dummy Se pasa para rellenar, no se usa Un usuario cualquiera
Stub Devuelve respuestas fijas Un repositorio que siempre devuelve el mismo café
Fake Implementación real pero simplificada Nuestro repositorio en memoria
Mock Además verifica que se le llamó como se esperaba Comprobar que se llamó a borrar una vez
Spy Registra las llamadas sin cambiar el comportamiento mock.fn() de node:test

Usaremos sobre todo fakes, y aquí llega la recompensa de una decisión de 03-05: conservamos cafes-memoria.js al migrar a SQLite. Ese fichero, que parecía código muerto, es ahora un fake completo, ya escrito y con la misma interfaz.

  1. Prueba unitaria del servicio de cafés

// pruebas/unitarias/servicio-cafes.test.js
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { crearServicioCafes } from '../../src/servicios/cafes.js';

/** Fake mínimo con la interfaz del repositorio. */
function crearRepositorioFalso(cafesIniciales = []) {
  let cafes = cafesIniciales.map((c) => ({ ...c }));
  let contador = cafes.length;

  return {
    // Registro de llamadas, para poder verificar interacciones.
    llamadas: [],

    buscar(criterios) {
      this.llamadas.push(['buscar', criterios]);
      return { elementos: cafes.map((c) => ({ ...c })), total: cafes.length };
    },
    buscarPorId(id) {
      this.llamadas.push(['buscarPorId', id]);
      const cafe = cafes.find((c) => c.id === id);
      return cafe ? { ...cafe } : undefined;
    },
    crear(datos) {
      this.llamadas.push(['crear', datos]);
      const cafe = {
        id: `caf_${String(++contador).padStart(3, '0')}`,
        ...datos,
        fechaCreacion: '2026-03-14T10:00:00Z',
        activo: true,
      };
      cafes.push(cafe);
      return { ...cafe };
    },
    borrar(id) {
      this.llamadas.push(['borrar', id]);
      const antes = cafes.length;
      cafes = cafes.filter((c) => c.id !== id);
      return cafes.length < antes;
    },
  };
}

const CAFE = {
  id: 'caf_001',
  nombre: 'Etiopía Yirgacheffe',
  origen: 'Etiopía',
  tueste: 'claro',
  precioCentimos: 1450,
  stock: 120,
  notasCata: ['cítrico'],
  descripcion: null,
  activo: true,
};

describe('servicioCafes', () => {
  let repositorio;
  let servicio;

  // beforeEach recrea el estado ANTES DE CADA prueba: así ninguna
  // depende de lo que hiciera la anterior y el orden es irrelevante.
  beforeEach(() => {
    repositorio = crearRepositorioFalso([CAFE]);
    servicio = crearServicioCafes(repositorio);
  });

  it('devuelve un café existente', () => {
    const cafe = servicio.obtener('caf_001');
    assert.equal(cafe.nombre, 'Etiopía Yirgacheffe');
  });

  it('lanza ErrorApi 404 con el código del catálogo si no existe', () => {
    assert.throws(
      () => servicio.obtener('caf_999'),
      (error) => {
        assert.equal(error.esErrorApi, true);
        assert.equal(error.estado, 404);
        assert.equal(error.codigo, 'cafe_no_encontrado');
        assert.match(error.message, /caf_999/);
        return true;
      }
    );
  });

  it('convierte los euros del cliente a céntimos al crear', () => {
    servicio.crear({
      nombre: 'Kenia Nyeri',
      origen: 'Kenia',
      tueste: 'medio',
      precioEuros: 16.75,
      stock: 40,
    });

    const [, datos] = repositorio.llamadas.find(([nombre]) => nombre === 'crear');
    assert.equal(datos.precioCentimos, 1675);
    assert.equal(datos.precioEuros, undefined, 'el repositorio no debe ver euros');
  });

  it('recorta los espacios del nombre antes de guardar', () => {
    servicio.crear({
      nombre: '   Kenia Nyeri   ',
      origen: 'Kenia',
      tueste: 'medio',
      precioEuros: 16.75,
      stock: 40,
    });
    const [, datos] = repositorio.llamadas.find(([nombre]) => nombre === 'crear');
    assert.equal(datos.nombre, 'Kenia Nyeri');
  });

  it('lanza 404 al borrar un café inexistente', () => {
    assert.throws(() => servicio.borrar('caf_999'), /caf_999/);
  });
});

Estas pruebas son rápidas y precisas: no tocan disco, no dependen del estado de la base de datos y, cuando una falla, señalan una sola función. Fíjate en la tercera: comprueba que el repositorio nunca ve euros, es decir, que la frontera de unidades que diseñamos se respeta. Eso es exactamente lo que una prueba unitaria puede verificar y una de integración no distinguiría.

  1. Supertest: peticiones sin puerto

Supertest toma el objeto app de Express y le lanza peticiones HTTP reales… sobre un servidor efímero que abre y cierra él mismo en un puerto aleatorio. Para nosotros equivale a no abrir puerto: no hay EADDRINUSE, no hay que arrancar nada y varias suites pueden correr a la vez.

import request from 'supertest';
import { app } from '../../src/app.js';

const respuesta = await request(app).get('/v1/cafes');
assert.equal(respuesta.status, 200);

Aquí se cobra el dividendo de la separación de 03-02. Si app.js hubiera llamado a listen(), importarlo desde una prueba ocuparía el puerto 3000 y la segunda suite fallaría.

La API de Supertest, en una tabla:

Llamada Qué hace
request(app).get(ruta) Método y ruta
.set('Authorization', valor) Cabecera de petición
.send(objeto) Cuerpo JSON (pone el Content-Type)
.query({ limite: 5 }) Parámetros de consulta
respuesta.status Código
respuesta.body Cuerpo ya parseado
respuesta.headers['location'] Cabecera de respuesta (en minúsculas)

  1. Datos de prueba: base de datos aislada

Las pruebas de integración necesitan base de datos, y hay tres reglas innegociables: no tocar la de desarrollo, empezar cada suite con datos conocidos y que el orden de las pruebas no importe.

// pruebas/ayudas/entorno-prueba.js

/**
 * Configura el entorno ANTES de que se cargue nada de src/.
 * Debe importarse la PRIMERA en cada fichero de prueba.
 */
process.env.NODE_ENV = 'prueba';
process.env.RUTA_BASE_DATOS = ':memory:';   // base SQLite solo en RAM
process.env.JWT_SECRETO = 'secreto-solo-para-pruebas-no-usar-en-produccion';
process.env.JWT_CADUCIDAD = '1h';
process.env.PUERTO = '0';
// pruebas/ayudas/base-datos-prueba.js
import { readFileSync } from 'node:fs';
import { baseDatos } from '../../src/config/base-datos.js';

/** Aplica el esquema completo sobre la base en memoria. */
export function migrar() {
  baseDatos.exec(readFileSync('migraciones/001-inicial.sql', 'utf8'));
}

/** Deja la base con datos conocidos. Se llama antes de cada prueba. */
export function sembrar() {
  const limpiar = baseDatos.transaction(() => {
    baseDatos.exec('DELETE FROM lineas_pedido; DELETE FROM pedidos; DELETE FROM resenas;');
    baseDatos.exec('DELETE FROM cafes; DELETE FROM clientes;');
  });
  limpiar();

  const insertarCafe = baseDatos.prepare(`
    INSERT INTO cafes (id, nombre, origen, tueste, precio_centimos, stock,
                       notas_cata, descripcion, fecha_creacion, activo, version)
    VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, 1, 1)
  `);

  insertarCafe.run('caf_001', 'Etiopía Yirgacheffe', 'Etiopía', 'claro', 1450, 120,
    JSON.stringify(['cítrico', 'floral', 'té negro']), '2026-01-15T09:00:00Z');
  insertarCafe.run('caf_002', 'Colombia Huila', 'Colombia', 'medio', 1290, 80,
    JSON.stringify(['chocolate', 'caramelo', 'nuez']), '2026-01-20T11:15:00Z');

  const insertarCliente = baseDatos.prepare(`
    INSERT INTO clientes (id, nombre, email, hash_contrasena, rol, fecha_creacion)
    VALUES (?, ?, ?, ?, ?, '2026-01-10T08:00:00Z')
  `);

  // Hash real de 'contrasena-de-prueba', precalculado para no gastar
  // 80 ms de bcrypt en cada prueba. En las de login sí se usa bcrypt.
  const HASH = '$2b$10$K7L1OJ0/9F0iQZ8dJvKZ8eqPX5Y1cW0nR4tV6uH2sA9bC3dE4fG5i';

  insertarCliente.run('cli_842', 'Marta García', '[email protected]', HASH, 'cliente');
  insertarCliente.run('cli_001', 'Alba Ruiz', '[email protected]', HASH, 'empleado');
  insertarCliente.run('cli_002', 'Diego Sanz', '[email protected]', HASH, 'administrador');

  baseDatos
    .prepare(
      `INSERT INTO pedidos (id, cliente_id, estado, total_centimos, fecha_creacion, version)
       VALUES ('ped_5001', 'cli_842', 'pendiente_pago', 2900, '2026-03-14T10:30:00Z', 1)`
    )
    .run();

  baseDatos
    .prepare(
      `INSERT INTO lineas_pedido (pedido_id, cafe_id, nombre_cafe, cantidad, precio_centimos)
       VALUES ('ped_5001', 'caf_001', 'Etiopía Yirgacheffe', 2, 1450)`
    )
    .run();
}

Y la ayuda para autenticarse sin pasar por el login en cada prueba:

// pruebas/ayudas/token.js
import { emitirToken } from '../../src/servicios/autenticacion.js';

/** Genera un Bearer válido para un rol. Usa el MISMO emisor que la app. */
export function tokenDe(rol = 'cliente', id = 'cli_842') {
  return `Bearer ${emitirToken({ id, rol })}`;
}

Generar el token con la función real de la aplicación, y no con un JWT escrito a mano en la prueba, es deliberado: si mañana cambia el emisor, el algoritmo o los claims, las pruebas siguen siendo válidas. Un token fabricado a mano se convierte en una copia del código que hay que mantener sincronizada.

Sobre :memory:: cada conexión SQLite en memoria es una base independiente que desaparece al cerrar el proceso. Es la opción más rápida y la que garantiza el aislamiento total entre ejecuciones. La alternativa es un fichero temporal (datos/prueba-${process.pid}.db), útil cuando quieres inspeccionar el estado tras un fallo.

Un detalle de ESM que cuesta una tarde. src/config/base-datos.js abre la conexión al importarse, leyendo entorno.rutaBaseDatos. Por eso entorno-prueba.js debe evaluarse antes. Los módulos ESM se evalúan en el orden en que aparecen sus import, así que poner import './ayudas/entorno-prueba.js'; en primera línea funciona… hasta que un formateador reordena los imports alfabéticamente. La versión a prueba de balas es cargar la app con importación dinámica:

import './ayudas/entorno-prueba.js';
const { app } = await import('../../src/app.js');   // se evalúa aquí, no antes

  1. Pruebas de integración de la colección

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

const { app } = await import('../../src/app.js');

before(() => migrar());        // el esquema, una vez
beforeEach(() => sembrar());   // los datos, antes de cada prueba

describe('GET /v1/cafes', () => {
  it('devuelve 200 con el envoltorio del contrato', async () => {
    const r = await request(app).get('/v1/cafes');

    assert.equal(r.status, 200);
    assert.match(r.headers['content-type'], /application\/json/);
    assert.ok(Array.isArray(r.body.datos), 'datos debe ser un array');
    assert.equal(typeof r.body.total, 'number');
    assert.equal(r.body.total, 2);
  });

  it('cada elemento tiene la forma exacta del contrato', async () => {
    const r = await request(app).get('/v1/cafes');
    const cafe = r.body.datos.find((c) => c.id === 'caf_001');

    assert.equal(cafe.nombre, 'Etiopía Yirgacheffe');
    assert.equal(cafe.precioEuros, 14.5);       // euros, no céntimos
    assert.equal(cafe.precioCentimos, undefined);
    assert.equal(cafe.descripcion, null);       // presente aunque sea null
    assert.deepEqual(cafe.notasCata, ['cítrico', 'floral', 'té negro']);
    assert.equal(cafe._links.self.href, '/v1/cafes/caf_001');
  });

  it('filtra por origen', async () => {
    const r = await request(app).get('/v1/cafes').query({ origen: 'Colombia' });
    assert.equal(r.body.total, 1);
    assert.equal(r.body.datos[0].id, 'caf_002');
  });

  it('total es el número de coincidencias, no el de la página', async () => {
    const r = await request(app).get('/v1/cafes').query({ limite: 1 });
    assert.equal(r.body.datos.length, 1, 'la página trae un elemento');
    assert.equal(r.body.total, 2, 'pero hay dos coincidencias');
  });

  it('devuelve la cabecera Link conservando los filtros', async () => {
    const r = await request(app).get('/v1/cafes').query({ limite: 1, ordenar: 'nombre' });

    assert.ok(r.headers.link, 'debe haber cabecera Link');
    assert.match(r.headers.link, /rel="next"/);
    assert.match(r.headers.link, /ordenar=nombre/, 'el filtro debe sobrevivir en el enlace');
  });

  it('rechaza un limite excesivo con 400 y NO lo recorta', async () => {
    const r = await request(app).get('/v1/cafes').query({ limite: 5000 });

    assert.equal(r.status, 400);
    assert.equal(r.body.error.codigo, 'parametro_invalido');
    assert.equal(r.body.error.detalles[0].campo, 'limite');
  });

  it('rechaza un parámetro desconocido (entrada estricta)', async () => {
    const r = await request(app).get('/v1/cafes').query({ limit: 20 });
    assert.equal(r.status, 400);
    assert.equal(r.body.error.codigo, 'parametro_invalido');
  });
});

describe('GET /v1/cafes/:id', () => {
  it('devuelve el elemento SIN envoltorio', async () => {
    const r = await request(app).get('/v1/cafes/caf_001');
    assert.equal(r.status, 200);
    assert.equal(r.body.id, 'caf_001');
    assert.equal(r.body.datos, undefined, 'un elemento no lleva envoltorio');
  });

  it('devuelve 404 con el código del catálogo', async () => {
    const r = await request(app).get('/v1/cafes/caf_999');

    assert.equal(r.status, 404);
    assert.equal(r.body.error.codigo, 'cafe_no_encontrado');
    assert.deepEqual(r.body.error.detalles, [], 'detalles siempre presente');
    assert.equal(r.body.error.trazaId, undefined, 'trazaId solo en los 5xx');
  });
});

describe('DELETE /v1/cafes sobre la colección', () => {
  it('devuelve 405 con la cabecera Allow', async () => {
    const r = await request(app).delete('/v1/cafes').set('Authorization', tokenDe('administrador'));

    assert.equal(r.status, 405);
    assert.equal(r.body.error.codigo, 'metodo_no_permitido');
    assert.match(r.headers.allow, /GET/);
    assert.match(r.headers.allow, /POST/);
  });
});

Cada una de estas pruebas corresponde a una decisión concreta del módulo 2. La de total protege contra el error más frecuente en las colecciones paginadas; la de Link contra perder los filtros; la de limite=5000 contra el recorte silencioso; la de detalles: [] contra que un cliente tenga que comprobar si el campo existe.

  1. Pruebas de creación, cabeceras y validación

// pruebas/integracion/cafes.test.js  (continuación)

describe('POST /v1/cafes', () => {
  const CAFE_VALIDO = {
    nombre: 'Kenia Nyeri',
    origen: 'Kenia',
    tueste: 'medio',
    precioEuros: 16.75,
    stock: 40,
    notasCata: ['grosella', 'tomate'],
  };

  it('crea con 201, cabecera Location y representación completa', async () => {
    const r = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('empleado', 'cli_001'))
      .send(CAFE_VALIDO);

    assert.equal(r.status, 201);
    assert.ok(r.headers.location, 'Location es obligatoria en toda creación');
    assert.match(r.headers.location, /^\/v1\/cafes\/caf_\d{3}$/);
    assert.equal(r.body.precioEuros, 16.75);
    assert.equal(r.body.descripcion, null);
    assert.equal(r.headers.location, r.body._links.self.href, 'Location y self coinciden');
  });

  it('el recurso creado es recuperable en la URI de Location', async () => {
    const creacion = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('empleado', 'cli_001'))
      .send(CAFE_VALIDO);

    const lectura = await request(app).get(creacion.headers.location);

    assert.equal(lectura.status, 200);
    assert.equal(lectura.body.nombre, 'Kenia Nyeri');
    assert.equal(lectura.body.precioEuros, 16.75, 'el precio sobrevive a la ida y vuelta');
  });

  it('devuelve TODOS los fallos de validación a la vez', async () => {
    const r = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('empleado', 'cli_001'))
      .send({ nombre: 'K', origen: 'Kenia', tueste: 'tostado', precioEuros: '16.75', stock: -3 });

    assert.equal(r.status, 400);
    assert.equal(r.body.error.codigo, 'datos_invalidos');
    assert.equal(r.body.error.detalles.length, 4, 'cuatro fallos, cuatro detalles');

    const campos = r.body.error.detalles.map((d) => d.campo).sort();
    assert.deepEqual(campos, ['nombre', 'precioEuros', 'stock', 'tueste']);

    // Cada detalle tiene los tres campos del contrato.
    for (const detalle of r.body.error.detalles) {
      assert.ok(detalle.campo);
      assert.ok(detalle.codigo);
      assert.ok(detalle.mensaje);
    }
  });

  it('rechaza campos desconocidos (entrada estricta)', async () => {
    const r = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('empleado', 'cli_001'))
      .send({ ...CAFE_VALIDO, color: 'rojo' });

    assert.equal(r.status, 400);
    assert.equal(r.body.error.detalles[0].codigo, 'campo_desconocido');
  });

  it('rechaza JSON mal formado con 400 del contrato, no con HTML', async () => {
    const r = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('empleado', 'cli_001'))
      .set('Content-Type', 'application/json')
      .send('{"nombre": "Kenia,}');

    assert.equal(r.status, 400);
    assert.equal(r.body.error.codigo, 'datos_invalidos');
  });
});

describe('PATCH /v1/cafes/:id', () => {
  it('modifica solo lo enviado', async () => {
    const r = await request(app)
      .patch('/v1/cafes/caf_001')
      .set('Authorization', tokenDe('empleado', 'cli_001'))
      .set('Content-Type', 'application/merge-patch+json')
      .send({ stock: 95 });

    assert.equal(r.status, 200);
    assert.equal(r.body.stock, 95);
    assert.equal(r.body.precioEuros, 14.5, 'el precio no debe cambiar');
    assert.equal(r.body.nombre, 'Etiopía Yirgacheffe');
  });

  it('rechaza JSON Patch con 415 y Accept-Patch', async () => {
    const r = await request(app)
      .patch('/v1/cafes/caf_001')
      .set('Authorization', tokenDe('empleado', 'cli_001'))
      .set('Content-Type', 'application/json-patch+json')
      .send([{ op: 'replace', path: '/stock', value: 95 }]);

    assert.equal(r.status, 415);
    assert.equal(r.body.error.codigo, 'formato_no_soportado');
    assert.equal(r.headers['accept-patch'], 'application/merge-patch+json');
  });
});

describe('DELETE /v1/cafes/:id', () => {
  it('devuelve 204 sin cuerpo y el café deja de existir', async () => {
    const borrado = await request(app)
      .delete('/v1/cafes/caf_002')
      .set('Authorization', tokenDe('administrador', 'cli_002'));

    assert.equal(borrado.status, 204);
    assert.deepEqual(borrado.body, {}, '204 no lleva cuerpo');

    const lectura = await request(app).get('/v1/cafes/caf_002');
    assert.equal(lectura.status, 404);
  });
});

La segunda prueba de POST es la más valiosa de todo el fichero: crea y luego lee en la URI que devolvió Location. Verifica de una vez que la cabecera es correcta, que el recurso se persistió, que los céntimos sobreviven a la ida y vuelta y que la representación es la misma en creación y en lectura. Una prueba, cuatro promesas del contrato.

  1. Pruebas de autenticación y autorización

// pruebas/integracion/autenticacion.test.js
import './../ayudas/entorno-prueba.js';
import { describe, it, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { migrar, sembrar } from '../ayudas/base-datos-prueba.js';
import { tokenDe } from '../ayudas/token.js';

const { app } = await import('../../src/app.js');

before(() => migrar());
beforeEach(() => sembrar());

describe('autenticación', () => {
  it('401 sin token, con WWW-Authenticate', async () => {
    const r = await request(app).get('/v1/pedidos');

    assert.equal(r.status, 401);
    assert.equal(r.body.error.codigo, 'no_autenticado');
    assert.match(r.headers['www-authenticate'], /^Bearer/);
  });

  it('401 con token manipulado', async () => {
    const valido = tokenDe('cliente');
    const roto = `${valido.slice(0, -4)}XXXX`;   // se cambia la firma

    const r = await request(app).get('/v1/pedidos').set('Authorization', roto);

    assert.equal(r.status, 401);
    assert.equal(r.body.error.codigo, 'no_autenticado');
  });

  it('401 token_caducado distingue el caso de un token vencido', async () => {
    // Se firma un token que expiró hace una hora.
    const caducado = jwt.sign({ rol: 'cliente' }, process.env.JWT_SECRETO, {
      subject: 'cli_842',
      issuer: 'api.tiendaaroma.example',
      expiresIn: '-1h',
    });

    const r = await request(app).get('/v1/pedidos').set('Authorization', `Bearer ${caducado}`);

    assert.equal(r.status, 401);
    assert.equal(r.body.error.codigo, 'token_caducado', 'el cliente debe poder renovar');
  });

  it('200 con un token válido', async () => {
    const r = await request(app).get('/v1/pedidos').set('Authorization', tokenDe('cliente'));
    assert.equal(r.status, 200);
  });
});

describe('autorización por rol', () => {
  it('403 al crear un café con rol cliente', async () => {
    const r = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('cliente'))
      .send({ nombre: 'Kenia Nyeri', origen: 'Kenia', tueste: 'medio', precioEuros: 16.75, stock: 40 });

    assert.equal(r.status, 403, 'permiso, no identidad: 403 y no 401');
    assert.equal(r.body.error.codigo, 'permisos_insuficientes');
  });

  it('201 al crear un café con rol empleado', async () => {
    const r = await request(app)
      .post('/v1/cafes')
      .set('Authorization', tokenDe('empleado', 'cli_001'))
      .send({ nombre: 'Kenia Nyeri', origen: 'Kenia', tueste: 'medio', precioEuros: 16.75, stock: 40 });

    assert.equal(r.status, 201);
  });

  it('403 al borrar un café con rol empleado (solo administrador)', async () => {
    const r = await request(app)
      .delete('/v1/cafes/caf_001')
      .set('Authorization', tokenDe('empleado', 'cli_001'));

    assert.equal(r.status, 403);
  });
});

describe('autorización a nivel de recurso', () => {
  it('un cliente NO ve el pedido de otro, y recibe 404 (no 403)', async () => {
    const r = await request(app)
      .get('/v1/pedidos/ped_5001')                       // es de cli_842
      .set('Authorization', tokenDe('cliente', 'cli_999'));

    assert.equal(r.status, 404, 'no confirmamos que el pedido exista');
    assert.equal(r.body.error.codigo, 'pedido_no_encontrado');
  });

  it('el propietario sí lo ve', async () => {
    const r = await request(app)
      .get('/v1/pedidos/ped_5001')
      .set('Authorization', tokenDe('cliente', 'cli_842'));

    assert.equal(r.status, 200);
    assert.equal(r.body.totalEuros, 29);
  });

  it('un cliente no puede listar los pedidos de otro con ?clienteId', async () => {
    const r = await request(app)
      .get('/v1/pedidos')
      .query({ clienteId: 'cli_842' })
      .set('Authorization', tokenDe('cliente', 'cli_999'));

    assert.equal(r.status, 200);
    assert.equal(r.body.total, 0, 'el clienteId de la query se ignora y se fuerza el del token');
  });

  it('un empleado sí ve los pedidos de cualquiera', async () => {
    const r = await request(app)
      .get('/v1/pedidos')
      .query({ clienteId: 'cli_842' })
      .set('Authorization', tokenDe('empleado', 'cli_001'));

    assert.equal(r.body.total, 1);
  });
});

Esa penúltima prueba —el ?clienteId de otro cliente— es la más importante del fichero. Es la única que detecta el IDOR de 03-06, y es exactamente el tipo de fallo que las pruebas funcionales no ven: la ruta responde 200, el token es válido, el rol es correcto y aun así se estarían filtrando datos ajenos. La matriz de permisos de 03-06 debería tener una prueba por celda.

  1. Cobertura y qué mide realmente

node --test --experimental-test-coverage pruebas/
ℹ start of coverage report
ℹ ------------------------------------------------------------
ℹ file                          | line % | branch % | funcs %
ℹ ------------------------------------------------------------
ℹ src/servicios/mapeadores.js   |  98.20 |    91.60 |  100.00
ℹ src/servicios/cafes.js        |  92.30 |    83.30 |  100.00
ℹ src/middleware/validacion.js  |  88.90 |    75.00 |  100.00
ℹ src/middleware/errores.js     |  71.40 |    58.30 |   80.00
ℹ src/repositorios/cafes-sqlite |  85.00 |    70.00 |   90.00
ℹ ------------------------------------------------------------
ℹ all files                     |  86.10 |    74.20 |   92.30
Métrica Qué mide
line % Líneas ejecutadas
branch % Ramas de los if/switch recorridas
funcs % Funciones invocadas al menos una vez

Para qué sirve la cobertura y para qué no. Sirve para encontrar lo que no se ha probado: en el informe de arriba, middleware/errores.js al 58 % de ramas indica que hay traducciones de error —la de SQLite, seguramente— que ninguna prueba ejerce. Ese hueco es información accionable.

Lo que no es es una medida de calidad. Esta prueba da 100 % de cobertura y no comprueba nada:

it('no falla', () => {
  cafeARepresentacion(cafeInterno);   // ni una sola aserción
});

Perseguir el 100 % lleva a escribir pruebas de ese tipo, a probar getters triviales y a añadir aserciones sobre detalles internos que convierten cualquier refactor en un día de arreglar pruebas. Un objetivo razonable es 80-90 % en la lógica de negocio, con la mirada puesta en las ramas más que en las líneas, y con el criterio de que todo camino de error tenga al menos una prueba. Los caminos de error son precisamente los que nadie ejecuta a mano y los que más fallan cuando llega el momento.

  1. Pruebas de contrato: la idea

Nuestras pruebas verifican lo que nosotros creemos que dice el contrato. Pero el contrato real está en openapi.yaml (02-08), y nada garantiza hoy que ambos coincidan: si alguien añade un campo a la respuesta sin tocar el YAML, todas nuestras pruebas siguen en verde y la documentación pasa a mentir.

Una prueba de contrato cierra ese hueco validando la respuesta real contra el esquema publicado:

// Idea, no implementación: se desarrolla en 05-04.
import { validarContraEsquema } from 'alguna-herramienta-openapi';

it('la respuesta cumple el esquema publicado', async () => {
  const r = await request(app).get('/v1/cafes/caf_001');
  const resultado = validarContraEsquema(r.body, 'openapi.yaml', '#/components/schemas/Cafe');
  assert.equal(resultado.valido, true, JSON.stringify(resultado.errores));
});

Con eso, el YAML deja de ser documentación y pasa a ser una prueba: si la implementación y la especificación divergen, el build falla. Es la única defensa real contra la deriva que describimos en 02-08. Las herramientas concretas —validadores de OpenAPI, Pact para contratos entre consumidor y proveedor, mocks generados desde la especificación— son la lección 05-04.

  1. Exploración manual y Postman

Las pruebas automáticas comprueban lo que se te ocurrió comprobar. La exploración manual sirve para lo otro: ¿qué pasa si envío un array donde espera un objeto? ¿Y si el Accept-Language es zh? ¿Y si mando limite=0?

# Guion de humo tras cualquier cambio importante
API=http://localhost:3000/v1

curl -s "$API/cafes" | jq '.total'
curl -s -o /dev/null -w "%{http_code}\n" "$API/cafes/caf_999"     # espera 404
curl -s -o /dev/null -w "%{http_code}\n" "$API/cafes?limite=5000" # espera 400
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE "$API/cafes"   # espera 405
curl -si "$API/cafes?limite=1" | grep -i "^link"                  # espera Link

-w "%{http_code}\n" imprime solo el código de estado, que es lo que interesa en un guion de humo.

Cuando la exploración manual crece —colecciones organizadas, entornos con variables, encadenar el token del login a las peticiones siguientes, ejecutar todo de golpe— la herramienta adecuada es Postman, y le dedicamos entera la lección 05-01. Lo importante es el orden: lo que descubras explorando, conviértelo en una prueba automática. Un bug encontrado a mano que no acaba en una prueba volverá.

  1. Lista de verificación del contrato de Tienda Aroma

Antes de dar por buena cualquier versión de la API:

Colecciones

  • [ ] GET de colección devuelve {"datos": [...], "total": n}, nunca un array desnudo.
  • [ ] total es el número de coincidencias del filtro, no el de la página.
  • [ ] Una colección vacía es 200 con datos: [], nunca 404.
  • [ ] Los filtros se combinan con Y lógico y un parámetro desconocido da 400.
  • [ ] limite por defecto 20, máximo 100, y por encima 400, sin recortar.
  • [ ] Toda ordenación termina con desempate por id.
  • [ ] La cabecera Link está presente cuando hay más de una página y conserva los filtros.

Elementos

  • [ ] GET de elemento devuelve el objeto desnudo, sin envoltorio.
  • [ ] Los campos sin valor están presentes con null; los arrays vacíos son [].
  • [ ] Los importes salen en euros; los céntimos nunca cruzan la frontera.
  • [ ] Los enumerados van en snake_case y no se traducen.
  • [ ] Las fechas son ISO-8601 en UTC con Z.
  • [ ] _links.self está siempre; las acciones solo en pedidos y según su estado.

Escritura

  • [ ] POST devuelve 201 con Location, y esa URI es recuperable con GET.
  • [ ] PUT reemplaza el recurso entero; PATCH solo lo enviado.
  • [ ] PATCH con application/json-patch+json da 415 con Accept-Patch.
  • [ ] DELETE devuelve 204 sin cuerpo.
  • [ ] Los campos que fija el servidor (id, estado, precios de línea) se rechazan en la entrada.

Errores

  • [ ] Todos tienen la forma {"error": {"codigo", "mensaje", "detalles"}}.
  • [ ] detalles está siempre, aunque sea [].
  • [ ] La validación devuelve todos los fallos a la vez.
  • [ ] trazaId solo en los 5xx.
  • [ ] Ningún error filtra pila, SQL, versiones ni rutas del sistema.
  • [ ] Método no permitido: 405 con Allow.
  • [ ] Ruta inexistente: 404 en JSON, nunca la página HTML de Express.

Seguridad

  • [ ] 401 con WWW-Authenticate; token_caducado distinguido de no_autenticado.
  • [ ] 403 cuando falta permiso, 401 cuando falta identidad.
  • [ ] Recursos ajenos: 404, no 403.
  • [ ] El identificador del propietario sale del token, nunca de la entrada.
  • [ ] Ninguna respuesta incluye contraseñas ni hashes.
  • [ ] Hay una prueba por cada celda de la matriz de permisos.

Proceso

  • [ ] npm test pasa en verde.
  • [ ] openapi.yaml refleja los endpoints, campos y errores reales.
  • [ ] Toda ruta nueva tiene su prueba de camino feliz y de error.

  1. Balance del módulo 3

Ocho lecciones, un solo proyecto. Esto es lo que hay construido:

Lección Lo que aportó
03-01 Node 20, ESM, dependencias, estructura por capas, configuración validada
03-02 Express, middleware, app/servidor separados, Router en /v1, 404 del contrato
03-03 Capas rutas/controladores/servicios, mapeador, CRUD, filtros, paginación, Link
03-04 Esquemas Zod, entrada estricta, validar(esquema, origen), todos los fallos a la vez
03-05 SQLite tras el repositorio, migraciones, sentencias preparadas, transacciones, cursor
03-06 bcrypt, JWT, autenticar, exigirRol, propiedad a nivel de recurso, matriz de permisos
03-07 ErrorApi, fábricas, middleware de errores único, asincrono(), trazaId
03-08 Unitarias con dobles, integración con Supertest, cobertura, lista de verificación

Y esto es lo que no tiene todavía, que es exactamente el programa del módulo 4: no hay CORS, así que un navegador en otro dominio no puede llamarla; no hay límite de peticiones, así que un bucle mal escrito la satura; no hay cabeceras de seguridad ni protección frente a las amenazas del OWASP; no hay caché HTTP, así que cada petición recalcula todo; no hay delegación de acceso para terceros; y sus logs son console.log sin niveles ni agregación, imposibles de consultar en producción.

Errores Comunes y Consejos

1. Pruebas que dependen del orden. Si la prueba B necesita que A haya creado un café, cualquier reordenación las rompe. beforeEach con siembra completa lo resuelve.

2. Compartir la base de datos de desarrollo. Las pruebas la vaciarán. Base en memoria o fichero temporal, siempre.

3. Probar Express, Zod o SQLite. No es tu código. Prueba tus decisiones.

4. Aserciones sobre el objeto completo. assert.deepEqual(cuerpo, {...}) con veinte campos falla cada vez que se añade uno, aunque sea un cambio aditivo perfectamente válido. Afirma sobre lo que importa.

5. Olvidar await en una prueba async. La prueba pasa sin haber comprobado nada, porque termina antes que la petición.

6. Perseguir el 100 % de cobertura. Produce pruebas sin aserciones y pruebas frágiles. Mira las ramas de la lógica de negocio.

7. Leer cabeceras con mayúsculas. En la respuesta de Supertest son r.headers['content-type'], en minúsculas.

8. No probar los caminos de error. Son los que nadie ejecuta a mano y los que más se rompen.

9. Fabricar tokens JWT a mano en la prueba. Duplica el código de emisión. Usa la función real de la aplicación.

Consejo: cuando aparezca un bug en producción, escribe primero la prueba que lo reproduce, comprueba que falla, y solo entonces arréglalo. Así sabes que la prueba sirve y ese bug concreto no vuelve nunca.

Ejercicios

Ejercicio 1

Escribe las pruebas de integración de POST /v1/pedidos que cubran: creación correcta con 201 y Location, 409 stock_insuficiente al pedir más unidades de las disponibles, 400 clave_idempotencia_requerida sin la cabecera, y —la importante— que tras un 409 el stock no se haya descontado. Explica por qué esta última prueba es la que verifica la transacción de 03-05.

Ejercicio 2

Esta prueba pasa siempre, incluso con la aplicación rota. Encuentra los tres motivos y escríbela correctamente.

it('crea un café', () => {
  const respuesta = request(app).post('/v1/cafes').send({ nombre: 'Kenia' });
  assert.ok(respuesta);
});

Ejercicio 3

El informe de cobertura muestra src/middleware/errores.js al 58 % de ramas. Identifica qué caminos concretos no están cubiertos según lo que hemos escrito en esta lección, decide cuáles merecen prueba y cuáles no, y escribe la prueba del más importante: comprobar que un error inesperado produce 500 error_interno con trazaId y sin filtrar el mensaje interno.

Soluciones

Solución 1

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

const { app } = await import('../../src/app.js');

before(() => migrar());
beforeEach(() => sembrar());

describe('POST /v1/pedidos', () => {
  const cabeceras = (clave = 'clave-unica-1') => ({
    Authorization: tokenDe('cliente', 'cli_842'),
    'Idempotency-Key': clave,
  });

  it('crea el pedido con 201 y Location', async () => {
    const r = await request(app)
      .post('/v1/pedidos')
      .set(cabeceras())
      .send({ lineas: [{ cafeId: 'caf_001', cantidad: 2 }] });

    assert.equal(r.status, 201);
    assert.match(r.headers.location, /^\/v1\/pedidos\/ped_\d+$/);
    assert.equal(r.body.estado, 'pendiente_pago');
    assert.equal(r.body.totalEuros, 29);          // 2 × 14,50 €
    assert.equal(r.body.lineas[0].precioEuros, 14.5, 'precio congelado');
    assert.ok(r.body._links.pagar, 'un pedido pendiente ofrece pagar');
  });

  it('descuenta el stock del café', async () => {
    await request(app)
      .post('/v1/pedidos')
      .set(cabeceras())
      .send({ lineas: [{ cafeId: 'caf_001', cantidad: 2 }] });

    const cafe = await request(app).get('/v1/cafes/caf_001');
    assert.equal(cafe.body.stock, 118, '120 − 2');
  });

  it('409 stock_insuficiente al pedir más de lo disponible', async () => {
    const r = await request(app)
      .post('/v1/pedidos')
      .set(cabeceras())
      .send({ lineas: [{ cafeId: 'caf_001', cantidad: 99 }] });

    assert.equal(r.status, 409);
    assert.equal(r.body.error.codigo, 'stock_insuficiente');
  });

  it('400 clave_idempotencia_requerida sin la cabecera', async () => {
    const r = await request(app)
      .post('/v1/pedidos')
      .set('Authorization', tokenDe('cliente', 'cli_842'))
      .send({ lineas: [{ cafeId: 'caf_001', cantidad: 1 }] });

    assert.equal(r.status, 400);
    assert.equal(r.body.error.codigo, 'clave_idempotencia_requerida');
  });

  it('tras un 409, NADA se ha modificado (atomicidad)', async () => {
    // Primera línea válida, segunda sin stock: debe fallar entera.
    const r = await request(app)
      .post('/v1/pedidos')
      .set(cabeceras())
      .send({
        lineas: [
          { cafeId: 'caf_001', cantidad: 2 },   // hay 120: cabría
          { cafeId: 'caf_002', cantidad: 99 },  // hay 80: no cabe
        ],
      });

    assert.equal(r.status, 409);

    // El stock del PRIMER café debe seguir intacto.
    const cafe1 = await request(app).get('/v1/cafes/caf_001');
    assert.equal(cafe1.body.stock, 120, 'el ROLLBACK debe deshacer el primer descuento');

    // Y no debe haber quedado ningún pedido a medias.
    const pedidos = await request(app)
      .get('/v1/pedidos')
      .set('Authorization', tokenDe('empleado', 'cli_001'));
    assert.equal(pedidos.body.total, 1, 'solo el pedido sembrado');
  });
});

Por qué la última prueba verifica la transacción: es la única que ejercita el camino de fallo a mitad de una operación de varios pasos. Sin BEGIN/ROLLBACK, la primera línea habría descontado 2 unidades de caf_001 y la fila del pedido ya estaría insertada cuando la segunda línea falla; el resultado sería stock desaparecido y un pedido incompleto en la base. Con la transacción, la excepción provoca ROLLBACK y el estado vuelve exactamente a como estaba. Es un fallo que no se ve nunca en el camino feliz, que no aparece en desarrollo con datos de sobra, y que en producción produce descuadres de inventario imposibles de explicar. Por eso merece una prueba explícita.

Solución 2

# Motivo Efecto
1 Falta await request(app).post(...) devuelve un objeto thenable que solo ejecuta la petición al esperarlo. La prueba termina sin que la petición llegue a enviarse
2 La prueba no es async Sin async no se puede usar await, y el runner da la prueba por terminada de inmediato
3 assert.ok(respuesta) no comprueba nada Un objeto siempre es verdadero. Pasaría igual con un 500, con un 400 o con la aplicación caída. Además, el cuerpo enviado es inválido (faltan origen, tueste, precioEuros, stock) y no hay token, así que la respuesta real sería 401

Versión correcta:

it('crea un café con datos válidos', async () => {
  const respuesta = await request(app)
    .post('/v1/cafes')
    .set('Authorization', tokenDe('empleado', 'cli_001'))
    .send({
      nombre: 'Kenia Nyeri',
      origen: 'Kenia',
      tueste: 'medio',
      precioEuros: 16.75,
      stock: 40,
    });

  assert.equal(respuesta.status, 201);
  assert.ok(respuesta.headers.location);
  assert.equal(respuesta.body.nombre, 'Kenia Nyeri');
  assert.equal(respuesta.body.precioEuros, 16.75);
});

La lección general: una prueba sin aserciones concretas es peor que ninguna prueba, porque da una falsa sensación de seguridad y además cuenta para la cobertura. Una regla útil: si al romper deliberadamente el código la prueba sigue en verde, la prueba no sirve.

Solución 3

Caminos de errores.js que probablemente no están cubiertos:

Camino ¿Merece prueba?
traducirSqlite con SQLITE_CONSTRAINT_UNIQUE : es una carrera real (dos registros con el mismo email)
traducirSqlite con SQLITE_CONSTRAINT_FOREIGNKEY Sí: pedido con cliente inexistente
traducirSqlite con SQLITE_BUSY No: difícil de provocar y su lógica es trivial
Error genérico → 500 error_interno Sí, la más importante
Rama Accept-Patch en 415 Ya cubierta por la prueba de PATCH
Rama Allow en 405 Ya cubierta por la prueba de DELETE sobre la colección
Campo depuracion fuera de producción Sí: comprobar que no aparece con NODE_ENV=produccion

Prueba del caso más importante:

// pruebas/integracion/errores.test.js
import './../ayudas/entorno-prueba.js';
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import express from 'express';
import { manejadorErrores } from '../../src/middleware/errores.js';
import { asignarTrazaId } from '../../src/middleware/traza.js';
import { asincrono } from '../../src/middleware/asincrono.js';

/**
 * Se monta una aplicación mínima con una ruta que revienta a propósito.
 * Provocar un bug real en la aplicación de verdad sería frágil; aquí se
 * prueba el MIDDLEWARE, que es lo que queremos verificar.
 */
function crearAppQueFalla() {
  const app = express();
  app.use(asignarTrazaId);
  app.get(
    '/explota',
    asincrono(async () => {
      throw new TypeError("Cannot read properties of null (reading 'map')");
    })
  );
  app.use(manejadorErrores);
  return app;
}

describe('errores inesperados', () => {
  const app = crearAppQueFalla();

  it('devuelve 500 error_interno con trazaId y sin filtrar nada', async () => {
    const r = await request(app).get('/explota');

    assert.equal(r.status, 500);
    assert.equal(r.body.error.codigo, 'error_interno');
    assert.equal(r.body.error.mensaje, 'Se ha producido un error inesperado.');
    assert.deepEqual(r.body.error.detalles, []);

    // trazaId presente y correlacionado con la cabecera.
    assert.match(r.body.error.trazaId, /^trz_[0-9a-f]{8}$/);
    assert.equal(r.headers['aroma-traza-id'], r.body.error.trazaId);

    // Y lo esencial: NADA del interior se ha filtrado.
    const texto = JSON.stringify(r.body);
    assert.ok(!texto.includes('TypeError'), 'no debe aparecer el tipo de error');
    assert.ok(!texto.includes('Cannot read properties'), 'no debe aparecer el mensaje interno');
    assert.ok(!texto.includes('.js:'), 'no debe aparecer ninguna traza de pila');
  });

  it('el envoltorio asincrono captura el rechazo: la petición SÍ responde', async () => {
    // Sin asincrono(), esta petición se colgaría y la prueba daría timeout.
    const r = await request(app).get('/explota');
    assert.ok(r.status, 'debe haber respuesta, no un cuelgue');
  });
});

Las tres aserciones negativas del final son el núcleo: comprueban que el consumidor no ve el tipo de excepción, ni el mensaje interno, ni ninguna ruta de fichero. Es una prueba de seguridad, no de funcionalidad, y es de las pocas que se escriben en negativo. Y la segunda prueba verifica el envoltorio asincrono(): si alguien lo quitara, esta prueba fallaría por tiempo de espera agotado en vez de pasar, que es justo la señal que queremos.

Conclusión

El módulo se cierra con la única garantía que vale algo: la implementación se comprueba sola. Tienes pruebas unitarias que verifican que 16,75 € son 1675 céntimos y vuelven a ser 16,75 €, que el mapeador no filtra activo ni version, y que un pedido enviado ofrece devolver y ya no pagar; pruebas de servicio con un repositorio falso inyectado, posibles porque en 03-03 separamos las capas y en 03-05 conservamos el almacén en memoria; y pruebas de integración con Supertest sobre el objeto app, posibles porque en 03-02 no llamamos a listen() ahí, que recorren códigos de estado, cabeceras Location, Link, Allow y Accept-Patch, la forma exacta del cuerpo, los detalles completos de la validación y la matriz de permisos, incluida la prueba del ?clienteId ajeno que es la única capaz de detectar un IDOR. Sabes además qué mide la cobertura y qué no, que las pruebas de contrato contra openapi.yaml son el siguiente escalón, y tienes una lista de verificación aplicable a cualquier API REST, no solo a esta.

Y con ello está terminado el módulo 3. Has partido del contrato en papel del módulo 2 y has construido una API completa: un entorno reproducible con la configuración en el entorno y validada al arrancar; un servidor Express con su cadena de middleware ordenada y el versionado materializado en /v1; tres capas con fronteras reales, un mapeador que concentra las decisiones de representación y el ciclo completo de escritura con sus códigos y cabeceras; validación declarativa que rechaza la entrada estricta y devuelve todos los fallos a la vez; persistencia en SQL con migraciones, sentencias preparadas, transacciones atómicas, concurrencia optimista y paginación por cursor; autenticación con JWT y autorización por rol y por propiedad; un único middleware de errores que nunca filtra el interior del sistema; y una suite de pruebas que protege todo lo anterior.

Lo que tienes es una API correcta. Lo que aún no es, es una API lista para producción. En el módulo 4, Buenas Prácticas y Seguridad, se endurece: repasaremos las buenas prácticas de diseño que separan una API decente de una excelente (04-01); cubriremos las amenazas reales y sus defensas, del OWASP API Security Top 10 a las cabeceras de seguridad (04-02); implementaremos OAuth 2.0 y OpenID Connect para que terceros accedan sin conocer las contraseñas (04-03); pondremos límites de peticiones con 429 y Retry-After para que nadie pueda saturarla (04-04); configuraremos CORS para que la SPA pueda llamarla desde otro dominio sin abrir la puerta a cualquiera (04-05); añadiremos caché HTTP con ETag, Cache-Control y peticiones condicionales, que es donde conflicto_version se reencontrará con If-Match y el 412 (04-06); y sustituiremos los console.log por observabilidad de verdad —logs estructurados, métricas y trazas distribuidas— aprovechando el trazaId que ya emitimos (04-07).

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

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

Módulo 2: Diseño de APIs RESTful

Módulo 3: Desarrollo de APIs RESTful

Módulo 4: Buenas Prácticas y Seguridad

Módulo 5: Herramientas y Frameworks

Módulo 6: Casos de Estudio y Proyectos

© Copyright 2026. Todos los derechos reservados