El pipeline de la 07-01 está en verde, pero su señal es débil: siete pruebas unitarias sobre una función pura no dicen absolutamente nada sobre si el endpoint /api/huecos responde bien, si el servidor arranca, o si la persistencia guarda lo que dice guardar. Un pipeline verde con pruebas insuficientes se ve exactamente igual que uno con pruebas buenas, y esa es su peculiar peligrosidad. En este laboratorio vas a convertir esa señal débil en una señal fuerte: ampliarás Mini-Reservalia con una capa de persistencia real, escribirás las tres capas de la pirámide sobre ella, medirás la cobertura y la publicarás en el resumen del run, pondrás un umbral que rompa el build, ejecutarás todo en una matriz de versiones de Node y en dos shards paralelos para ver bajar el tiempo, y —lo más instructivo de la lección— fabricarás una prueba flaky a propósito para verla fallar de forma intermitente y aplicarle la política de cuarentena de la 02-04.

Ninguna de estas piezas es opcional en un proyecto real. Las tres capas te dicen qué está roto y a qué nivel; la cobertura te dice dónde no estás mirando; la matriz te protege del "funciona en mi versión"; el sharding es lo que hace que la suite siga siendo tolerable cuando pase de 7 pruebas a 700; y la política de flaky es lo único que impide que el equipo aprenda a ignorar el rojo.

Contenido

  1. Objetivo, requisitos previos y punto de partida
  2. La capa de persistencia: interfaz, memoria y SQLite
  3. El servidor sobre el repositorio
  4. Capa 1: pruebas unitarias con casos límite de verdad
  5. Capa 2: pruebas de integración contra la persistencia real
  6. Capa 3: la prueba end-to-end contra el proceso arrancado
  7. Cobertura: medirla, publicarla y ponerle un umbral
  8. Matriz de versiones de Node
  9. Sharding: partir la suite en dos
  10. El ci.yml completo
  11. Fabricar una prueba flaky y aplicarle la cuarentena
  12. Informes como artefacto y anotaciones en el PR
  13. Verificación final
  14. Errores Comunes y Consejos
  15. Ejercicios
  16. Conclusión

  1. Objetivo, requisitos previos y punto de partida

Objetivo. Al terminar tendrás una suite de tres capas sobre Mini-Reservalia que se ejecuta en cuatro jobs paralelos (2 versiones de Node × 2 shards), con una cobertura medida, publicada y con umbral que rompe el build, y una política de cuarentena aplicada a una prueba flaky real.

Requisitos previos. Haber completado la 07-01: el repositorio mini-reservalia en GitHub, con ci.yml de tres jobs, caché, main protegida y el badge en el README.

Punto de partida.

mini-reservalia/
├── .github/workflows/ci.yml
├── src/disponibilidad.js
├── src/servidor.js
├── scripts/build.js
├── test/disponibilidad.test.js
├── eslint.config.js
├── package.json
└── package-lock.json

Trabaja en una rama desde el principio, porque main está protegida:

git checkout main && git pull
git checkout -b pruebas-automatizadas

  1. La capa de persistencia: interfaz, memoria y SQLite

Hasta ahora la agenda era un Map en el propio servidor.js. Eso hace imposible probar la persistencia y confunde dos responsabilidades. Vamos a extraer un repositorio con dos implementaciones intercambiables: una en memoria (rápida, para las unitarias) y una sobre SQLite (real, para las de integración).

Por qué SQLite y no PostgreSQL como camino principal: SQLite es un fichero, no necesita un servicio, arranca en microsegundos y funciona igual en tu portátil y en el runner. La variante con PostgreSQL —que es lo que usa el Reservalia real— va en una nota al final del apartado.

npm install better-sqlite3

Es la primera dependencia de producción del proyecto. A partir de ahora npm ci instala un módulo nativo, lo que hará la caché de la 07-01 bastante más rentable.

Nota. Node 22.5+ incluye node:sqlite de serie (aún experimental). Si tu proyecto solo va a correr en Node 22+, puedes evitarte la dependencia. Aquí usamos better-sqlite3 porque nuestra matriz incluye Node 20 y porque un módulo nativo es un caso más realista para hablar de cachés y de auditoría de dependencias en la 07-05.

2.1 src/repositorio.js — la interfaz y la implementación en memoria

// src/repositorio.js
// Contrato de persistencia de Mini-Reservalia + implementacion en memoria.
//
// Todas las implementaciones exponen la misma interfaz:
//   listarCitas(fecha) -> {inicio, fin, cliente}[]  ordenadas por inicio
//   crearCita({fecha, inicio, fin, cliente}) -> cita creada (con id)
//   borrarTodo() -> void
//   cerrar() -> void

const PATRON_FECHA = /^\d{4}-\d{2}-\d{2}$/;
const PATRON_HORA = /^([01]\d|2[0-3]):([0-5]\d)$/;

/** Valida y normaliza una cita antes de guardarla. Lanza si es invalida. */
export function validarCita(cita) {
  if (!PATRON_FECHA.test(cita?.fecha ?? '')) {
    throw new TypeError('fecha invalida: se espera YYYY-MM-DD');
  }
  if (!PATRON_HORA.test(cita?.inicio ?? '') || !PATRON_HORA.test(cita?.fin ?? '')) {
    throw new TypeError('inicio y fin deben tener formato HH:MM');
  }
  if (cita.fin <= cita.inicio) {
    throw new RangeError('el fin debe ser posterior al inicio');
  }
  return {
    fecha: cita.fecha,
    inicio: cita.inicio,
    fin: cita.fin,
    cliente: String(cita.cliente ?? 'anonimo').slice(0, 80),
  };
}

export class RepositorioMemoria {
  #porFecha = new Map();
  #siguienteId = 1;

  listarCitas(fecha) {
    const citas = this.#porFecha.get(fecha) ?? [];
    return [...citas].sort((a, b) => a.inicio.localeCompare(b.inicio));
  }

  crearCita(datos) {
    const cita = { id: this.#siguienteId++, ...validarCita(datos) };
    const lista = this.#porFecha.get(cita.fecha) ?? [];
    lista.push(cita);
    this.#porFecha.set(cita.fecha, lista);
    return cita;
  }

  borrarTodo() {
    this.#porFecha.clear();
    this.#siguienteId = 1;
  }

  cerrar() {
    /* nada que cerrar */
  }
}

2.2 src/repositorio-sqlite.js

// src/repositorio-sqlite.js
// Implementacion del mismo contrato sobre SQLite.

import Database from 'better-sqlite3';
import { validarCita } from './repositorio.js';

const ESQUEMA = `
  CREATE TABLE IF NOT EXISTS citas (
    id      INTEGER PRIMARY KEY AUTOINCREMENT,
    fecha   TEXT NOT NULL,
    inicio  TEXT NOT NULL,
    fin     TEXT NOT NULL,
    cliente TEXT NOT NULL DEFAULT 'anonimo'
  );
  CREATE INDEX IF NOT EXISTS idx_citas_fecha ON citas(fecha);
`;

export class RepositorioSqlite {
  #db;
  #stmtListar;
  #stmtInsertar;

  /** @param {string} ruta ':memory:' para una base efimera, o un fichero. */
  constructor(ruta = ':memory:') {
    this.#db = new Database(ruta);
    this.#db.pragma('journal_mode = WAL');
    this.#db.exec(ESQUEMA); // migracion minima; la 04-06 explica por que en real esto va versionado
    this.#stmtListar = this.#db.prepare(
      'SELECT id, fecha, inicio, fin, cliente FROM citas WHERE fecha = ? ORDER BY inicio',
    );
    this.#stmtInsertar = this.#db.prepare(
      'INSERT INTO citas (fecha, inicio, fin, cliente) VALUES (@fecha, @inicio, @fin, @cliente)',
    );
  }

  listarCitas(fecha) {
    return this.#stmtListar.all(fecha);
  }

  crearCita(datos) {
    const cita = validarCita(datos);
    const info = this.#stmtInsertar.run(cita);
    return { id: Number(info.lastInsertRowid), ...cita };
  }

  borrarTodo() {
    this.#db.exec('DELETE FROM citas');
  }

  cerrar() {
    this.#db.close();
  }
}

/**
 * Fabrica del repositorio segun la URL de conexion.
 * Esto es lo que permite que el mismo binario corra con memoria en las
 * pruebas rapidas y con SQLite en produccion, sin ninguna rama de "si estamos
 * en test". La configuracion viene del entorno (12-factor), como en la 03-02.
 */
export async function crearRepositorio(url = process.env.BASE_DATOS ?? 'memoria:') {
  if (url === 'memoria:') {
    const { RepositorioMemoria } = await import('./repositorio.js');
    return new RepositorioMemoria();
  }
  if (url.startsWith('sqlite:')) {
    return new RepositorioSqlite(url.slice('sqlite:'.length));
  }
  throw new Error(`Origen de datos no soportado: ${url}`);
}

