El pipeline de Reservalia ya construye un artefacto reproducible, pero construir no es lo mismo que funcionar: tsc puede compilar sin quejarse un código que devuelve la respuesta equivocada. Las pruebas automatizadas son lo que convierte el verde del pipeline en una afirmación con contenido. En esta lección veremos qué tipos de prueba existen y cuáles tienen sentido ejecutar en cada pull request y cuáles no, porque meterlo todo es la vía más rápida a un pipeline de 40 minutos que el equipo acaba ignorando. Escribiremos código real con Vitest: una prueba unitaria de la lógica de disponibilidad de citas y una de integración contra el PostgreSQL del services: que levantamos en la 02-02. Hablaremos de cobertura sin convertirla en un objetivo, dedicaremos un apartado entero al mayor destructor de confianza en un pipeline —las pruebas inestables— y cerraremos con la paralelización y con el criterio de qué bloquea exactamente un merge. Lo que no tocaremos aquí son los linters y el análisis estático, que son la lección 02-05.

Contenido

  1. La pirámide de pruebas aplicada al pipeline
  2. Qué se ejecuta en cada pull request y qué no
  3. Una prueba unitaria de la disponibilidad de citas
  4. Una prueba de integración contra PostgreSQL
  5. Cobertura de código: una señal, no un objetivo
  6. Pruebas inestables (flaky) y política de cuarentena
  7. Paralelización y matrix
  8. El job test de Reservalia y qué bloquea el merge
  9. Errores Comunes y Consejos
  10. Ejercicios
  11. Conclusión

  1. La pirámide de pruebas aplicada al pipeline

La pirámide de pruebas dice algo muy simple: muchas pruebas rápidas y baratas abajo, pocas lentas y caras arriba. La razón no es estética, es económica: cada nivel es aproximadamente un orden de magnitud más lento y más frágil que el anterior.

flowchart TD
    E["E2E · pocas decenas · minutos"] --> C["Contrato · decenas · segundos"]
    C --> I["Integración · cientos · segundos"] --> U["Unitarias · miles · milisegundos"]
Tipo Qué verifica Qué necesita Velocidad típica Fragilidad
Unitaria Una función o clase aislada Nada externo 1-10 ms Muy baja
Integración Varias piezas juntas: código + base de datos PostgreSQL real 50-500 ms Baja
Contrato Que api y web siguen entendiéndose Un esquema compartido 10-100 ms Baja
End-to-end (E2E) Un recorrido completo de usuario Todo el sistema desplegado 5-60 s Alta

En Reservalia la traducción es directa. Unitaria: dado un horario de 9:00 a 14:00 con un descanso a mediodía, ¿qué huecos de 30 minutos quedan libres? Integración: al insertar una cita, ¿la restricción de la base de datos impide crear otra encima? Contrato: el tipo Cita que devuelve apps/api, ¿es el que apps/web espera? —en un monorepo con tipos compartidos, buena parte de esto lo hace tsc gratis—. E2E: un cliente entra en la web pública, elige negocio, día y hora, y recibe un correo de confirmación.

  1. Qué se ejecuta en cada pull request y qué no

Es la decisión de diseño más importante del pipeline de pruebas, y se rige por una fórmula sencilla: valor de la información ÷ tiempo que cuesta obtenerla.

Prueba ¿En cada PR? ¿Bloquea el merge? Motivo
Unitarias Segundos; atrapan la mayoría de errores de lógica
Integración Un par de minutos; atrapan lo que las unitarias no ven
Contrato Baratas y evitan romper a la otra aplicación
E2E críticas (2-3 recorridos) Es el flujo que da dinero: reservar una cita
E2E completas (~40 recorridos) No No 25 minutos; se ejecutan de noche sobre main
Rendimiento, carga y seguridad No No Materia de las lecciones 04-04 y 04-03

Reservalia acuerda que el conjunto que bloquea un PR debe caber en 10 minutos, la regla 5 del acuerdo de la 02-01. Los recorridos E2E completos se ejecutan en un workflow aparte, programado con schedule de madrugada: si algo se rompe ahí, aparece a primera hora como incidencia, no como un PR bloqueado.

