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
- Objetivo, requisitos previos y punto de partida
- La capa de persistencia: interfaz, memoria y SQLite
- El servidor sobre el repositorio
- Capa 1: pruebas unitarias con casos límite de verdad
- Capa 2: pruebas de integración contra la persistencia real
- Capa 3: la prueba end-to-end contra el proceso arrancado
- Cobertura: medirla, publicarla y ponerle un umbral
- Matriz de versiones de Node
- Sharding: partir la suite en dos
- El
ci.ymlcompleto - Fabricar una prueba flaky y aplicarle la cuarentena
- Informes como artefacto y anotaciones en el PR
- Verificación final
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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:
- 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.
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:sqlitede serie (aún experimental). Si tu proyecto solo va a correr en Node 22+, puedes evitarte la dependencia. Aquí usamosbetter-sqlite3porque 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. preparefuera 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.ymlde 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 testSi quieres hacerlo así, escribe un
RepositorioPostgrescon 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.
- 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
- 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.
- 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:
- Puerto 0. Nunca fijes un puerto. Dos shards paralelos en el mismo runner con el puerto 3000 fijo se pisan y producen un
EADDRINUSEintermitente: acabas de crear un flaky sin querer. beforeEachque limpia. El aislamiento entre pruebas no es opcional. Sin él, el orden de ejecución importa, y el orden cambia cuando shardeas.- La prueba negativa comprueba el efecto secundario.
rechaza una cita invalida ... Y NO la persisteno 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.
- 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í:
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 testen 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.
- Cobertura: medirla, publicarla y ponerle un umbral
Node trae cobertura integrada:
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:
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 $? # 1La 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.
- 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 testQué 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.
- 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 mitadEn 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 | 1× |
| 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.
- El
ci.yml completo
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 noif: success(). Sinalways(), si unneedsfalla, 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é. Conalways(), el job siempre corre y siempre reporta: verde o rojo, pero reporta.
- 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 testQué 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 flakyPaso 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') }), []);
});
});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 OKLo 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 |
- 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:
- Dos de los cuatro jobs de
Pruebasen rojo (los que contienen ese fichero según el shard). - El artefacto
tests-node20-shard1descargable a pesar del fallo, gracias aif: always(). - La anotación roja sobre la línea del assert.
Construiromitido.CI OKen rojo, no gris.- El botón de merge deshabilitado.
Revierte el cambio antes de continuar.
- 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 OKque 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
- Conceptos Básicos de CI/CD
- Beneficios de CI/CD
- Herramientas Populares de CI/CD
- El Proyecto del Curso: la Aplicación que Vamos a Automatizar
- Métricas DORA: Cómo se Mide la Entrega de Software
Módulo 2: Integración Continua (CI)
- Introducción a la Integración Continua
- Configuración de un Entorno de CI
- Automatización de la Construcción
- Pruebas Automatizadas
- Calidad de Código y Análisis Estático
- Artefactos, Versionado y Promoción
- Integración con Control de Versiones
Módulo 3: Despliegue Continuo (CD)
- Introducción al Despliegue Continuo
- Automatización del Despliegue
- Infraestructura como Código y Entornos Reproducibles
- Estrategias de Despliegue
- Feature Flags, Rollback y Recuperación ante Fallos
- Monitoreo y Retroalimentación
Módulo 4: Prácticas Avanzadas de CI/CD
- Pipelines de CI/CD
- Gestión de Dependencias
- Seguridad en CI/CD
- Escalabilidad y Rendimiento
- Pipeline as Code: Plantillas, Reutilización y Pruebas del Pipeline
- Bases de Datos en el Pipeline: Migraciones Seguras
Módulo 5: Implementación de CI/CD en Proyectos Reales
- Caso de Estudio: Proyecto Web
- Caso de Estudio: Aplicación Móvil
- Caso de Estudio: Microservicios
- Caso de Estudio: Modernizar un Proyecto Legacy
Módulo 6: Herramientas y Tecnologías
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker y Kubernetes
- GitHub Actions a Fondo
- Comparativa y Criterios para Elegir Herramienta
Módulo 7: Ejercicios Prácticos
- Ejercicio 1: Configuración de un Pipeline Básico
- Ejercicio 2: Integración de Pruebas Automatizadas
- Ejercicio 3: Despliegue en un Entorno de Producción
- Ejercicio 4: Monitoreo y Retroalimentación
- Ejercicio 5: Endurecer el Pipeline con Seguridad y Secretos
- Proyecto Final: Pipeline Completo de Extremo a Extremo