Dos decisiones que valen para cualquier proyecto:

  • La validación vive en un solo sitio (validarCita), compartida por ambas implementaciones. Si estuviera duplicada, las dos implementaciones se comportarían distinto ante datos malos y las pruebas de integración pasarían mientras producción falla.
  • prepare fuera de los métodos. Las sentencias preparadas evitan la concatenación de SQL. Esto no es solo rendimiento: es lo que hace que la inyección SQL sea imposible por construcción. En la 07-05 introduciremos una consulta mal escrita a propósito para ver a CodeQL detectarla.

Equivalente real en Reservalia y variante PostgreSQL. Reservalia usa PostgreSQL sobre RDS. En CI, el ci.yml de la 02-02 levanta un PostgreSQL como servicio del runner:

  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: prueba
          POSTGRES_DB: reservalia_test
        ports: ['5432:5432']
        # Sin este health check, los pasos arrancan antes de que
        # Postgres acepte conexiones: fallo intermitente clasico.
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s --health-timeout 5s --health-retries 10
    env:
      BASE_DATOS: postgres://postgres:prueba@localhost:5432/reservalia_test
    steps:
      - uses: actions/checkout@v4
      # ... npm ci, migraciones, npm test

Si quieres hacerlo así, escribe un RepositorioPostgres con la misma interfaz y todo lo demás de esta lección funciona igual. El coste es +20-30 s por job y un servicio más que puede fallar; por eso aquí el camino principal es SQLite.

  1. El servidor sobre el repositorio

Sustituye la agenda en memoria de src/servidor.js por el repositorio inyectado, y añade POST /api/citas para poder crear datos desde las pruebas de integración.

// src/servidor.js  (version 07-02)
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import { calcularHuecos } from './disponibilidad.js';
import { crearRepositorio } from './repositorio-sqlite.js';

export const VERSION = process.env.APP_VERSION ?? 'dev';
export const PUERTO = Number(process.env.PORT ?? 3000);

export const HORARIO_POR_DEFECTO = [
  { inicio: '09:00', fin: '14:00' },
  { inicio: '16:00', fin: '20:00' },
];

const PATRON_FECHA = /^\d{4}-\d{2}-\d{2}$/;

function responderJson(res, codigo, cuerpo) {
  const texto = JSON.stringify(cuerpo);
  res.writeHead(codigo, {
    'content-type': 'application/json; charset=utf-8',
    'content-length': Buffer.byteLength(texto),
  });
  res.end(texto);
}

async function leerCuerpo(req, maximoBytes = 8192) {
  const trozos = [];
  let total = 0;
  for await (const trozo of req) {
    total += trozo.length;
    if (total > maximoBytes) throw new RangeError('cuerpo demasiado grande');
    trozos.push(trozo);
  }
  if (total === 0) return {};
  return JSON.parse(Buffer.concat(trozos).toString('utf8'));
}

export function crearServidor({ repositorio, horario = HORARIO_POR_DEFECTO } = {}) {
  if (!repositorio) throw new Error('crearServidor requiere un repositorio');

  return http.createServer(async (req, res) => {
    const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);

    try {
      if (req.method === 'GET' && url.pathname === '/salud') {
        return responderJson(res, 200, {
          estado: 'ok',
          version: VERSION,
          activoSeg: Math.round(process.uptime()),
        });
      }

      if (req.method === 'GET' && url.pathname === '/api/huecos') {
        const fecha = url.searchParams.get('fecha');
        const duracion = Number(url.searchParams.get('duracion') ?? 30);
        if (!fecha || !PATRON_FECHA.test(fecha)) {
          return responderJson(res, 400, { error: 'Parametro "fecha" obligatorio (YYYY-MM-DD)' });
        }
        if (!Number.isInteger(duracion) || duracion <= 0) {
          return responderJson(res, 400, { error: 'Parametro "duracion" invalido' });
        }
        const citas = repositorio.listarCitas(fecha);
        const huecos = calcularHuecos(horario, citas, duracion);
        return responderJson(res, 200, { fecha, duracion, total: huecos.length, huecos });
      }

      if (req.method === 'POST' && url.pathname === '/api/citas') {
        const cuerpo = await leerCuerpo(req);
        const cita = repositorio.crearCita(cuerpo);
        return responderJson(res, 201, cita);
      }

      return responderJson(res, 404, { error: 'Ruta no encontrada' });
    } catch (error) {
      // Errores de validacion -> 400; el resto -> 500. Sin filtrar el stack.
      const esValidacion = error instanceof TypeError || error instanceof RangeError || error instanceof SyntaxError;
      if (!esValidacion) console.error('Error no controlado:', error);
      return responderJson(res, esValidacion ? 400 : 500, {
        error: esValidacion ? error.message : 'Error interno',
      });
    }
  });
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const repositorio = await crearRepositorio();
  crearServidor({ repositorio }).listen(PUERTO, () => {
    console.log(`Mini-Reservalia ${VERSION} escuchando en http://localhost:${PUERTO}`);
  });
}

Prueba en local antes de seguir:

BASE_DATOS='sqlite:/tmp/mini.db' npm start &
curl -s -X POST localhost:3000/api/citas \
  -H 'content-type: application/json' \
  -d '{"fecha":"2026-03-02","inicio":"10:00","fin":"10:30","cliente":"Ana"}'
# {"id":1,"fecha":"2026-03-02","inicio":"10:00","fin":"10:30","cliente":"Ana"}

curl -s "localhost:3000/api/huecos?fecha=2026-03-02&duracion=30" | head -c 200
# {"fecha":"2026-03-02","duracion":30,"total":17,"huecos":[{"inicio":"09:00",...
kill %1

  1. Capa 1: pruebas unitarias con casos límite de verdad

La pirámide de la 02-04, aplicada a este proyecto:

Capa Qué prueba Cuántas Velocidad Fichero
Unitaria calcularHuecos, validarCita — lógica pura, sin E/S Muchas µs test/disponibilidad.test.js
Integración Repositorio SQLite real; rutas HTTP contra ese repositorio Algunas ms test/repositorio.test.js, test/api.test.js
End-to-end El proceso real arrancado, por HTTP, sin trucos Muy pocas s test/e2e.test.js

Amplía test/disponibilidad.test.js con los casos límite que sí duelen en producción:

// test/disponibilidad.test.js  (ampliacion 07-02)
import test, { describe } from 'node:test';
import assert from 'node:assert/strict';
import { calcularHuecos, aMinutos, aHora } from '../src/disponibilidad.js';

const MANANA = { inicio: '09:00', fin: '12:00' };
const PARTIDO = [
  { inicio: '09:00', fin: '14:00' },
  { inicio: '16:00', fin: '20:00' },
];

describe('conversiones de hora', () => {
  test('aMinutos convierte horas validas', () => {
    assert.equal(aMinutos('00:00'), 0);
    assert.equal(aMinutos('09:30'), 570);
    assert.equal(aMinutos('23:59'), 1439);
  });

  test('aMinutos rechaza formatos invalidos', () => {
    for (const malo of ['9:00', '25:00', '09:60', '', '0900', 900, null]) {
      assert.throws(() => aMinutos(malo), TypeError, `deberia rechazar ${JSON.stringify(malo)}`);
    }
  });

  test('aHora es la inversa de aMinutos', () => {
    for (const hora of ['00:00', '07:05', '13:45', '23:59']) {
      assert.equal(aHora(aMinutos(hora)), hora);
    }
  });
});

describe('calcularHuecos: casos base', () => {
  test('un dia sin citas se trocea entero', () => {
    assert.deepEqual(calcularHuecos(MANANA, [], 60), [
      { inicio: '09:00', fin: '10:00' },
      { inicio: '10:00', fin: '11:00' },
      { inicio: '11:00', fin: '12:00' },
    ]);
  });

  test('una cita parte el dia en dos bloques', () => {
    assert.deepEqual(calcularHuecos(MANANA, [{ inicio: '10:00', fin: '11:00' }], 60), [
      { inicio: '09:00', fin: '10:00' },
      { inicio: '11:00', fin: '12:00' },
    ]);
  });

  test('el resto sobrante no genera un hueco corto', () => {
    const huecos = calcularHuecos(MANANA, [], 50);
    assert.equal(huecos.length, 3);
    assert.equal(huecos.at(-1).fin, '11:30');
  });
});