La trampa del "pongámoslo todo en el PR". Es una decisión que parece prudente y que se paga en confianza: cuando el pipeline tarda 40 minutos, el equipo empieza a fusionar sin esperar, a relanzar sin mirar y a considerar el rojo un ruido de fondo. Un conjunto de pruebas más pequeño que se respeta protege más que uno enorme que se ignora.

  1. Una prueba unitaria de la disponibilidad de citas

La lógica vive en apps/api/src/dominio/agenda.ts, con esta firma —horario es la jornada del negocio, ocupados son citas y descansos, y duracionMin la duración del servicio—:

export interface Intervalo { inicio: string; fin: string }   // "HH:MM"
export function calcularHuecos(
  horario: Intervalo, ocupados: Intervalo[], duracionMin: number,
): Intervalo[] { /* ... */ }

Y esta es la prueba, en apps/api/tests/unidad/agenda.test.ts:

import { describe, it, expect } from 'vitest';
import { calcularHuecos } from '../../src/dominio/agenda';

describe('calcularHuecos', () => {
  const jornada = { inicio: '09:00', fin: '11:00' };

  it('devuelve todos los huecos cuando la agenda está vacía', () => {
    const huecos = calcularHuecos(jornada, [], 30);
    expect(huecos).toHaveLength(4);                      // 9:00, 9:30, 10:00, 10:30
    expect(huecos[0]).toEqual({ inicio: '09:00', fin: '09:30' });
  });

  it('excluye el intervalo de una cita existente', () => {
    const huecos = calcularHuecos(jornada, [{ inicio: '09:30', fin: '10:00' }], 30);
    expect(huecos.map(h => h.inicio)).toEqual(['09:00', '10:00', '10:30']);
  });

  it('no ofrece un hueco que se solape con el descanso', () => {
    const descanso = [{ inicio: '09:45', fin: '10:15' }];   // ← el caso del error real
    expect(calcularHuecos(jornada, descanso, 30).map(h => h.inicio))
      .toEqual(['09:00', '10:30']);
  });

  it('no ofrece un hueco que se salga de la jornada', () => {
    expect(calcularHuecos({ inicio: '09:00', fin: '09:40' }, [], 30))
      .toEqual([{ inicio: '09:00', fin: '09:30' }]);
  });
});

Cuatro cosas que hacen buena a esta prueba:

  • describe agrupa e it describe un comportamiento en lenguaje natural. Cuando falla, el nombre del test ya te dice qué se ha roto sin abrir el código.
  • No hay reloj ni base de datos. Todos los datos son literales del propio test. Por eso tarda milisegundos y no puede ser inestable.
  • El tercer caso es el error real de Diego: un hueco de 30 minutos que empieza a las 9:30 se solapa con un descanso que arranca a las 9:45. Toda corrección de un error debería empezar por una prueba que lo reproduzca; así el pipeline garantiza que no vuelve.
  • Los casos límite están cubiertos: agenda vacía, ocupación parcial, solapamiento y hueco que no cabe. Se ejecuta con npm run test:unidad --workspace apps/api, que por debajo es vitest run tests/unidad.

  1. Una prueba de integración contra PostgreSQL

Las pruebas unitarias no ven las restricciones de la base de datos: que la aplicación calcule bien los huecos no impide que dos peticiones simultáneas creen dos citas solapadas, y eso solo lo garantiza PostgreSQL.

// apps/api/tests/integracion/citas.test.ts
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { Pool } from 'pg';
import { crearCita } from '../../src/rutas/citas';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });   // 1

beforeEach(async () => {
  await pool.query('TRUNCATE citas, negocios RESTART IDENTITY CASCADE'); // 2
  await pool.query(`INSERT INTO negocios (id, nombre, hora_apertura, hora_cierre)
                    VALUES (1, 'Peluquería Sol', '09:00', '14:00')`);
});

afterAll(async () => { await pool.end(); });                             // 3