describe('calcularHuecos: casos limite', () => {
  test('dos citas SOLAPADAS se fusionan y no dejan un hueco fantasma', () => {
    // 10:00-11:00 y 10:30-11:30 -> ocupado 10:00-11:30, no hay hueco entre ellas.
    const huecos = calcularHuecos({ inicio: '09:00', fin: '13:00' }, [
      { inicio: '10:00', fin: '11:00' },
      { inicio: '10:30', fin: '11:30' },
    ], 30);
    assert.deepEqual(huecos.map((h) => h.inicio), ['09:00', '09:30', '11:30', '12:00', '12:30']);
  });

  test('dos citas CONSECUTIVAS que se tocan no dejan un hueco de duracion cero', () => {
    const huecos = calcularHuecos({ inicio: '09:00', fin: '12:00' }, [
      { inicio: '10:00', fin: '10:30' },
      { inicio: '10:30', fin: '11:00' },
    ], 30);
    assert.deepEqual(huecos.map((h) => h.inicio), ['09:00', '09:30', '11:00', '11:30']);
  });

  test('citas DESORDENADAS dan el mismo resultado que ordenadas', () => {
    const desordenadas = [{ inicio: '11:00', fin: '11:30' }, { inicio: '09:30', fin: '10:00' }];
    const ordenadas = [...desordenadas].sort((a, b) => a.inicio.localeCompare(b.inicio));
    assert.deepEqual(calcularHuecos(MANANA, desordenadas, 30), calcularHuecos(MANANA, ordenadas, 30));
  });

  test('una cita que CRUZA EL CIERRE recorta el tramo sin desbordarlo', () => {
    // Cierre a las 14:00, cita 13:45-14:30. Ningun hueco puede pasar de 13:45.
    const huecos = calcularHuecos({ inicio: '09:00', fin: '14:00' }, [{ inicio: '13:45', fin: '14:30' }], 30);
    assert.ok(huecos.every((h) => h.fin <= '13:45'), `hueco fuera de horario: ${JSON.stringify(huecos.at(-1))}`);
    assert.equal(huecos.at(-1).fin, '13:30');
  });

  test('una cita ANTERIOR A LA APERTURA no afecta', () => {
    const huecos = calcularHuecos(MANANA, [{ inicio: '07:00', fin: '08:00' }], 60);
    assert.equal(huecos.length, 3);
  });

  test('una cita que CUBRE TODO el tramo deja el dia sin huecos', () => {
    assert.deepEqual(calcularHuecos(MANANA, [{ inicio: '08:00', fin: '15:00' }], 30), []);
  });

  test('HORARIO PARTIDO: ningun hueco cruza la pausa de comida', () => {
    const huecos = calcularHuecos(PARTIDO, [], 60);
    assert.ok(!huecos.some((h) => h.inicio < '14:00' && h.fin > '14:00'), 'hay un hueco que cruza la pausa');
    assert.equal(huecos.length, 5 + 4);
  });

  test('HORARIO PARTIDO con una cita en cada tramo', () => {
    const huecos = calcularHuecos(PARTIDO, [
      { inicio: '10:00', fin: '11:00' },
      { inicio: '17:00', fin: '18:00' },
    ], 60);
    assert.deepEqual(huecos.map((h) => h.inicio), ['09:00', '11:00', '12:00', '13:00', '16:00', '18:00', '19:00']);
  });

  test('una duracion mayor que el tramo no produce huecos', () => {
    assert.deepEqual(calcularHuecos(MANANA, [], 240), []);
  });

  test('parametros invalidos lanzan, no devuelven vacio', () => {
    assert.throws(() => calcularHuecos(MANANA, [], 0), RangeError);
    assert.throws(() => calcularHuecos(MANANA, [], 12.5), RangeError);
    assert.throws(() => calcularHuecos({ inicio: '14:00', fin: '09:00' }, [], 30), RangeError);
  });
});

Fíjate en el patrón de la prueba del cierre: assert.ok(huecos.every(...)) con un mensaje que incluye el valor que falló. Un assert.ok(condicion) sin mensaje produce AssertionError: The expression evaluated to a falsy value, que no te dice nada; con mensaje, el log del pipeline te da el diagnóstico sin tener que reproducir en local. Es una diferencia de dos minutos de escritura y de veinte de depuración.

npm test
# ℹ tests 17 / pass 17 / fail 0

  1. Capa 2: pruebas de integración contra la persistencia real

Dos ficheros: uno para el repositorio y otro para las rutas HTTP.

5.1 test/repositorio.test.js

Lo interesante aquí es que la misma batería se ejecuta contra las dos implementaciones. Eso es un test de contrato: garantiza que memoria y SQLite son intercambiables, que es justo lo que asumimos al inyectar una u otra.

// test/repositorio.test.js
import test, { describe, beforeEach, after } from 'node:test';
import assert from 'node:assert/strict';
import { RepositorioMemoria } from '../src/repositorio.js';
import { RepositorioSqlite } from '../src/repositorio-sqlite.js';

const IMPLEMENTACIONES = [
  ['memoria', () => new RepositorioMemoria()],
  ['sqlite', () => new RepositorioSqlite(':memory:')],
];

for (const [nombre, fabrica] of IMPLEMENTACIONES) {
  describe(`contrato del repositorio: ${nombre}`, () => {
    let repo;

    beforeEach(() => {
      repo?.cerrar();
      repo = fabrica();
    });

    after(() => repo?.cerrar());

    test('un dia sin citas devuelve lista vacia', () => {
      assert.deepEqual(repo.listarCitas('2026-03-02'), []);
    });

    test('crearCita devuelve la cita con un id numerico', () => {
      const cita = repo.crearCita({ fecha: '2026-03-02', inicio: '10:00', fin: '10:30', cliente: 'Ana' });
      assert.equal(typeof cita.id, 'number');
      assert.equal(cita.cliente, 'Ana');
    });

    test('las citas se devuelven ORDENADAS por hora de inicio', () => {
      repo.crearCita({ fecha: '2026-03-02', inicio: '17:00', fin: '18:00', cliente: 'C' });
      repo.crearCita({ fecha: '2026-03-02', inicio: '09:00', fin: '09:30', cliente: 'A' });
      repo.crearCita({ fecha: '2026-03-02', inicio: '12:00', fin: '12:30', cliente: 'B' });
      assert.deepEqual(repo.listarCitas('2026-03-02').map((c) => c.cliente), ['A', 'B', 'C']);
    });

    test('las citas de un dia NO se mezclan con las de otro', () => {
      repo.crearCita({ fecha: '2026-03-02', inicio: '10:00', fin: '10:30' });
      repo.crearCita({ fecha: '2026-03-03', inicio: '11:00', fin: '11:30' });
      assert.equal(repo.listarCitas('2026-03-02').length, 1);
      assert.equal(repo.listarCitas('2026-03-03').length, 1);
    });

    test('el cliente por defecto es "anonimo"', () => {
      const cita = repo.crearCita({ fecha: '2026-03-02', inicio: '10:00', fin: '10:30' });
      assert.equal(cita.cliente, 'anonimo');
    });

    test('rechaza datos invalidos en AMBAS implementaciones', () => {
      assert.throws(() => repo.crearCita({ fecha: '2/3/2026', inicio: '10:00', fin: '10:30' }), TypeError);
      assert.throws(() => repo.crearCita({ fecha: '2026-03-02', inicio: '10:00', fin: '09:00' }), RangeError);
      assert.throws(() => repo.crearCita({ fecha: '2026-03-02', inicio: '10', fin: '11' }), TypeError);
    });

    test('borrarTodo deja el repositorio limpio', () => {
      repo.crearCita({ fecha: '2026-03-02', inicio: '10:00', fin: '10:30' });
      repo.borrarTodo();
      assert.deepEqual(repo.listarCitas('2026-03-02'), []);
    });
  });
}

5.2 test/api.test.js — las rutas contra la persistencia real

// test/api.test.js
// Integracion: servidor HTTP real + repositorio SQLite real, en un puerto efimero.
import test, { describe, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { crearServidor } from '../src/servidor.js';
import { RepositorioSqlite } from '../src/repositorio-sqlite.js';

let servidor;
let repositorio;
let base;

before(async () => {
  repositorio = new RepositorioSqlite(':memory:');
  servidor = crearServidor({ repositorio });
  // Puerto 0 = el sistema asigna uno libre. Nunca fijes un puerto en las
  // pruebas: dos jobs en paralelo en el mismo runner chocarian (EADDRINUSE).
  await new Promise((resolver) => servidor.listen(0, '127.0.0.1', resolver));
  base = `http://127.0.0.1:${servidor.address().port}`;
});

after(async () => {
  await new Promise((resolver) => servidor.close(resolver));
  repositorio.cerrar();
});

beforeEach(() => repositorio.borrarTodo()); // aislamiento entre pruebas

describe('GET /salud', () => {
  test('responde 200 con estado ok y version', async () => {
    const respuesta = await fetch(`${base}/salud`);
    assert.equal(respuesta.status, 200);
    const cuerpo = await respuesta.json();
    assert.equal(cuerpo.estado, 'ok');
    assert.ok(typeof cuerpo.version === 'string');
    assert.ok(Number.isFinite(cuerpo.activoSeg));
  });
});

describe('GET /api/huecos', () => {
  test('sin citas devuelve el dia completo (9 huecos de 60 min)', async () => {
    const cuerpo = await (await fetch(`${base}/api/huecos?fecha=2026-03-02&duracion=60`)).json();
    assert.equal(cuerpo.total, 9); // 5 de manana + 4 de tarde
  });

  test('refleja una cita creada por la API', async () => {
    await fetch(`${base}/api/citas`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ fecha: '2026-03-02', inicio: '10:00', fin: '11:00', cliente: 'Ana' }),
    });
    const cuerpo = await (await fetch(`${base}/api/huecos?fecha=2026-03-02&duracion=60`)).json();
    assert.equal(cuerpo.total, 8);
    assert.ok(!cuerpo.huecos.some((h) => h.inicio === '10:00'), 'el hueco reservado sigue apareciendo');
  });

  test('sin fecha responde 400 con mensaje util', async () => {
    const respuesta = await fetch(`${base}/api/huecos`);
    assert.equal(respuesta.status, 400);
    assert.match((await respuesta.json()).error, /fecha/i);
  });

  test('con fecha mal formada responde 400', async () => {
    assert.equal((await fetch(`${base}/api/huecos?fecha=02-03-2026`)).status, 400);
  });

  test('con duracion invalida responde 400', async () => {
    assert.equal((await fetch(`${base}/api/huecos?fecha=2026-03-02&duracion=-5`)).status, 400);
    assert.equal((await fetch(`${base}/api/huecos?fecha=2026-03-02&duracion=abc`)).status, 400);
  });
});

describe('POST /api/citas', () => {
  test('crea la cita y responde 201 con el id', async () => {
    const respuesta = await fetch(`${base}/api/citas`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ fecha: '2026-03-02', inicio: '10:00', fin: '10:30', cliente: 'Ana' }),
    });
    assert.equal(respuesta.status, 201);
    assert.ok((await respuesta.json()).id > 0);
  });

  test('rechaza una cita invalida con 400 y NO la persiste', async () => {
    const respuesta = await fetch(`${base}/api/citas`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ fecha: '2026-03-02', inicio: '11:00', fin: '10:00' }),
    });
    assert.equal(respuesta.status, 400);
    assert.deepEqual(repositorio.listarCitas('2026-03-02'), []);
  });

  test('un JSON malformado responde 400, no 500', async () => {
    const respuesta = await fetch(`${base}/api/citas`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: '{esto no es json',
    });
    assert.equal(respuesta.status, 400);
  });
});

describe('rutas desconocidas', () => {
  test('responden 404', async () => {
    assert.equal((await fetch(`${base}/no-existe`)).status, 404);
  });
});

Tres reglas que estas pruebas encarnan y que puedes llevarte a cualquier proyecto:

  1. Puerto 0. Nunca fijes un puerto. Dos shards paralelos en el mismo runner con el puerto 3000 fijo se pisan y producen un EADDRINUSE intermitente: acabas de crear un flaky sin querer.
  2. beforeEach que limpia. El aislamiento entre pruebas no es opcional. Sin él, el orden de ejecución importa, y el orden cambia cuando shardeas.
  3. La prueba negativa comprueba el efecto secundario. rechaza una cita invalida ... Y NO la persiste no solo mira el código de estado: mira que no se haya escrito nada. Un 400 que además guarda la fila es un bug que un test perezoso no detecta.

  1. Capa 3: la prueba end-to-end contra el proceso arrancado

Las de integración importan el servidor como módulo. Eso deja fuera todo lo que ocurre al arrancar el proceso de verdad: las variables de entorno, el crearRepositorio, la guarda de argv[1], el listen. Una prueba end-to-end lanza el binario tal cual lo lanzará producción.

// test/e2e.test.js
// End-to-end: arranca el proceso REAL con `node src/servidor.js`, con su
// configuracion por entorno, y le habla por HTTP como lo haria un cliente.
import test, { describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const PUERTO = 3100 + Number(process.env.DESPLAZAMIENTO_PUERTO ?? 0);
const BASE = `http://127.0.0.1:${PUERTO}`;

let proceso;
let directorio;

/** Espera activa a que /salud responda 200, con limite de tiempo. */
async function esperarSalud(intentos = 40, esperaMs = 250) {
  for (let i = 1; i <= intentos; i++) {
    try {
      const respuesta = await fetch(`${BASE}/salud`);
      if (respuesta.ok) return;
    } catch {
      /* aun no escucha: reintentar */
    }
    await new Promise((r) => setTimeout(r, esperaMs));
  }
  throw new Error(`El servidor no respondio en ${(intentos * esperaMs) / 1000}s`);
}

before(async () => {
  directorio = await mkdtemp(join(tmpdir(), 'mini-reservalia-e2e-'));
  proceso = spawn(process.execPath, ['src/servidor.js'], {
    env: {
      ...process.env,
      PORT: String(PUERTO),
      BASE_DATOS: `sqlite:${join(directorio, 'e2e.db')}`,
      APP_VERSION: 'e2e-test',
    },
    stdio: ['ignore', 'pipe', 'pipe'],
  });
  // Reenviar la salida del hijo: sin esto, un fallo al arrancar es invisible.
  proceso.stdout.on('data', (d) => process.stdout.write(`[servidor] ${d}`));
  proceso.stderr.on('data', (d) => process.stderr.write(`[servidor:err] ${d}`));
  await esperarSalud();
});

after(async () => {
  proceso?.kill('SIGTERM');
  await rm(directorio, { recursive: true, force: true });
});

describe('flujo completo de reserva', () => {
  test('/salud informa de la version inyectada por el entorno', async () => {
    const cuerpo = await (await fetch(`${BASE}/salud`)).json();
    assert.equal(cuerpo.version, 'e2e-test');
  });

  test('reservar reduce los huecos disponibles y persiste entre peticiones', async () => {
    const fecha = '2026-04-15';
    const antes = await (await fetch(`${BASE}/api/huecos?fecha=${fecha}&duracion=60`)).json();

    const creada = await fetch(`${BASE}/api/citas`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ fecha, inicio: '11:00', fin: '12:00', cliente: 'Diego' }),
    });
    assert.equal(creada.status, 201);

    const despues = await (await fetch(`${BASE}/api/huecos?fecha=${fecha}&duracion=60`)).json();
    assert.equal(despues.total, antes.total - 1);
    assert.ok(!despues.huecos.some((h) => h.inicio === '11:00'));
  });
});

Con esto, la suite queda así:

npm test
# ℹ tests 34
# ℹ pass 34
# ℹ fail 0
# ℹ duration_ms 1850

Nota sobre Playwright. Esta E2E prueba la API. Un end-to-end de interfaz —clic en el calendario, seleccionar hueco, confirmar— requiere un navegador real y ahí la herramienta es Playwright. La 05-01 lo cubre en el contexto de Reservalia: npx playwright test en CI con --reporter=html, navegadores cacheados y trazas de los fallos como artefacto. No lo repetimos aquí porque triplicaría el tiempo del pipeline sin enseñar nada nuevo sobre CI.

  1. Cobertura: medirla, publicarla y ponerle un umbral

Node trae cobertura integrada:

node --test --experimental-test-coverage test/

Al final del informe verás una tabla por fichero con líneas, ramas y funciones. Para el pipeline necesitamos tres cosas más: un formato máquina, un resumen legible y un umbral.

Añade a package.json:

"scripts": {
  "lint": "eslint .",
  "test": "node --test test/",
  "test:cobertura": "node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=informes/lcov.info --test-reporter=spec --test-reporter-destination=stdout test/",
  "cobertura:comprobar": "node scripts/cobertura.js",
  "build": "node scripts/build.js",
  "start": "node src/servidor.js"
}

Y crea scripts/cobertura.js, que lee el LCOV, escribe el resumen en Markdown y falla si baja del umbral:

// scripts/cobertura.js
// Lee informes/lcov.info, publica un resumen y aplica los umbrales.
// Sin dependencias: el formato LCOV son cuatro etiquetas.
//
//   SF:<fichero>   inicio de fichero
//   LF/LH          lineas encontradas / cubiertas
//   BRF/BRH        ramas encontradas / cubiertas
//   FNF/FNH        funciones encontradas / cubiertas
//   end_of_record

import { readFile, appendFile } from 'node:fs/promises';

const UMBRAL_LINEAS = Number(process.env.UMBRAL_LINEAS ?? 85);
const UMBRAL_RAMAS = Number(process.env.UMBRAL_RAMAS ?? 75);

const contenido = await readFile('informes/lcov.info', 'utf8').catch(() => {
  console.error('No existe informes/lcov.info. Ejecuta antes: npm run test:cobertura');
  process.exit(2);
});

const ficheros = [];
let actual = null;
for (const linea of contenido.split('\n')) {
  const [etiqueta, valor] = linea.split(':');
  if (etiqueta === 'SF') actual = { fichero: valor, LF: 0, LH: 0, BRF: 0, BRH: 0 };
  else if (actual && ['LF', 'LH', 'BRF', 'BRH'].includes(etiqueta)) actual[etiqueta] = Number(valor);
  else if (etiqueta === 'end_of_record' && actual) {
    ficheros.push(actual);
    actual = null;
  }
}

const total = ficheros.reduce(
  (acc, f) => ({ LF: acc.LF + f.LF, LH: acc.LH + f.LH, BRF: acc.BRF + f.BRF, BRH: acc.BRH + f.BRH }),
  { LF: 0, LH: 0, BRF: 0, BRH: 0 },
);

const pct = (parte, todo) => (todo === 0 ? 100 : (parte / todo) * 100);
const lineas = pct(total.LH, total.LF);
const ramas = pct(total.BRH, total.BRF);