describe('crearCita', () => {
  it('guarda una cita válida', async () => {
    const cita = await crearCita(pool, {
      negocioId: 1, inicio: '2026-03-02T10:00:00+01:00', duracionMin: 30,
    });
    const { rows } = await pool.query('SELECT * FROM citas WHERE id = $1', [cita.id]);
    expect(rows).toHaveLength(1);
    expect(rows[0].negocio_id).toBe(1);
  });

  it('rechaza una cita que se solapa con otra existente', async () => {
    await crearCita(pool, { negocioId: 1, inicio: '2026-03-02T10:00:00+01:00', duracionMin: 30 });
    await expect(
      crearCita(pool, { negocioId: 1, inicio: '2026-03-02T10:15:00+01:00', duracionMin: 30 }),
    ).rejects.toThrow('solapamiento');                                   // 4
  });
});
  1. DATABASE_URL viene del entorno, no está escrita en el código. En local la pone el docker-compose.yml; en CI, el services: de la 02-02. La prueba es idéntica en ambos sitios. El esquema se crea antes, ejecutando npm run migrate, de modo que el pipeline verifica de paso que las migraciones funcionan (lección 04-06).
  2. beforeEach con TRUNCATE es la clave del aislamiento: cada prueba parte de una base conocida. Sin esto, el orden de ejecución cambia el resultado, y eso es exactamente una prueba inestable.
  3. afterAll cierra el pool. Si no lo haces, el proceso de Vitest no termina y el job se queda colgado hasta el timeout-minutes. (4) El segundo caso solo se puede verificar aquí: el rechazo lo produce una restricción de exclusión de PostgreSQL, no el código de la aplicación.

En local basta con docker compose up -d seguido de npm run test:integracion --workspace apps/api.

  1. Cobertura de código: una señal, no un objetivo

La cobertura mide qué porcentaje del código se ha ejecutado durante las pruebas. Se genera con npm run test --workspace apps/api -- --coverage y se configura así en apps/api/vitest.config.ts:

coverage: {
  provider: 'v8',
  reporter: ['text', 'lcov', 'json-summary'],              // consola, herramientas, resumen
  exclude: ['**/migraciones/**', '**/*.d.ts', 'tests/**'], // lo que no aporta señal
  thresholds: { lines: 70, functions: 70, branches: 60 },
}

Para publicarla como resumen del job, $GITHUB_STEP_SUMMARY es un fichero especial: lo que escribas en él aparece en la página del workflow, sin ninguna herramienta externa.

      - name: Publicar resumen de cobertura
        if: always()                                        # aunque alguna prueba falle
        run: |
          echo "### Cobertura de apps/api" >> $GITHUB_STEP_SUMMARY
          npx nyc report --reporter=text-summary >> $GITHUB_STEP_SUMMARY

Ahora la parte incómoda. La cobertura mide lo que se ejecuta, no lo que se comprueba. Una prueba como it('no rompe', () => { calcularHuecos(jornada, [], 30); }) —sin un solo expect— da el 100 % de cobertura de esa función sin verificar absolutamente nada.

Por eso el umbral es una señal: sirve para detectar que un módulo nuevo ha entrado sin ninguna prueba, no para certificar calidad. Cómo usarlo bien:

  • Fija el umbral en el nivel actual, no en un ideal. Si hoy estás en el 68 %, pon 68 y súbelo cuando lo superes de forma natural. Un umbral inalcanzable se acaba desactivando.
  • Vigila la cobertura del código nuevo, no la global —es la idea del quality gate de "nuevo código limpio" de la 02-05— y excluye lo que no aporta: migraciones, configuración, tipos.
  • Nunca conviertas la cobertura en un objetivo de equipo. Es un caso de manual de la ley de Goodhart (lección 01-05): en cuanto se premia el número, aparecen pruebas sin aserciones que lo suben sin verificar nada.

  1. Pruebas inestables (flaky) y política de cuarentena

Una prueba inestable es la que, sin cambiar el código, unas veces pasa y otras falla. Es el problema más corrosivo de un pipeline, porque destruye el significado del rojo: si el rojo puede ser "mala suerte", nadie vuelve a investigarlo.

6.1. Por qué aparecen

Causa Ejemplo en Reservalia Cómo se arregla
Tiempo real Una prueba usa new Date() y falla a medianoche o en otra zona horaria Inyectar la fecha o congelar el reloj
Orden de ejecución Una prueba deja citas que otra encuentra TRUNCATE en beforeEach
Concurrencia Dos pruebas en paralelo comparten la misma base Base o esquema por proceso
Esperas fijas await sleep(500) confiando en que basta Esperar a la condición, no al reloj
Recursos externos Una prueba llama a un servicio real o pide el puerto 3000 Doble de prueba, puerto dinámico