const filas = ficheros
  .filter((f) => f.fichero.includes('/src/'))
  .map((f) => `| \`${f.fichero.replace(process.cwd() + '/', '')}\` | ${pct(f.LH, f.LF).toFixed(1)} % | ${pct(f.BRH, f.BRF).toFixed(1)} % |`)
  .sort();

const marca = (valor, umbral) => (valor >= umbral ? '✅' : '❌');

const resumen = [
  '## Cobertura',
  '',
  `**Lineas: ${lineas.toFixed(1)} %** ${marca(lineas, UMBRAL_LINEAS)} (umbral ${UMBRAL_LINEAS} %)  `,
  `**Ramas: ${ramas.toFixed(1)} %** ${marca(ramas, UMBRAL_RAMAS)} (umbral ${UMBRAL_RAMAS} %)`,
  '',
  '| Fichero | Lineas | Ramas |',
  '|---|---|---|',
  ...filas,
  '',
  '> La cobertura mide que codigo se EJECUTA, no que codigo se COMPRUEBA.',
  '> Un 95 % sin asserts es un 0 % de valor. Ver la leccion 02-04.',
].join('\n');

console.log(resumen);
if (process.env.GITHUB_STEP_SUMMARY) {
  await appendFile(process.env.GITHUB_STEP_SUMMARY, `${resumen}\n`);
}

if (lineas < UMBRAL_LINEAS || ramas < UMBRAL_RAMAS) {
  console.error(`\nCobertura insuficiente: lineas ${lineas.toFixed(1)}% (min ${UMBRAL_LINEAS}%), ramas ${ramas.toFixed(1)}% (min ${UMBRAL_RAMAS}%)`);
  process.exit(1);
}
console.log('\nCobertura por encima de los umbrales.');

Añade informes/ al .gitignore. Y pruébalo:

mkdir -p informes
npm run test:cobertura && npm run cobertura:comprobar

Qué debes ver:

## Cobertura

**Lineas: 93.4 %** ✅ (umbral 85 %)
**Ramas: 84.1 %** ✅ (umbral 75 %)

| Fichero | Lineas | Ramas |
|---|---|---|
| `src/disponibilidad.js` | 100.0 % | 96.2 % |
| `src/repositorio-sqlite.js` | 88.9 % | 66.7 % |
| `src/repositorio.js` | 96.0 % | 90.0 % |
| `src/servidor.js` | 91.2 % | 80.6 % |

Cobertura por encima de los umbrales.

La comprobación de que falla cuando debe fallar. Sube el umbral temporalmente y verifica que rompe:

UMBRAL_LINEAS=99 npm run cobertura:comprobar
# Cobertura insuficiente: lineas 93.4% (min 99%), ramas 84.1% (min 75%)
echo $?   # 1

La advertencia obligatoria. La cobertura mide qué código se ejecuta, no qué código se comprueba. Puedes llegar al 100 % con esta prueba:

test('cobertura falsa', () => {
  calcularHuecos(MANANA, [{ inicio: '10:00', fin: '11:00' }], 30);
  // ...y ningun assert. Ejecuta todo. No verifica nada.
});

Por eso el umbral se usa como detector de regresión —"no bajemos de donde estamos"— y no como objetivo. Un equipo al que se le pone un objetivo de cobertura del 90 % produce, sin excepción, pruebas sin asserts. Fija el umbral 2-3 puntos por debajo del valor actual y súbelo cuando suba de forma natural.

  1. Matriz de versiones de Node

Mini-Reservalia declara "node": ">=20.6.0". Esa afirmación no está verificada por nada. La matriz la verifica.

  test:
    name: Pruebas (Node ${{ matrix.node }})
    runs-on: ubuntu-latest
    strategy:
      # Sin fail-fast, si Node 22 falla queremos saber TAMBIEN si Node 20 falla.
      # Con fail-fast (el valor por defecto), GitHub cancela las demas entradas
      # en cuanto una falla y pierdes la mitad de la informacion.
      fail-fast: false
      matrix:
        node: ['20', '22']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

Qué debes ver: dos checks, Pruebas (Node 20) y Pruebas (Node 22), ejecutándose a la vez.

Aviso que te va a morder: al usar matriz, los nombres de los checks cambian. Tu ruleset de la 07-01 exige un check llamado Pruebas que ya no existe, así que el PR se quedará bloqueado esperándolo eternamente. Esto tiene dos soluciones y merece la pena entender la diferencia:

Solución Cómo Contrapartida
Actualizar el ruleset con los nombres nuevos Añadir Pruebas (Node 20) y Pruebas (Node 22) Hay que tocar la configuración cada vez que cambie la matriz
Job agregador Un job ci-ok con needs: [...] e if: always() que falla si algo falló; es el único check requerido El ruleset no vuelve a tocarse nunca

La segunda es la que usa Reservalia y la que vamos a implementar en el apartado 10.

  1. Sharding: partir la suite en dos

Con 34 pruebas y 1,8 segundos, shardear no aporta nada. Se hace ahora precisamente para tener el mecanismo montado antes de que haga falta, que es cuando tienes 700 pruebas y 11 minutos y todo el mundo está esperando.

Node 20.6+ trae --test-shard=<indice>/<total>, que reparte ficheros de forma determinista:

node --test --test-shard=1/2 test/   # primera mitad
node --test --test-shard=2/2 test/   # segunda mitad

En la matriz, dos dimensiones cruzadas:

    strategy:
      fail-fast: false
      matrix:
        node: ['20', '22']
        shard: [1, 2]
    steps:
      # ...
      - name: Pruebas (shard ${{ matrix.shard }}/2)
        run: node --test --test-shard=${{ matrix.shard }}/2 test/

Resultado: cuatro jobs en paralelo. Medición típica en este proyecto:

Configuración Tiempo de pared del paso de test Minutos de máquina consumidos
1 job, toda la suite ~2,0 s
2 shards ~1,2 s ~2×
4 jobs (2 Node × 2 shards) ~1,2 s ~4×

La ganancia aquí es ridícula porque el reparto es por fichero y tenemos cuatro ficheros muy desiguales: el shard que contiene e2e.test.js (que arranca un proceso) domina el tiempo. Esa es la lección real del sharding, y la 06-03 ya la anticipaba: repartir por número de ficheros da resultados pobres; repartir por tiempos históricos da resultados buenos. CircleCI lo trae de serie; en GitHub Actions hay que construirlo o aceptar el reparto ingenuo.

Regla práctica: no shardees hasta que la suite pase de 3-4 minutos, y cuando lo hagas, equilibra manualmente los ficheros o implementa un reparto por tiempos. Un sharding mal balanceado multiplica el coste sin reducir el tiempo.

  1. El ci.yml completo

# .github/workflows/ci.yml - VERSION 07-02
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  calidad:
    name: Calidad
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: ESLint
        run: npm run lint

  test:
    name: Pruebas
    runs-on: ubuntu-latest
    timeout-minutes: 15
    strategy:
      fail-fast: false
      matrix:
        node: ['20', '22']
        shard: [1, 2]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci

      - name: Ejecutar shard ${{ matrix.shard }}/2
        run: |
          mkdir -p informes
          node --test \
            --test-shard=${{ matrix.shard }}/2 \
            --test-reporter=tap --test-reporter-destination=informes/tests-${{ matrix.node }}-${{ matrix.shard }}.tap \
            --test-reporter=spec --test-reporter-destination=stdout \
            test/

      # `if: always()` para subir el informe TAMBIEN cuando las pruebas fallan,
      # que es justo cuando el informe sirve para algo.
      - name: Subir informe de pruebas
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: tests-node${{ matrix.node }}-shard${{ matrix.shard }}
          path: informes/
          retention-days: 7

      - name: Anotar fallos en el PR
        if: failure()
        run: node scripts/anotar-fallos.js informes/tests-${{ matrix.node }}-${{ matrix.shard }}.tap

  cobertura:
    name: Cobertura
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Suite completa con cobertura
        run: |
          mkdir -p informes
          npm run test:cobertura
      - name: Umbrales de cobertura
        run: npm run cobertura:comprobar
        env:
          UMBRAL_LINEAS: '85'
          UMBRAL_RAMAS: '75'
      - name: Subir LCOV
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: cobertura-lcov
          path: informes/lcov.info

  build:
    name: Construir
    runs-on: ubuntu-latest
    needs: [calidad, test, cobertura]
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist-${{ github.sha }}
          path: dist/
          retention-days: 7

  # Job agregador: el UNICO check requerido en la proteccion de rama.
  # Asi la matriz puede crecer o encoger sin tocar el ruleset.
  ci-ok:
    name: CI OK
    runs-on: ubuntu-latest
    needs: [calidad, test, cobertura, build]
    if: always()   # se ejecuta aunque algun `needs` haya fallado o se haya saltado
    steps:
      - name: Evaluar el resultado del pipeline
        run: |
          echo "calidad:   ${{ needs.calidad.result }}"
          echo "test:      ${{ needs.test.result }}"
          echo "cobertura: ${{ needs.cobertura.result }}"
          echo "build:     ${{ needs.build.result }}"
          if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
            echo "::error::Al menos un job del pipeline no ha pasado."
            exit 1
          fi
          echo "Pipeline completo en verde." >> "$GITHUB_STEP_SUMMARY"

Actualiza el ruleset para que el único check obligatorio sea CI OK:

# Ver el id del ruleset creado en la 07-01
gh api "repos/{owner}/{repo}/rulesets" --jq '.[] | "\(.id) \(.name)"'

Y en Settings → Rules → proteger-main, sustituye los tres checks por uno solo: CI OK.

Por qué if: always() y no if: success(). Sin always(), si un needs falla, el job agregador se omite en vez de fallar. Un check omitido no reporta estado, y GitHub lo interpreta como "pendiente" para siempre. El PR queda bloqueado con un check gris y nadie entiende por qué. Con always(), el job siempre corre y siempre reporta: verde o rojo, pero reporta.

  1. Fabricar una prueba flaky y aplicarle la cuarentena

Esta es la parte de la lección que más te va a servir en un trabajo real. Vamos a crear una prueba que falla a veces, verla fallar, y aplicarle el procedimiento completo.

11.1 Fabricarla

Crea test/reservas-hoy.test.js:

// test/reservas-hoy.test.js
// ATENCION: esta prueba esta MAL a proposito. Es el ejemplo de flaky de la leccion.
import test from 'node:test';
import assert from 'node:assert/strict';
import { calcularHuecos } from '../src/disponibilidad.js';

const HORARIO = [{ inicio: '09:00', fin: '14:00' }, { inicio: '16:00', fin: '20:00' }];

/** Devuelve los huecos que aun no han pasado, segun el reloj del sistema. */
function huecosRestantesDeHoy(citas = []) {
  const ahora = new Date();
  const hhmm = `${String(ahora.getHours()).padStart(2, '0')}:${String(ahora.getMinutes()).padStart(2, '0')}`;
  return calcularHuecos(HORARIO, citas, 60).filter((h) => h.inicio >= hhmm);
}

test('quedan huecos disponibles hoy', () => {
  // FLAKY: depende de la hora a la que se ejecute el pipeline.
  // Verde de dia, rojo de noche, rojo en un runner con TZ=UTC si tu estas en UTC+2.
  assert.ok(huecosRestantesDeHoy().length > 0, 'no queda ningun hueco hoy');
});

test('el primer hueco de hoy empieza a las 09:00', () => {
  // FLAKY todavia peor: solo pasa si son menos de las 09:00.
  assert.equal(huecosRestantesDeHoy()[0]?.inicio, '09:00');
});

Ejecútala varias veces con horas simuladas para ver el patrón sin esperar a la noche:

# En Linux/macOS con la libreria faketime instalada:
faketime '10:00' npm test    # la primera pasa, la segunda falla
faketime '21:00' npm test    # las dos fallan

# Sin faketime, cambia la zona horaria del proceso:
TZ=Pacific/Auckland npm test   # en Europa, esto suele dar la noche de alla
TZ=UTC npm test

Qué debes ver: el mismo commit, el mismo código, resultados distintos. Súbelo al pipeline y reejecuta el workflow varias veces con Re-run all jobs; verás ejecuciones verdes y rojas sobre un código idéntico.

11.2 El daño que hace

Consecuencia Efecto medible
La gente reejecuta el job en vez de investigar El "Re-run" se convierte en el reflejo por defecto
El rojo deja de significar "está roto" Se empieza a mergear con checks rojos "porque es el flaky ese"
Se pierde la señal de los fallos reales Un bug de verdad se confunde con el flaky y llega a producción
Se dispara el tiempo de merge Cada PR necesita 2-3 pasadas

Una sola prueba flaky en una suite de 300 basta para degradar la confianza en las 299 restantes. Por eso la 02-04 insistía: una flaky no es un fallo menor, es un fallo de la señal.

11.3 La política de cuarentena, paso a paso

Paso 1 — Detectar y etiquetar. Marca la prueba, no la borres:

// test/reservas-hoy.test.js
import test from 'node:test';

// CUARENTENA #12 - flaky por dependencia del reloj del sistema.
// Aislada el 2026-04-10 por @tu-usuario. Fecha limite: 2026-04-24.
// Si el 24 no esta arreglada, se BORRA. Ver la politica en CONTRIBUTING.md.
test('quedan huecos disponibles hoy', { skip: 'flaky: depende del reloj (#12)' }, () => {
  /* ... */
});

Paso 2 — Aislar. Que salga del camino crítico pero siga ejecutándose, para no perderla de vista. Añade un job informativo:

  flaky-vigiladas:
    name: Pruebas en cuarentena (informativo)
    runs-on: ubuntu-latest
    # NO esta en el `needs` de ci-ok: no bloquea nada.
    continue-on-error: true
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - name: Ejecutar la cuarentena 5 veces
        run: |
          FALLOS=0
          for i in 1 2 3 4 5; do
            node --test --test-skip-pattern='^$' test/reservas-hoy.test.js || FALLOS=$((FALLOS+1))
          done
          echo "### Cuarentena: $FALLOS/5 fallos" >> "$GITHUB_STEP_SUMMARY"
          [ "$FALLOS" -eq 0 ] && echo "Candidata a salir de cuarentena." >> "$GITHUB_STEP_SUMMARY"

Ejecutar la prueba N veces es la forma correcta de medir flakiness: una ejecución no distingue "rota" de "inestable"; cinco sí. Y el job informa sin bloquear, que es lo que significa "cuarentena".

Paso 3 — Abrir el ticket con fecha límite.

gh issue create \
  --title "Flaky: 'quedan huecos disponibles hoy' depende del reloj del sistema" \
  --body "$(cat <<'EOF'
**Sintoma:** falla de forma intermitente segun la hora de ejecucion del runner.
**Frecuencia:** ~40 % de las ejecuciones (100 % despues de las 19:00 UTC).
**Causa raiz:** `huecosRestantesDeHoy()` llama a `new Date()` directamente.
**Estado:** en cuarentena desde 2026-04-10 (`skip`).
**Fecha limite:** 2026-04-24. Si no esta arreglada, se borra la prueba.
**Arreglo propuesto:** inyectar el reloj como parametro.
EOF
)" --label flaky

Paso 4 — Arreglar la causa raíz. Un flaky por reloj se arregla inyectando el reloj, nunca con un sleep ni con reintentos:

// src/agenda.js
import { calcularHuecos } from './disponibilidad.js';

/**
 * Huecos que aun no han empezado a una hora dada.
 * @param {object} opciones
 * @param {() => Date} opciones.reloj Inyectable: en produccion `() => new Date()`,
 *        en las pruebas una funcion que devuelve una fecha fija.
 */
export function huecosRestantes(horario, citas, duracionMin, { reloj = () => new Date() } = {}) {
  const ahora = reloj();
  const hhmm = `${String(ahora.getHours()).padStart(2, '0')}:${String(ahora.getMinutes()).padStart(2, '0')}`;
  return calcularHuecos(horario, citas, duracionMin).filter((h) => h.inicio >= hhmm);
}
// test/agenda.test.js  (sustituye a reservas-hoy.test.js)
import test, { describe } from 'node:test';
import assert from 'node:assert/strict';
import { huecosRestantes } from '../src/agenda.js';

const HORARIO = [{ inicio: '09:00', fin: '14:00' }, { inicio: '16:00', fin: '20:00' }];
const alas = (hhmm) => () => new Date(`2026-04-15T${hhmm}:00`);

describe('huecosRestantes (con reloj inyectado: determinista)', () => {
  test('a las 08:00 quedan todos los huecos del dia', () => {
    assert.equal(huecosRestantes(HORARIO, [], 60, { reloj: alas('08:00') }).length, 9);
  });

  test('a las 12:30 solo quedan los de 13:00 en adelante', () => {
    const huecos = huecosRestantes(HORARIO, [], 60, { reloj: alas('12:30') });
    assert.deepEqual(huecos.map((h) => h.inicio), ['13:00', '16:00', '17:00', '18:00', '19:00']);
  });

  test('a las 21:00 no queda ninguno', () => {
    assert.deepEqual(huecosRestantes(HORARIO, [], 60, { reloj: alas('21:00') }), []);
  });

  test('el caso limite de las 20:00 en punto: el ultimo hueco ya ha empezado', () => {
    assert.deepEqual(huecosRestantes(HORARIO, [], 60, { reloj: alas('20:00') }), []);
  });
});
rm test/reservas-hoy.test.js
npm test    # verde, y verde SIEMPRE, a cualquier hora, en cualquier zona

Ejecuta la suite diez veces seguidas para demostrar el determinismo:

for i in $(seq 1 10); do npm test > /dev/null 2>&1 && echo "run $i: OK" || echo "run $i: FALLO"; done
# 10 veces OK

Lo que hay que llevarse. Una prueba flaky casi siempre esconde una dependencia oculta del entorno: el reloj, el orden de ejecución, un puerto fijo, un fichero compartido, una condición de carrera real, la red. Arreglarla mejora el código, no solo la prueba. En este caso, inyectar el reloj hace la función testeable y permite implementar mañana "ver la agenda de un negocio en otra zona horaria" sin tocar nada. Los reintentos automáticos, en cambio, esconden el problema y a menudo esconden un bug de concurrencia de verdad.