6.2. Cómo detectarlas y qué hacer con ellas

La forma más simple es repetir: npx vitest run tests/integracion/citas.test.ts --repeat 20 delata una prueba que falla una de cada veinte veces. En el pipeline, un workflow nocturno que ejecuta toda la suite varias veces sobre el mismo commit encuentra las inestables antes de que las encuentre un compañero a las seis de la tarde; registrar cada fallo (prueba, fecha, commit) permite ver cuáles reinciden.

La política de cuarentena de Reservalia. Cuando se detecta una prueba inestable, el mismo día: se marca como omitida con referencia explícita a la incidencia —it.skip('envía el recordatorio 24 h antes [INESTABLE · RES-412]', ...)—; se abre una incidencia con el enlace a la ejecución que falló y a la que pasó; se asigna a alguien con fecha límite, porque sin fecha la cuarentena se convierte en un cementerio; y si en dos semanas nadie la arregla, se borra, ya que una prueba desactivada indefinidamente da una falsa sensación de cobertura.

Y reintentar a ciegas es una trampa. Muchas herramientas ofrecen retry: 3. Es tentador y es un error, por tres motivos: oculta errores reales de concurrencia que también ocurrirán en producción, con clientes de verdad; enmascara el deterioro, porque una prueba que falla 2 de cada 3 veces sigue pasando el pipeline; y castiga el tiempo, ya que los reintentos multiplican la duración de los peores casos. Si aun así los necesitas como medida temporal, que sea con métrica: registra cuántas pruebas necesitaron reintento y trata ese número como deuda a reducir.

  1. Paralelización y matrix

Hay dos formas de acelerar, y no son lo mismo. El paralelismo dentro del job lo hace Vitest por defecto, ejecutando ficheros en varios procesos: es gratis para las unitarias, pero para las de integración exige que cada proceso tenga su propio espacio de datos, o volverás al problema del apartado anterior.

El reparto entre jobs (sharding) divide la suite en trozos que corren en runners distintos. Cada job paga su propio arranque (checkout + npm ci), así que solo compensa cuando la suite dura varios minutos:

  test-unidad:
    runs-on: ubuntu-22.04
    strategy:
      fail-fast: false                     # que un shard rojo no cancele los demás
      matrix:
        shard: [1, 2, 3]                   # → tres jobs en paralelo
    steps:
      # ... checkout, setup-node y npm ci ...
      - run: npx vitest run tests/unidad --shard=${{ matrix.shard }}/3

matrix para varias versiones de Node. El mismo mecanismo sirve para probar en varios entornos a la vez: basta con matrix: { node: ['20.11.0', '22.4.0'] } y pasar node-version: ${{ matrix.node }} a setup-node. Reservalia fija Node 20.11.0, así que hoy no lo necesita; sí lo necesitaría una librería que declare soportar varias versiones. Cuidado con el crecimiento combinatorio: 3 versiones × 3 sistemas son 9 jobs, y 8 de ellos no te dirán nada nuevo.

  1. El job test de Reservalia y qué bloquea el merge

Reemplazamos el job test provisional de la 02-02 por su versión definitiva, ordenada para dar feedback rápido:

  test:
    name: Pruebas
    runs-on: ubuntu-22.04
    timeout-minutes: 15
    services:
      postgres:                          # el mismo bloque de la lección 02-02,
        image: postgres:16.3             # con su healthcheck de pg_isready
        env: { POSTGRES_USER: reservalia, POSTGRES_PASSWORD: ci, POSTGRES_DB: reservalia_test }
        ports: ['5432:5432']
        options: >-
          --health-cmd "pg_isready -U reservalia -d reservalia_test"
          --health-interval 5s --health-timeout 3s --health-retries 10
    env:
      TZ: Europe/Madrid
      DATABASE_URL: postgres://reservalia:ci@localhost:5432/reservalia_test
    steps:
      # ... checkout, setup-node y npm ci ...
      - name: Pruebas unitarias          # ~40 s · corta antes si algo evidente falla
        run: npm run test:unidad --workspaces --if-present

      - name: Migraciones sobre la base de pruebas
        run: npm run migrate --workspace apps/api

      - name: Pruebas de integración     # ~2 min
        run: npm run test:integracion --workspace apps/api

      - name: Cobertura
        if: always()
        run: npm run test --workspace apps/api -- --coverage

El orden unitarias → migraciones → integración no es casual: lo barato primero. Si una unitaria falla, el job se detiene en 40 segundos en lugar de en tres minutos. Qué bloquea el merge en Reservalia: el check test completo (unitarias, migraciones e integración), el check calidad de la próxima lección y el check build. Qué no bloquea: la cobertura por debajo del umbral —se informa en el resumen y se comenta en la revisión— y la suite E2E completa nocturna. La configuración técnica de esos checks obligatorios es la lección 02-07.

Errores Comunes y Consejos

Error 1: pruebas que dependen del reloj del sistema. new Date() dentro de la lógica hace imposible probar un lunes lo que solo ocurre un sábado. Pasa la fecha como parámetro: además de hacer la prueba estable, mejora el diseño. Error 2: no aislar el estado entre pruebas de integración; sin TRUNCATE en beforeEach, las pruebas se contaminan y el orden decide el resultado, que es la fábrica número uno de inestabilidad.

Error 3: convertir la cobertura en un objetivo. El resultado predecible son pruebas sin aserciones que suben el número sin verificar nada. Error 4: meter las E2E completas en cada PR, la forma más rápida de llegar a los 40 minutos y perder la confianza del equipo. Error 5: normalizar el reintento, porque retry: 3 esconde problemas de concurrencia que sí aparecerán en producción, donde no hay reintentos.

Consejo 1: cada error corregido empieza por una prueba que lo reproduce. Es la mejor fuente de pruebas útiles que existe, porque cubre exactamente lo que el sistema ya demostró no saber hacer. Consejo 2: cuando una prueba falle, léela antes que el código —muchas veces el fallo está en la prueba— y mide el tiempo de tus pruebas: vitest --reporter=verbose te dirá cuáles son las cinco más lentas, y arreglar esas cinco suele reducir a la mitad el tiempo total.

Ejercicios

Ejercicio 1

Clasifica cada prueba (unitaria, integración, contrato o E2E) y di si debería bloquear un PR:

  1. Que calcularHuecos respeta un descanso a mediodía.
  2. Que un cliente puede reservar desde la web pública y recibe el correo.
  3. Que la restricción de la base de datos impide dos citas solapadas.
  4. Que el JSON de GET /citas/:id encaja con el tipo Cita de tipos-compartidos.
  5. Que la API responde en menos de 200 ms con 500 peticiones por segundo.

Ejercicio 2

Esta prueba falla en CI aproximadamente una vez de cada cinco, siempre por la noche. Identifica dos problemas y reescríbela.

it('crea la cita para mañana', async () => {
  const manana = new Date(Date.now() + 24 * 60 * 60 * 1000);
  await crearCita(pool, { negocioId: 1, inicio: manana.toISOString(), duracionMin: 30 });
  const { rows } = await pool.query('SELECT * FROM citas');
  expect(rows).toHaveLength(1);
});

Ejercicio 3

El equipo propone: "subamos el umbral de cobertura al 95 % y así garantizamos la calidad". Da tres argumentos técnicos en contra y una alternativa concreta.

Soluciones

Solución 1. (1) Unitaria, bloquea: milisegundos, lógica pura. (2) E2E, bloquea solo si es uno de los 2-3 recorridos críticos —lo es: reservar una cita es el flujo que genera ingresos; el resto de E2E van de noche—. (3) Integración, bloquea: solo la base de datos puede garantizarlo. (4) Contrato, bloquea: es barata y evita romper apps/web. (5) Rendimiento, no bloquea: lenta y ruidosa en un runner compartido, y es materia de la 04-04.

Solución 2. Los dos problemas: (a) usa Date.now(), así que a las 23:30 de Madrid "mañana" cae en otro día en UTC y el cálculo se desplaza —de ahí el "siempre por la noche"—; (b) no aísla el estado, porque SELECT * FROM citas cuenta todas las filas, incluidas las que dejen otras pruebas, y toHaveLength(1) acaba dependiendo del orden de ejecución.