Fuentes más frecuentes y su arreglo:

Fuente Síntoma Arreglo correcto Arreglo falso (no lo hagas)
Reloj / fecha Falla de noche, en fin de mes, en otra TZ Inyectar el reloj TZ=Europe/Madrid en CI
Orden de pruebas Falla al shardear o en paralelo beforeEach que limpia el estado Forzar ejecución en serie
Puerto fijo EADDRINUSE intermitente listen(0), puerto efímero Un sleep antes
Espera fija Falla cuando el runner va lento Sondeo con reintentos y límite Subir el sleep
Servicio externo Falla cuando la red falla Doble de prueba en integración; el real solo en E2E Reintentos

  1. Informes como artefacto y anotaciones en el PR

El workflow ya sube los .tap. Falta convertir los fallos en anotaciones sobre el código del PR. GitHub Actions lee comandos especiales de la salida estándar:

// scripts/anotar-fallos.js
// Convierte un informe TAP de node:test en anotaciones de GitHub Actions.
// Uso: node scripts/anotar-fallos.js informes/tests-20-1.tap
import { readFile } from 'node:fs/promises';

const ruta = process.argv[2];
if (!ruta) {
  console.error('Uso: node scripts/anotar-fallos.js <fichero.tap>');
  process.exit(2);
}

const lineas = (await readFile(ruta, 'utf8')).split('\n');
let fallos = 0;

for (let i = 0; i < lineas.length; i++) {
  const fallo = lineas[i].match(/^not ok \d+ - (.+)$/);
  if (!fallo) continue;
  fallos++;
  const nombre = fallo[1].trim();

  // El bloque YAML posterior trae file/line/failureType/error.
  let fichero = '';
  let linea = '1';
  let mensaje = '';
  for (let j = i + 1; j < Math.min(i + 30, lineas.length); j++) {
    const m = lineas[j].match(/^\s*(file|line|error):\s*(.*)$/);
    if (!m) continue;
    const valor = m[2].replace(/^['"]|['"]$/g, '').trim();
    if (m[1] === 'file') fichero = valor.replace(`${process.cwd()}/`, '');
    if (m[1] === 'line') linea = valor;
    if (m[1] === 'error') mensaje = valor;
    if (lineas[j].startsWith('  ...')) break;
  }

  // Escapado obligatorio: los saltos de linea y los ':' rompen el comando.
  const limpio = `${nombre}: ${mensaje}`.replace(/%/g, '%25').replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D');
  console.log(`::error file=${fichero || 'test'},line=${linea},title=Prueba fallida::${limpio}`);
}

console.log(`::notice::${fallos} prueba(s) fallida(s) en ${ruta}`);

Qué debes ver cuando un test falla en un PR: en la pestaña Files changed, un recuadro rojo sobre la línea exacta del fichero de prueba, con el mensaje del assert. Ya no hay que abrir el log.

Prueba la cadena completa: rompe un assert de test/api.test.js (cambia assert.equal(cuerpo.total, 9) por 10), empuja y observa:

  1. Dos de los cuatro jobs de Pruebas en rojo (los que contienen ese fichero según el shard).
  2. El artefacto tests-node20-shard1 descargable a pesar del fallo, gracias a if: always().
  3. La anotación roja sobre la línea del assert.
  4. Construir omitido.
  5. CI OK en rojo, no gris.
  6. El botón de merge deshabilitado.

Revierte el cambio antes de continuar.

  1. Verificación final

# Comprobación Cómo Esperado
1 Tres capas presentes ls test/ disponibilidad, repositorio, api, e2e, agenda
2 Suite verde en local npm test ~38 pruebas, 0 fallos
3 Contrato del repositorio Log de npm test El bloque se repite para memoria y sqlite
4 E2E arranca el proceso real Log Líneas [servidor] Mini-Reservalia e2e-test escuchando...
5 Cobertura publicada Portada del run Tabla "Cobertura" con porcentajes
6 El umbral rompe UMBRAL_LINEAS=99 npm run cobertura:comprobar Salida 1
7 Matriz de 4 jobs Grafo del run Pruebas (20,1), (20,2), (22,1), (22,2)
8 fail-fast: false funciona Romper un test y mirar Los 4 jobs se ejecutan; no se cancelan
9 Informe como artefacto en fallo Sección Artifacts de un run rojo .tap descargable
10 Anotación en el PR Files changed Recuadro rojo sobre el assert
11 La flaky ya no existe 10 ejecuciones seguidas 10 verdes
12 CI OK es el único check requerido Ruleset Un solo check

Errores Comunes y Consejos

Síntoma: Error: Cannot find module 'better-sqlite3' o was compiled against a different Node.js version. Causa: módulo nativo compilado para otra versión de Node (típico al cambiar de versión con nvm sin reinstalar), o caché de npm restaurada de una versión distinta. Arreglo: en local, rm -rf node_modules && npm ci. En CI, asegúrate de que setup-node va antes de npm ci. Si persiste, incluye la versión de Node en la clave de caché.

Síntoma: el job de pruebas termina, pero el proceso tarda 30 s extra en salir. Causa: un servidor o una base de datos abiertos que no se cierran. after() no se ejecutó, o falta el repositorio.cerrar(). Arreglo: cierra todo en after(). Para diagnosticar: node --test --test-force-exit test/ termina igualmente; si con eso va rápido, tienes un recurso sin cerrar.

Síntoma: EADDRINUSE: address already in use 127.0.0.1:3100 en la E2E, solo en CI. Causa: dos shards en el mismo runner arrancando el proceso en el mismo puerto. Arreglo: o bien usa puerto 0 y lee el puerto del log del hijo, o desplaza el puerto por shard con la variable DESPLAZAMIENTO_PUERTO que ya está prevista en el código: env: { DESPLAZAMIENTO_PUERTO: ${{ matrix.shard }} }.

Síntoma: la cobertura sale al 0 % o informes/lcov.info está vacío. Causa: el directorio informes/ no existía cuando el reporter intentó escribir. Arreglo: mkdir -p informes antes de ejecutar las pruebas (está en el workflow por eso).

Síntoma: el PR queda bloqueado con un check gris Expected — Pruebas. Causa: el ruleset exige un nombre de check que ya no se genera, porque la matriz cambió los nombres. Arreglo: el job agregador CI OK del apartado 10, y que sea el único check requerido.

Síntoma: npm test pasa en local y falla en CI con diferencias en el orden de las citas. Causa habitual: SQLite devuelve las filas en orden de inserción cuando no hay ORDER BY, y ese orden puede diferir. Si tu prueba depende del orden, el orden debe estar en la consulta. Arreglo: ORDER BY inicio explícito (ya está en el repositorio) y assert.deepEqual sobre listas ordenadas, o comparación insensible al orden.

Consejo — cómo repartir el esfuerzo. La proporción sana en un proyecto como este: ~70 % unitarias, ~25 % integración, ~5 % E2E. No por dogma, sino por economía: una unitaria cuesta microsegundos y señala la línea exacta; una E2E cuesta segundos y solo te dice "algo del flujo está roto". Si tu suite tarda demasiado, mira primero si has escrito como E2E algo que era una unitaria.

Consejo — el orden de las etapas. Lo barato primero. Lint (5 s) antes que unitarias (2 s) antes que integración (10 s) antes que E2E (30 s). Un PR con un error de sintaxis debería morir en el primer job, no después de cuatro minutos.

Ejercicios

Ejercicio 1: un test de contrato para una tercera implementación

Escribe RepositorioJson, que persiste en un fichero JSON, y haz que pase la misma batería de contrato sin modificar test/repositorio.test.js más que para añadirla a la lista. Si tu batería de contrato está bien escrita, debería detectar al menos un bug de tu implementación al primer intento.

Ejercicio 2: cobertura mínima por fichero, no solo global

El umbral global tiene un agujero: puedes tener un 90 % global con un fichero crítico al 40 %. Modifica scripts/cobertura.js para que además falle si algún fichero de src/ baja del 70 % de líneas, con una lista de exclusiones configurable.

Ejercicio 3: detectar flaky antes de que llegue a main

Añade un job que, solo en los PR que tocan test/, ejecute las pruebas modificadas 5 veces y falle si no son deterministas. Es la manera de que una flaky nueva no entre nunca.

Soluciones

Solución 1.

// src/repositorio-json.js
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { validarCita } from './repositorio.js';

export class RepositorioJson {
  #ruta;
  #datos;

  constructor(ruta) {
    this.#ruta = ruta;
    mkdirSync(dirname(ruta), { recursive: true });
    this.#datos = existsSync(ruta)
      ? JSON.parse(readFileSync(ruta, 'utf8'))
      : { siguienteId: 1, citas: [] };
  }

  #guardar() {
    // Escritura atomica: escribir a un temporal y renombrar. Sin esto, un fallo
    // a mitad de escritura deja el fichero corrupto y la app no vuelve a arrancar.
    const temporal = `${this.#ruta}.tmp`;
    writeFileSync(temporal, JSON.stringify(this.#datos, null, 2));
    require('node:fs').renameSync(temporal, this.#ruta);
  }

  listarCitas(fecha) {
    return this.#datos.citas
      .filter((c) => c.fecha === fecha)
      .sort((a, b) => a.inicio.localeCompare(b.inicio));
  }

  crearCita(datos) {
    const cita = { id: this.#datos.siguienteId++, ...validarCita(datos) };
    this.#datos.citas.push(cita);
    this.#guardar();
    return cita;
  }

  borrarTodo() {
    this.#datos = { siguienteId: 1, citas: [] };
    this.#guardar();
  }

  cerrar() {
    this.#guardar();
  }
}

(Con ESM, sustituye el require por un import { renameSync } from 'node:fs' arriba: ese es precisamente uno de los bugs que la batería detecta al primer intento, porque require no existe en un módulo ESM.)

En el test, una línea:

import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { RepositorioJson } from '../src/repositorio-json.js';

const IMPLEMENTACIONES = [
  ['memoria', () => new RepositorioMemoria()],
  ['sqlite', () => new RepositorioSqlite(':memory:')],
  ['json', () => new RepositorioJson(join(mkdtempSync(join(tmpdir(), 'repo-')), 'citas.json'))],
];

Bugs que la batería suele cazar en el primer intento: el id devuelto como string, el cliente por defecto no aplicado (si olvidas llamar a validarCita), y la mezcla de fechas si el filtro está mal. Ese es el valor de un test de contrato: una batería, N implementaciones, cero duplicación, y la garantía de que son de verdad intercambiables.

Solución 2.

// Al final de scripts/cobertura.js, antes del exit final:

const UMBRAL_POR_FICHERO = Number(process.env.UMBRAL_FICHERO ?? 70);
const EXCLUIDOS = (process.env.COBERTURA_EXCLUIR ?? 'src/servidor.js')
  .split(',')
  .map((s) => s.trim())
  .filter(Boolean);

const insuficientes = ficheros
  .filter((f) => f.fichero.includes('/src/'))
  .map((f) => ({ ruta: f.fichero.replace(`${process.cwd()}/`, ''), pct: pct(f.LH, f.LF) }))
  .filter((f) => f.pct < UMBRAL_POR_FICHERO && !EXCLUIDOS.includes(f.ruta));

if (insuficientes.length > 0) {
  const detalle = insuficientes.map((f) => `- \`${f.ruta}\`: ${f.pct.toFixed(1)} % (min ${UMBRAL_POR_FICHERO} %)`);
  const bloque = ['', '### ❌ Ficheros por debajo del umbral individual', '', ...detalle].join('\n');
  console.error(bloque);
  if (process.env.GITHUB_STEP_SUMMARY) {
    await appendFile(process.env.GITHUB_STEP_SUMMARY, `${bloque}\n`);
  }
  for (const f of insuficientes) {
    console.log(`::error file=${f.ruta}::Cobertura ${f.pct.toFixed(1)} %, por debajo del minimo de ${UMBRAL_POR_FICHERO} %`);
  }
  process.exit(1);
}

La lista de exclusiones debe ser explícita y estar en el repositorio, no oculta en un secreto ni en la interfaz de una herramienta. Y cada exclusión debería llevar comentario y fecha, exactamente como las excepciones de vulnerabilidades de la 07-05: una excepción sin fecha de caducidad es una excepción permanente.

Solución 3.

  deteccion-flaky:
    name: Deteccion de flaky
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # necesario para diffear contra la base del PR

      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci

      - name: Localizar las pruebas modificadas en este PR
        id: cambios
        run: |
          FICHEROS=$(git diff --name-only \
            "${{ github.event.pull_request.base.sha }}" HEAD \
            -- 'test/**/*.test.js' | tr '\n' ' ')
          echo "ficheros=$FICHEROS" >> "$GITHUB_OUTPUT"
          echo "Pruebas modificadas: ${FICHEROS:-(ninguna)}"

      - name: Ejecutar 5 veces y exigir determinismo
        if: steps.cambios.outputs.ficheros != ''
        run: |
          set -u
          FICHEROS="${{ steps.cambios.outputs.ficheros }}"
          FALLOS=0
          for i in 1 2 3 4 5; do
            echo "--- Pasada $i de 5 ---"
            if node --test $FICHEROS; then
              echo "pasada $i: OK"
            else
              echo "pasada $i: FALLO"
              FALLOS=$((FALLOS + 1))
            fi
          done

          {
            echo "## Deteccion de flaky"
            echo ""
            echo "Ficheros analizados: \`$FICHEROS\`"
            echo ""
            echo "Resultado: **$((5 - FALLOS))/5 pasadas en verde**"
          } >> "$GITHUB_STEP_SUMMARY"

          if [ "$FALLOS" -gt 0 ] && [ "$FALLOS" -lt 5 ]; then
            echo "::error::Comportamiento NO DETERMINISTA: $FALLOS de 5 pasadas fallaron sobre el mismo codigo."
            echo "Las pruebas de este PR no son deterministas. Revisa reloj, orden, puertos y esperas fijas." >> "$GITHUB_STEP_SUMMARY"
            exit 1
          fi
          if [ "$FALLOS" -eq 5 ]; then
            echo "::error::Las pruebas fallan SIEMPRE: no es flaky, esta rota."
            exit 1
          fi
          echo "Deterministas en 5 pasadas."

La distinción del final es la clave del ejercicio y la que casi nadie implementa: 5/5 fallos = rota (la señal es correcta, arregla el código); 1-4/5 fallos = flaky (la señal está corrupta, arregla la prueba). Son dos problemas distintos con dos respuestas distintas, y confundirlos es exactamente lo que lleva a un equipo a normalizar el rojo.

Para ir más lejos: ejecuta también en un runner cargado (stress-ng de fondo) para cazar las flaky por timing que solo aparecen cuando la máquina va lenta. Es lo que distingue "verde en mi portátil" de "verde en el runner del viernes por la tarde".

Reto opcional

Reescribe el reparto de shards para que sea por tiempos históricos en vez de por número de ficheros: guarda la duración de cada fichero de prueba en un artefacto, recupéralo en la ejecución siguiente, y reparte los ficheros con un algoritmo voraz de "el siguiente fichero va al shard menos cargado". Con cuatro ficheros de duraciones 0,2 s / 0,3 s / 0,4 s / 1,5 s, el reparto ingenuo da 0,5 s y 1,9 s; el voraz da 1,5 s y 0,9 s. Es exactamente lo que hace CircleCI con --split-by=timings (06-03), y hacerlo a mano te enseña por qué es una funcionalidad y no una línea de configuración.

Qué has construido

  • Una capa de persistencia con tres piezas intercambiables y un test de contrato que garantiza que lo son.
  • Las tres capas de la pirámide: 17 unitarias con casos límite reales, integración contra SQLite y contra las rutas HTTP, y un end-to-end contra el proceso arrancado con su configuración por entorno.
  • Cobertura medida, publicada en el resumen del run y con umbral que rompe el build, comprobado por el lado del fallo.
  • Una matriz de 4 jobs (2 versiones de Node × 2 shards) con fail-fast: false, y la comprensión de por qué el sharding ingenuo rinde poco.
  • Un job agregador CI OK que desacopla la protección de rama de la forma del pipeline.
  • Informes como artefacto incluso cuando falla y anotaciones sobre el código en el PR.
  • Una prueba flaky fabricada, diagnosticada, puesta en cuarentena con fecha límite y arreglada en su causa raíz, con la lección de que el arreglo mejoró el código de producción.

Conclusión

El pipeline ha pasado de "el código compila y siete pruebas pasan" a "el código está verificado en tres niveles, en dos versiones de Node, con una cobertura conocida y con un mecanismo que impide que una prueba inestable corrompa la señal". Esa diferencia es lo que separa un CI decorativo de uno en el que se puede confiar para desplegar sin mirar.

Y esa última frase es la bisagra hacia la siguiente lección. Todo lo que has construido tiene un único propósito: permitir que un cambio llegue a producción sin que nadie tenga que revisarlo a mano. De momento, el pipeline produce un directorio dist/ que se guarda siete días y no va a ninguna parte.

En la 07-03 cerramos el circuito. Empaquetarás Mini-Reservalia en un Dockerfile multi-etapa con usuario no-root y HEALTHCHECK, lo construirás con Buildx y caché, lo publicarás en ghcr.io identificado por su digest (gratis, sin AWS, con el GITHUB_TOKEN), separarás CI de CD con un cd.yml disparado por workflow_run, crearás Environments con staging automático y produccion con revisor requerido —y verás la ejecución esperando tu aprobación—, desplegarás con un script idempotente, comprobarás con un smoke test con reintentos, promocionarás por digest de staging a producción verificando que es el mismo byte a byte, y escribirás un rollback.yml que ejecutarás cronómetro en mano. Y, como siempre, romperás algo a propósito: desplegarás una versión que falla el /salud para ver la puerta cerrarse y el rollback funcionar.

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