beforeEach(async () => pool.query('TRUNCATE citas RESTART IDENTITY CASCADE'));

it('crea la cita para el día indicado', async () => {
  const inicio = '2026-03-02T10:00:00+01:00';          // fecha fija y explícita
  const cita = await crearCita(pool, { negocioId: 1, inicio, duracionMin: 30 });
  const { rows } = await pool.query('SELECT * FROM citas WHERE id = $1', [cita.id]);
  expect(rows).toHaveLength(1);                        // consulta acotada a esta cita
});

Y en el pipeline, TZ: Europe/Madrid elimina la clase entera de problemas horarios.

Solución 3. Tres argumentos: (1) la cobertura mide ejecución, no verificación, así que se puede llegar al 95 % con pruebas sin una sola aserción; (2) el último tramo, del 80 % al 95 %, suele consistir en manejadores de error y ramas defensivas cuyo coste de prueba es alto y su valor bajo, y ese tiempo no se dedica a probar bien la lógica de negocio; (3) un umbral inalcanzable acaba desactivado o esquivado con exclusiones, con lo que se pierde también la señal que sí servía. Alternativa: fijar el umbral global en el valor actual (para que no baje) y aplicar un quality gate sobre el código nuevo —por ejemplo, 80 % de cobertura en las líneas que el PR añade o modifica—, junto con la exigencia de que toda corrección de un error venga acompañada de la prueba que lo reproduce.

Conclusión

Reservalia ya sabe si su código funciona, y lo sabe rápido:

  • La pirámide de pruebas ordena el esfuerzo: miles de unitarias en milisegundos, cientos de integración con PostgreSQL real, unas decenas de contrato y muy pocas E2E. Y no todo va en cada PR: unitarias, integración, contrato y dos o tres E2E críticas bloquean el merge; la suite E2E completa, el rendimiento y la seguridad se ejecutan aparte, con el criterio de que el conjunto bloqueante quepa en 10 minutos.
  • Las pruebas unitarias de calcularHuecos no tocan reloj ni base de datos, y una de ellas reproduce el error real del descanso a mediodía. Las de integración usan la DATABASE_URL del entorno, se aíslan con TRUNCATE en beforeEach y verifican lo único que el código no puede garantizar por sí solo: la restricción de solapamiento de la base de datos.
  • La cobertura se genera con --coverage, se publica en $GITHUB_STEP_SUMMARY y se interpreta como señal: umbral realista, atención al código nuevo y nunca como objetivo de equipo.
  • Las pruebas inestables tienen causas identificables —tiempo, orden, concurrencia, esperas fijas, recursos externos—, se detectan repitiendo la ejecución y se gestionan con una cuarentena con responsable y fecha límite. Reintentar a ciegas oculta problemas que sí ocurrirán en producción.
  • La paralelización por shards y la matrix de versiones aceleran, pero cada job paga su propio arranque. Y el job test definitivo ordena el trabajo de barato a caro —unitarias, migraciones, integración, cobertura— sobre el services: de PostgreSQL 16.3 y con TZ: Europe/Madrid.

Nos queda una familia entera de problemas que ninguna prueba detecta: código que funciona pero es incoherente, ilegible o innecesariamente complicado. En la siguiente lección, Calidad de Código y Análisis Estático, veremos por qué eso pertenece al pipeline y no a la revisión humana, en qué se diferencian exactamente Prettier, ESLint y tsc --noEmit con ejemplos de lo que cada uno detecta y los otros no, qué es un quality gate y por qué el criterio debe ser "nuevo código limpio" en lugar de arreglar toda la deuda de golpe. Y añadiremos al ci.yml el job calidad.

Curso de CI/CD: Integración y Despliegue Continuo

Módulo 1: Introducción a CI/CD

Módulo 2: Integración Continua (CI)

Módulo 3: Despliegue Continuo (CD)

Módulo 4: Prácticas Avanzadas de CI/CD

Módulo 5: Implementación de CI/CD en Proyectos Reales

Módulo 6: Herramientas y Tecnologías

Módulo 7: Ejercicios Prácticos

Módulo 8: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados