El pipeline de la 07-01 és en verd, però el seu senyal és feble: set proves unitàries sobre una funció pura no diuen absolutament res sobre si l'endpoint /api/forats respon bé, si el servidor arrenca, o si la persistència desa el que diu que desa. Un pipeline verd amb proves insuficients es veu exactament igual que un amb proves bones, i aquesta és la seva peculiar perillositat. En aquest laboratori convertiràs aquest senyal feble en un senyal fort: ampliaràs Mini-Reservalia amb una capa de persistència real, escriuràs les tres capes de la piràmide al seu damunt, mesuraràs la cobertura i la publicaràs al resum del run, hi posaràs un llindar que trenqui el build, ho executaràs tot en una matriu de versions de Node i en dos shards paral·lels per veure baixar el temps, i —el més instructiu de la lliçó— fabricaràs una prova flaky expressament per veure-la fallar de manera intermitent i aplicar-hi la política de quarantena de la 02-04.
Cap d'aquestes peces no és opcional en un projecte real. Les tres capes et diuen què està trencat i a quin nivell; la cobertura et diu on no estàs mirant; la matriu et protegeix del «funciona a la meva versió»; el sharding és el que fa que la suite continuï sent tolerable quan passi de 7 proves a 700; i la política de flaky és l'única cosa que impedeix que l'equip aprengui a ignorar el vermell.
Contingut
- Objectiu, requisits previs i punt de partida
- La capa de persistència: interfície, memòria i SQLite
- El servidor sobre el repositori
- Capa 1: proves unitàries amb casos límit de debò
- Capa 2: proves d'integració contra la persistència real
- Capa 3: la prova end-to-end contra el procés arrencat
- Cobertura: mesurar-la, publicar-la i posar-li un llindar
- Matriu de versions de Node
- Sharding: partir la suite en dos
- El
ci.ymlcomplet - Fabricar una prova flaky i aplicar-hi la quarantena
- Informes com a artefacte i anotacions al PR
- Verificació final
- Errors Comuns i Consells
- Exercicis
- Conclusió
- Objectiu, requisits previs i punt de partida
Objectiu. En acabar tindràs una suite de tres capes sobre Mini-Reservalia que s'executa en quatre jobs paral·lels (2 versions de Node × 2 shards), amb una cobertura mesurada, publicada i amb llindar que trenca el build, i una política de quarantena aplicada a una prova flaky real.
Requisits previs. Haver completat la 07-01: el repositori mini-reservalia a GitHub, amb ci.yml de tres jobs, memòria cau, main protegida i el distintiu al README.
Punt de partida.
mini-reservalia/ ├── .github/workflows/ci.yml ├── src/disponibilitat.js ├── src/servidor.js ├── scripts/build.js ├── test/disponibilitat.test.js ├── eslint.config.js ├── package.json └── package-lock.json
Treballa en una branca des del principi, perquè main està protegida:
- La capa de persistència: interfície, memòria i SQLite
Fins ara l'agenda era un Map dins del servidor.js mateix. Això fa impossible provar la persistència i confon dues responsabilitats. Extraurem un repositori amb dues implementacions intercanviables: una en memòria (ràpida, per a les unitàries) i una sobre SQLite (real, per a les d'integració).
Per què SQLite i no PostgreSQL com a camí principal: SQLite és un fitxer, no necessita cap servei, arrenca en microsegons i funciona igual al teu portàtil i al runner. La variant amb PostgreSQL —que és el que fa servir el Reservalia real— va en una nota al final de l'apartat.
És la primera dependència de producció del projecte. A partir d'ara npm ci instal·la un mòdul natiu, cosa que farà la memòria cau de la 07-01 força més rendible.
Nota. Node 22.5+ inclou
node:sqlitede sèrie (encara experimental). Si el teu projecte només ha de córrer en Node 22+, pots estalviar-te la dependència. Aquí fem servirbetter-sqlite3perquè la nostra matriu inclou Node 20 i perquè un mòdul natiu és un cas més realista per parlar de memòries cau i d'auditoria de dependències a la 07-05.
2.1 src/repositori.js — la interfície i la implementació en memòria
// src/repositori.js
// Contracte de persistencia de Mini-Reservalia + implementacio en memoria.
//
// Totes les implementacions exposen la mateixa interficie:
// llistarCites(data) -> {inici, fi, client}[] ordenades per inici
// crearCita({data, inici, fi, client}) -> cita creada (amb id)
// esborrarTot() -> void
// tancar() -> void
const PATRO_DATA = /^\d{4}-\d{2}-\d{2}$/;
const PATRO_HORA = /^([01]\d|2[0-3]):([0-5]\d)$/;
/** Valida i normalitza una cita abans de desar-la. Llenca si es invalida. */
export function validarCita(cita) {
if (!PATRO_DATA.test(cita?.data ?? '')) {
throw new TypeError('data invalida: s espera YYYY-MM-DD');
}
if (!PATRO_HORA.test(cita?.inici ?? '') || !PATRO_HORA.test(cita?.fi ?? '')) {
throw new TypeError('inici i fi han de tenir format HH:MM');
}
if (cita.fi <= cita.inici) {
throw new RangeError('el fi ha de ser posterior a l inici');
}
return {
data: cita.data,
inici: cita.inici,
fi: cita.fi,
client: String(cita.client ?? 'anonim').slice(0, 80),
};
}
export class RepositoriMemoria {
#perData = new Map();
#seguentId = 1;
llistarCites(data) {
const cites = this.#perData.get(data) ?? [];
return [...cites].sort((a, b) => a.inici.localeCompare(b.inici));
}
crearCita(dades) {
const cita = { id: this.#seguentId++, ...validarCita(dades) };
const llista = this.#perData.get(cita.data) ?? [];
llista.push(cita);
this.#perData.set(cita.data, llista);
return cita;
}
esborrarTot() {
this.#perData.clear();
this.#seguentId = 1;
}
tancar() {
/* res a tancar */
}
}2.2 src/repositori-sqlite.js
// src/repositori-sqlite.js
// Implementacio del mateix contracte sobre SQLite.
import Database from 'better-sqlite3';
import { validarCita } from './repositori.js';
const ESQUEMA = `
CREATE TABLE IF NOT EXISTS cites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
data TEXT NOT NULL,
inici TEXT NOT NULL,
fi TEXT NOT NULL,
client TEXT NOT NULL DEFAULT 'anonim'
);
CREATE INDEX IF NOT EXISTS idx_cites_data ON cites(data);
`;
export class RepositoriSqlite {
#db;
#stmtLlistar;
#stmtInserir;
/** @param {string} ruta ':memory:' per a una base efimera, o un fitxer. */
constructor(ruta = ':memory:') {
this.#db = new Database(ruta);
this.#db.pragma('journal_mode = WAL');
this.#db.exec(ESQUEMA); // migracio minima; la 04-06 explica per que en real aixo va versionat
this.#stmtLlistar = this.#db.prepare(
'SELECT id, data, inici, fi, client FROM cites WHERE data = ? ORDER BY inici',
);
this.#stmtInserir = this.#db.prepare(
'INSERT INTO cites (data, inici, fi, client) VALUES (@data, @inici, @fi, @client)',
);
}
llistarCites(data) {
return this.#stmtLlistar.all(data);
}
crearCita(dades) {
const cita = validarCita(dades);
const info = this.#stmtInserir.run(cita);
return { id: Number(info.lastInsertRowid), ...cita };
}
esborrarTot() {
this.#db.exec('DELETE FROM cites');
}
tancar() {
this.#db.close();
}
}
/**
* Fabrica del repositori segons la URL de connexio.
* Aixo es el que permet que el mateix binari corri amb memoria a les
* proves rapides i amb SQLite a produccio, sense cap branca de "si som
* en test". La configuracio ve de l entorn (12-factor), com a la 03-02.
*/
export async function crearRepositori(url = process.env.BASE_DADES ?? 'memoria:') {
if (url === 'memoria:') {
const { RepositoriMemoria } = await import('./repositori.js');
return new RepositoriMemoria();
}
if (url.startsWith('sqlite:')) {
return new RepositoriSqlite(url.slice('sqlite:'.length));
}
throw new Error(`Origen de dades no suportat: ${url}`);
}Dues decisions que valen per a qualsevol projecte:
- La validació viu en un sol lloc (
validarCita), compartida per totes dues implementacions. Si estigués duplicada, les dues implementacions es comportarien diferent davant de dades dolentes i les proves d'integració passarien mentre producció falla. preparefora dels mètodes. Les sentències preparades eviten la concatenació de SQL. Això no és només rendiment: és el que fa que la injecció SQL sigui impossible per construcció. A la 07-05 introduirem una consulta mal escrita expressament per veure com CodeQL la detecta.
Equivalent real a Reservalia i variant PostgreSQL. Reservalia fa servir PostgreSQL sobre RDS. A la CI, el
ci.ymlde la 02-02 aixeca un PostgreSQL com a servei del runner:test: runs-on: ubuntu-latest services: postgres: image: postgres:16-alpine env: POSTGRES_PASSWORD: prova POSTGRES_DB: reservalia_test ports: ['5432:5432'] # Sense aquest health check, els passos arrenquen abans que # Postgres accepti connexions: fallada intermitent classica. options: >- --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10 env: BASE_DADES: postgres://postgres:prova@localhost:5432/reservalia_test steps: - uses: actions/checkout@v4 # ... npm ci, migracions, npm testSi ho vols fer així, escriu un
RepositoriPostgresamb la mateixa interfície i tota la resta d'aquesta lliçó funciona igual. El cost és +20-30 s per job i un servei més que pot fallar; per això aquí el camí principal és SQLite.
- El servidor sobre el repositori
Substitueix l'agenda en memòria de src/servidor.js pel repositori injectat, i afegeix POST /api/cites per poder crear dades des de les proves d'integració.
// src/servidor.js (versio 07-02)
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import { calcularForats } from './disponibilitat.js';
import { crearRepositori } from './repositori-sqlite.js';
export const VERSIO = process.env.APP_VERSION ?? 'dev';
export const PORT = Number(process.env.PORT ?? 3000);
export const HORARI_PER_DEFECTE = [
{ inici: '09:00', fi: '14:00' },
{ inici: '16:00', fi: '20:00' },
];
const PATRO_DATA = /^\d{4}-\d{2}-\d{2}$/;
function respondreJson(res, codi, cos) {
const text = JSON.stringify(cos);
res.writeHead(codi, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(text),
});
res.end(text);
}
async function llegirCos(req, maximBytes = 8192) {
const trossos = [];
let total = 0;
for await (const tros of req) {
total += tros.length;
if (total > maximBytes) throw new RangeError('cos massa gran');
trossos.push(tros);
}
if (total === 0) return {};
return JSON.parse(Buffer.concat(trossos).toString('utf8'));
}
export function crearServidor({ repositori, horari = HORARI_PER_DEFECTE } = {}) {
if (!repositori) throw new Error('crearServidor requereix un repositori');
return http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
try {
if (req.method === 'GET' && url.pathname === '/salut') {
return respondreJson(res, 200, {
estat: 'ok',
versio: VERSIO,
actiuSeg: Math.round(process.uptime()),
});
}
if (req.method === 'GET' && url.pathname === '/api/forats') {
const data = url.searchParams.get('data');
const duracio = Number(url.searchParams.get('duracio') ?? 30);
if (!data || !PATRO_DATA.test(data)) {
return respondreJson(res, 400, { error: 'Parametre "data" obligatori (YYYY-MM-DD)' });
}
if (!Number.isInteger(duracio) || duracio <= 0) {
return respondreJson(res, 400, { error: 'Parametre "duracio" invalid' });
}
const cites = repositori.llistarCites(data);
const forats = calcularForats(horari, cites, duracio);
return respondreJson(res, 200, { data, duracio, total: forats.length, forats });
}
if (req.method === 'POST' && url.pathname === '/api/cites') {
const cos = await llegirCos(req);
const cita = repositori.crearCita(cos);
return respondreJson(res, 201, cita);
}
return respondreJson(res, 404, { error: 'Ruta no trobada' });
} catch (error) {
// Errors de validacio -> 400; la resta -> 500. Sense filtrar el stack.
const esValidacio = error instanceof TypeError || error instanceof RangeError || error instanceof SyntaxError;
if (!esValidacio) console.error('Error no controlat:', error);
return respondreJson(res, esValidacio ? 400 : 500, {
error: esValidacio ? error.message : 'Error intern',
});
}
});
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const repositori = await crearRepositori();
crearServidor({ repositori }).listen(PORT, () => {
console.log(`Mini-Reservalia ${VERSIO} escoltant a http://localhost:${PORT}`);
});
}Prova-ho en local abans de continuar:
BASE_DADES='sqlite:/tmp/mini.db' npm start &
curl -s -X POST localhost:3000/api/cites \
-H 'content-type: application/json' \
-d '{"data":"2026-03-02","inici":"10:00","fi":"10:30","client":"Anna"}'
# {"id":1,"data":"2026-03-02","inici":"10:00","fi":"10:30","client":"Anna"}
curl -s "localhost:3000/api/forats?data=2026-03-02&duracio=30" | head -c 200
# {"data":"2026-03-02","duracio":30,"total":17,"forats":[{"inici":"09:00",...
kill %1
- Capa 1: proves unitàries amb casos límit de debò
La piràmide de la 02-04, aplicada a aquest projecte:
| Capa | Què prova | Quantes | Velocitat | Fitxer |
|---|---|---|---|---|
| Unitària | calcularForats, validarCita — lògica pura, sense E/S |
Moltes | µs | test/disponibilitat.test.js |
| Integració | Repositori SQLite real; rutes HTTP contra aquest repositori | Algunes | ms | test/repositori.test.js, test/api.test.js |
| End-to-end | El procés real arrencat, per HTTP, sense trucs | Molt poques | s | test/e2e.test.js |
Amplia test/disponibilitat.test.js amb els casos límit que sí que fan mal a producció:
// test/disponibilitat.test.js (ampliacio 07-02)
import test, { describe } from 'node:test';
import assert from 'node:assert/strict';
import { calcularForats, aMinuts, aHora } from '../src/disponibilitat.js';
const MATI = { inici: '09:00', fi: '12:00' };
const PARTIT = [
{ inici: '09:00', fi: '14:00' },
{ inici: '16:00', fi: '20:00' },
];
describe('conversions d hora', () => {
test('aMinuts converteix hores valides', () => {
assert.equal(aMinuts('00:00'), 0);
assert.equal(aMinuts('09:30'), 570);
assert.equal(aMinuts('23:59'), 1439);
});
test('aMinuts rebutja formats invalids', () => {
for (const dolent of ['9:00', '25:00', '09:60', '', '0900', 900, null]) {
assert.throws(() => aMinuts(dolent), TypeError, `hauria de rebutjar ${JSON.stringify(dolent)}`);
}
});
test('aHora es la inversa d aMinuts', () => {
for (const hora of ['00:00', '07:05', '13:45', '23:59']) {
assert.equal(aHora(aMinuts(hora)), hora);
}
});
});
describe('calcularForats: casos base', () => {
test('un dia sense cites es trosseja sencer', () => {
assert.deepEqual(calcularForats(MATI, [], 60), [
{ inici: '09:00', fi: '10:00' },
{ inici: '10:00', fi: '11:00' },
{ inici: '11:00', fi: '12:00' },
]);
});
test('una cita parteix el dia en dos blocs', () => {
assert.deepEqual(calcularForats(MATI, [{ inici: '10:00', fi: '11:00' }], 60), [
{ inici: '09:00', fi: '10:00' },
{ inici: '11:00', fi: '12:00' },
]);
});
test('la resta sobrant no genera un forat curt', () => {
const forats = calcularForats(MATI, [], 50);
assert.equal(forats.length, 3);
assert.equal(forats.at(-1).fi, '11:30');
});
});
describe('calcularForats: casos limit', () => {
test('dues cites SOLAPADES es fusionen i no deixen un forat fantasma', () => {
// 10:00-11:00 i 10:30-11:30 -> ocupat 10:00-11:30, no hi ha forat entre elles.
const forats = calcularForats({ inici: '09:00', fi: '13:00' }, [
{ inici: '10:00', fi: '11:00' },
{ inici: '10:30', fi: '11:30' },
], 30);
assert.deepEqual(forats.map((f) => f.inici), ['09:00', '09:30', '11:30', '12:00', '12:30']);
});
test('dues cites CONSECUTIVES que es toquen no deixen un forat de duracio zero', () => {
const forats = calcularForats({ inici: '09:00', fi: '12:00' }, [
{ inici: '10:00', fi: '10:30' },
{ inici: '10:30', fi: '11:00' },
], 30);
assert.deepEqual(forats.map((f) => f.inici), ['09:00', '09:30', '11:00', '11:30']);
});
test('cites DESORDENADES donen el mateix resultat que ordenades', () => {
const desordenades = [{ inici: '11:00', fi: '11:30' }, { inici: '09:30', fi: '10:00' }];
const ordenades = [...desordenades].sort((a, b) => a.inici.localeCompare(b.inici));
assert.deepEqual(calcularForats(MATI, desordenades, 30), calcularForats(MATI, ordenades, 30));
});
test('una cita que CREUA EL TANCAMENT retalla el tram sense desbordar-lo', () => {
// Tancament a les 14:00, cita 13:45-14:30. Cap forat no pot passar de 13:45.
const forats = calcularForats({ inici: '09:00', fi: '14:00' }, [{ inici: '13:45', fi: '14:30' }], 30);
assert.ok(forats.every((f) => f.fi <= '13:45'), `forat fora d horari: ${JSON.stringify(forats.at(-1))}`);
assert.equal(forats.at(-1).fi, '13:30');
});
test('una cita ANTERIOR A L OBERTURA no afecta', () => {
const forats = calcularForats(MATI, [{ inici: '07:00', fi: '08:00' }], 60);
assert.equal(forats.length, 3);
});
test('una cita que COBREIX TOT el tram deixa el dia sense forats', () => {
assert.deepEqual(calcularForats(MATI, [{ inici: '08:00', fi: '15:00' }], 30), []);
});
test('HORARI PARTIT: cap forat no creua la pausa de dinar', () => {
const forats = calcularForats(PARTIT, [], 60);
assert.ok(!forats.some((f) => f.inici < '14:00' && f.fi > '14:00'), 'hi ha un forat que creua la pausa');
assert.equal(forats.length, 5 + 4);
});
test('HORARI PARTIT amb una cita a cada tram', () => {
const forats = calcularForats(PARTIT, [
{ inici: '10:00', fi: '11:00' },
{ inici: '17:00', fi: '18:00' },
], 60);
assert.deepEqual(forats.map((f) => f.inici), ['09:00', '11:00', '12:00', '13:00', '16:00', '18:00', '19:00']);
});
test('una duracio mes gran que el tram no produeix forats', () => {
assert.deepEqual(calcularForats(MATI, [], 240), []);
});
test('parametres invalids llencen, no retornen buit', () => {
assert.throws(() => calcularForats(MATI, [], 0), RangeError);
assert.throws(() => calcularForats(MATI, [], 12.5), RangeError);
assert.throws(() => calcularForats({ inici: '14:00', fi: '09:00' }, [], 30), RangeError);
});
});Fixa't en el patró de la prova del tancament: assert.ok(forats.every(...)) amb un missatge que inclou el valor que ha fallat. Un assert.ok(condicio) sense missatge produeix AssertionError: The expression evaluated to a falsy value, que no et diu res; amb missatge, el log del pipeline et dona el diagnòstic sense haver de reproduir en local. És una diferència de dos minuts d'escriptura i de vint de depuració.
- Capa 2: proves d'integració contra la persistència real
Dos fitxers: un per al repositori i un altre per a les rutes HTTP.
5.1 test/repositori.test.js
El que és interessant aquí és que la mateixa bateria s'executa contra les dues implementacions. Això és un test de contracte: garanteix que memòria i SQLite són intercanviables, que és justament el que assumim en injectar-ne una o l'altra.
// test/repositori.test.js
import test, { describe, beforeEach, after } from 'node:test';
import assert from 'node:assert/strict';
import { RepositoriMemoria } from '../src/repositori.js';
import { RepositoriSqlite } from '../src/repositori-sqlite.js';
const IMPLEMENTACIONS = [
['memoria', () => new RepositoriMemoria()],
['sqlite', () => new RepositoriSqlite(':memory:')],
];
for (const [nom, fabrica] of IMPLEMENTACIONS) {
describe(`contracte del repositori: ${nom}`, () => {
let repo;
beforeEach(() => {
repo?.tancar();
repo = fabrica();
});
after(() => repo?.tancar());
test('un dia sense cites retorna llista buida', () => {
assert.deepEqual(repo.llistarCites('2026-03-02'), []);
});
test('crearCita retorna la cita amb un id numeric', () => {
const cita = repo.crearCita({ data: '2026-03-02', inici: '10:00', fi: '10:30', client: 'Anna' });
assert.equal(typeof cita.id, 'number');
assert.equal(cita.client, 'Anna');
});
test('les cites es retornen ORDENADES per hora d inici', () => {
repo.crearCita({ data: '2026-03-02', inici: '17:00', fi: '18:00', client: 'C' });
repo.crearCita({ data: '2026-03-02', inici: '09:00', fi: '09:30', client: 'A' });
repo.crearCita({ data: '2026-03-02', inici: '12:00', fi: '12:30', client: 'B' });
assert.deepEqual(repo.llistarCites('2026-03-02').map((c) => c.client), ['A', 'B', 'C']);
});
test('les cites d un dia NO es barregen amb les d un altre', () => {
repo.crearCita({ data: '2026-03-02', inici: '10:00', fi: '10:30' });
repo.crearCita({ data: '2026-03-03', inici: '11:00', fi: '11:30' });
assert.equal(repo.llistarCites('2026-03-02').length, 1);
assert.equal(repo.llistarCites('2026-03-03').length, 1);
});
test('el client per defecte es "anonim"', () => {
const cita = repo.crearCita({ data: '2026-03-02', inici: '10:00', fi: '10:30' });
assert.equal(cita.client, 'anonim');
});
test('rebutja dades invalides en TOTES DUES implementacions', () => {
assert.throws(() => repo.crearCita({ data: '2/3/2026', inici: '10:00', fi: '10:30' }), TypeError);
assert.throws(() => repo.crearCita({ data: '2026-03-02', inici: '10:00', fi: '09:00' }), RangeError);
assert.throws(() => repo.crearCita({ data: '2026-03-02', inici: '10', fi: '11' }), TypeError);
});
test('esborrarTot deixa el repositori net', () => {
repo.crearCita({ data: '2026-03-02', inici: '10:00', fi: '10:30' });
repo.esborrarTot();
assert.deepEqual(repo.llistarCites('2026-03-02'), []);
});
});
}5.2 test/api.test.js — les rutes contra la persistència real
// test/api.test.js
// Integracio: servidor HTTP real + repositori SQLite real, en un port efimer.
import test, { describe, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { crearServidor } from '../src/servidor.js';
import { RepositoriSqlite } from '../src/repositori-sqlite.js';
let servidor;
let repositori;
let base;
before(async () => {
repositori = new RepositoriSqlite(':memory:');
servidor = crearServidor({ repositori });
// Port 0 = el sistema n assigna un de lliure. No fixis mai un port a les
// proves: dos jobs en paral·lel al mateix runner xocarien (EADDRINUSE).
await new Promise((resoldre) => servidor.listen(0, '127.0.0.1', resoldre));
base = `http://127.0.0.1:${servidor.address().port}`;
});
after(async () => {
await new Promise((resoldre) => servidor.close(resoldre));
repositori.tancar();
});
beforeEach(() => repositori.esborrarTot()); // aillament entre proves
describe('GET /salut', () => {
test('respon 200 amb estat ok i versio', async () => {
const resposta = await fetch(`${base}/salut`);
assert.equal(resposta.status, 200);
const cos = await resposta.json();
assert.equal(cos.estat, 'ok');
assert.ok(typeof cos.versio === 'string');
assert.ok(Number.isFinite(cos.actiuSeg));
});
});
describe('GET /api/forats', () => {
test('sense cites retorna el dia complet (9 forats de 60 min)', async () => {
const cos = await (await fetch(`${base}/api/forats?data=2026-03-02&duracio=60`)).json();
assert.equal(cos.total, 9); // 5 de mati + 4 de tarda
});
test('reflecteix una cita creada per l API', async () => {
await fetch(`${base}/api/cites`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ data: '2026-03-02', inici: '10:00', fi: '11:00', client: 'Anna' }),
});
const cos = await (await fetch(`${base}/api/forats?data=2026-03-02&duracio=60`)).json();
assert.equal(cos.total, 8);
assert.ok(!cos.forats.some((f) => f.inici === '10:00'), 'el forat reservat continua apareixent');
});
test('sense data respon 400 amb missatge util', async () => {
const resposta = await fetch(`${base}/api/forats`);
assert.equal(resposta.status, 400);
assert.match((await resposta.json()).error, /data/i);
});
test('amb data mal formada respon 400', async () => {
assert.equal((await fetch(`${base}/api/forats?data=02-03-2026`)).status, 400);
});
test('amb duracio invalida respon 400', async () => {
assert.equal((await fetch(`${base}/api/forats?data=2026-03-02&duracio=-5`)).status, 400);
assert.equal((await fetch(`${base}/api/forats?data=2026-03-02&duracio=abc`)).status, 400);
});
});
describe('POST /api/cites', () => {
test('crea la cita i respon 201 amb l id', async () => {
const resposta = await fetch(`${base}/api/cites`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ data: '2026-03-02', inici: '10:00', fi: '10:30', client: 'Anna' }),
});
assert.equal(resposta.status, 201);
assert.ok((await resposta.json()).id > 0);
});
test('rebutja una cita invalida amb 400 i NO la persisteix', async () => {
const resposta = await fetch(`${base}/api/cites`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ data: '2026-03-02', inici: '11:00', fi: '10:00' }),
});
assert.equal(resposta.status, 400);
assert.deepEqual(repositori.llistarCites('2026-03-02'), []);
});
test('un JSON mal format respon 400, no 500', async () => {
const resposta = await fetch(`${base}/api/cites`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{aixo no es json',
});
assert.equal(resposta.status, 400);
});
});
describe('rutes desconegudes', () => {
test('responen 404', async () => {
assert.equal((await fetch(`${base}/no-existeix`)).status, 404);
});
});Tres regles que aquestes proves encarnen i que pots endur-te a qualsevol projecte:
- Port 0. No fixis mai un port. Dos shards paral·lels al mateix runner amb el port 3000 fix es trepitgen i produeixen un
EADDRINUSEintermitent: acabes de crear un flaky sense voler. beforeEachque neteja. L'aïllament entre proves no és opcional. Sense ell, l'ordre d'execució importa, i l'ordre canvia quan shardeges.- La prova negativa comprova l'efecte secundari.
rebutja una cita invalida ... I NO la persisteixno només mira el codi d'estat: mira que no s'hagi escrit res. Un 400 que a més desa la fila és un bug que un test mandrós no detecta.
- Capa 3: la prova end-to-end contra el procés arrencat
Les d'integració importen el servidor com a mòdul. Això deixa fora tot el que passa en arrencar el procés de debò: les variables d'entorn, el crearRepositori, la guarda d'argv[1], el listen. Una prova end-to-end llança el binari tal com el llançarà producció.
// test/e2e.test.js
// End-to-end: arrenca el proces REAL amb `node src/servidor.js`, amb la seva
// configuracio per entorn, i li parla per HTTP com ho faria un client.
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 PORT = 3100 + Number(process.env.DESPLACAMENT_PORT ?? 0);
const BASE = `http://127.0.0.1:${PORT}`;
let proces;
let directori;
/** Espera activa que /salut respongui 200, amb limit de temps. */
async function esperarSalut(intents = 40, esperaMs = 250) {
for (let i = 1; i <= intents; i++) {
try {
const resposta = await fetch(`${BASE}/salut`);
if (resposta.ok) return;
} catch {
/* encara no escolta: reintentar */
}
await new Promise((r) => setTimeout(r, esperaMs));
}
throw new Error(`El servidor no ha respost en ${(intents * esperaMs) / 1000}s`);
}
before(async () => {
directori = await mkdtemp(join(tmpdir(), 'mini-reservalia-e2e-'));
proces = spawn(process.execPath, ['src/servidor.js'], {
env: {
...process.env,
PORT: String(PORT),
BASE_DADES: `sqlite:${join(directori, 'e2e.db')}`,
APP_VERSION: 'e2e-test',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
// Reenviar la sortida del fill: sense aixo, una fallada en arrencar es invisible.
proces.stdout.on('data', (d) => process.stdout.write(`[servidor] ${d}`));
proces.stderr.on('data', (d) => process.stderr.write(`[servidor:err] ${d}`));
await esperarSalut();
});
after(async () => {
proces?.kill('SIGTERM');
await rm(directori, { recursive: true, force: true });
});
describe('flux complet de reserva', () => {
test('/salut informa de la versio injectada per l entorn', async () => {
const cos = await (await fetch(`${BASE}/salut`)).json();
assert.equal(cos.versio, 'e2e-test');
});
test('reservar redueix els forats disponibles i persisteix entre peticions', async () => {
const data = '2026-04-15';
const abans = await (await fetch(`${BASE}/api/forats?data=${data}&duracio=60`)).json();
const creada = await fetch(`${BASE}/api/cites`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ data, inici: '11:00', fi: '12:00', client: 'Diego' }),
});
assert.equal(creada.status, 201);
const despres = await (await fetch(`${BASE}/api/forats?data=${data}&duracio=60`)).json();
assert.equal(despres.total, abans.total - 1);
assert.ok(!despres.forats.some((f) => f.inici === '11:00'));
});
});Amb això, la suite queda així:
Nota sobre Playwright. Aquesta E2E prova l'API. Un end-to-end d'interfície —clic al calendari, seleccionar forat, confirmar— requereix un navegador real i aquí l'eina és Playwright. La 05-01 ho cobreix en el context de Reservalia:
npx playwright testa la CI amb--reporter=html, navegadors a la memòria cau i traces de les fallades com a artefacte. No ho repetim aquí perquè triplicaria el temps del pipeline sense ensenyar res de nou sobre CI.
- Cobertura: mesurar-la, publicar-la i posar-li un llindar
Node porta cobertura integrada:
Al final de l'informe hi veuràs una taula per fitxer amb línies, branques i funcions. Per al pipeline necessitem tres coses més: un format màquina, un resum llegible i un llindar.
Afegeix al 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:comprovar": "node scripts/cobertura.js",
"build": "node scripts/build.js",
"start": "node src/servidor.js"
}I crea scripts/cobertura.js, que llegeix el LCOV, escriu el resum en Markdown i falla si baixa del llindar:
// scripts/cobertura.js
// Llegeix informes/lcov.info, publica un resum i aplica els llindars.
// Sense dependencies: el format LCOV son quatre etiquetes.
//
// SF:<fitxer> inici de fitxer
// LF/LH linies trobades / cobertes
// BRF/BRH branques trobades / cobertes
// FNF/FNH funcions trobades / cobertes
// end_of_record
import { readFile, appendFile } from 'node:fs/promises';
const LLINDAR_LINIES = Number(process.env.LLINDAR_LINIES ?? 85);
const LLINDAR_BRANQUES = Number(process.env.LLINDAR_BRANQUES ?? 75);
const contingut = await readFile('informes/lcov.info', 'utf8').catch(() => {
console.error('No existeix informes/lcov.info. Executa abans: npm run test:cobertura');
process.exit(2);
});
const fitxers = [];
let actual = null;
for (const linia of contingut.split('\n')) {
const [etiqueta, valor] = linia.split(':');
if (etiqueta === 'SF') actual = { fitxer: 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) {
fitxers.push(actual);
actual = null;
}
}
const total = fitxers.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 = (part, tot) => (tot === 0 ? 100 : (part / tot) * 100);
const linies = pct(total.LH, total.LF);
const branques = pct(total.BRH, total.BRF);
const files = fitxers
.filter((f) => f.fitxer.includes('/src/'))
.map((f) => `| \`${f.fitxer.replace(process.cwd() + '/', '')}\` | ${pct(f.LH, f.LF).toFixed(1)} % | ${pct(f.BRH, f.BRF).toFixed(1)} % |`)
.sort();
const marca = (valor, llindar) => (valor >= llindar ? '✅' : '❌');
const resum = [
'## Cobertura',
'',
`**Linies: ${linies.toFixed(1)} %** ${marca(linies, LLINDAR_LINIES)} (llindar ${LLINDAR_LINIES} %) `,
`**Branques: ${branques.toFixed(1)} %** ${marca(branques, LLINDAR_BRANQUES)} (llindar ${LLINDAR_BRANQUES} %)`,
'',
'| Fitxer | Linies | Branques |',
'|---|---|---|',
...files,
'',
'> La cobertura mesura quin codi s EXECUTA, no quin codi es COMPROVA.',
'> Un 95 % sense asserts es un 0 % de valor. Veure la llico 02-04.',
].join('\n');
console.log(resum);
if (process.env.GITHUB_STEP_SUMMARY) {
await appendFile(process.env.GITHUB_STEP_SUMMARY, `${resum}\n`);
}
if (linies < LLINDAR_LINIES || branques < LLINDAR_BRANQUES) {
console.error(`\nCobertura insuficient: linies ${linies.toFixed(1)}% (min ${LLINDAR_LINIES}%), branques ${branques.toFixed(1)}% (min ${LLINDAR_BRANQUES}%)`);
process.exit(1);
}
console.log('\nCobertura per sobre dels llindars.');Afegeix informes/ al .gitignore. I prova-ho:
Què has de veure:
## Cobertura **Linies: 93.4 %** ✅ (llindar 85 %) **Branques: 84.1 %** ✅ (llindar 75 %) | Fitxer | Linies | Branques | |---|---|---| | `src/disponibilitat.js` | 100.0 % | 96.2 % | | `src/repositori-sqlite.js` | 88.9 % | 66.7 % | | `src/repositori.js` | 96.0 % | 90.0 % | | `src/servidor.js` | 91.2 % | 80.6 % | Cobertura per sobre dels llindars.
La comprovació que falla quan ha de fallar. Apuja el llindar temporalment i verifica que trenca:
LLINDAR_LINIES=99 npm run cobertura:comprovar
# Cobertura insuficient: linies 93.4% (min 99%), branques 84.1% (min 75%)
echo $? # 1L'advertència obligatòria. La cobertura mesura quin codi s'executa, no quin codi es comprova. Pots arribar al 100 % amb aquesta prova:
test('cobertura falsa', () => {
calcularForats(MATI, [{ inici: '10:00', fi: '11:00' }], 30);
// ...i cap assert. Executa tot. No verifica res.
});Per això el llindar es fa servir com a detector de regressió —«no baixem d'on som»— i no com a objectiu. Un equip a qui es posa un objectiu de cobertura del 90 % produeix, sense excepció, proves sense asserts. Fixa el llindar 2-3 punts per sota del valor actual i apuja'l quan pugi de manera natural.
- Matriu de versions de Node
Mini-Reservalia declara "node": ">=20.6.0". Aquesta afirmació no la verifica res. La matriu la verifica.
test:
name: Proves (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
# Sense fail-fast, si Node 22 falla volem saber TAMBE si Node 20 falla.
# Amb fail-fast (el valor per defecte), GitHub cancel·la les altres entrades
# tan bon punt una falla i perds la meitat de la informacio.
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è has de veure: dues comprovacions, Proves (Node 20) i Proves (Node 22), executant-se alhora.
Avís que et mossegarà: en fer servir matriu, els noms de les comprovacions canvien. El teu ruleset de la 07-01 exigeix una comprovació anomenada Proves que ja no existeix, així que el PR es quedarà bloquejat esperant-la eternament. Això té dues solucions i val la pena entendre'n la diferència:
| Solució | Com | Contrapartida |
|---|---|---|
| Actualitzar el ruleset amb els noms nous | Afegir Proves (Node 20) i Proves (Node 22) |
Cal tocar la configuració cada vegada que canviï la matriu |
| Job agregador | Un job ci-ok amb needs: [...] i if: always() que falla si alguna cosa ha fallat; és l'única comprovació requerida |
El ruleset no es torna a tocar mai |
La segona és la que fa servir Reservalia i la que implementarem a l'apartat 10.
- Sharding: partir la suite en dos
Amb 34 proves i 1,8 segons, shardejar no aporta res. Es fa ara precisament per tenir el mecanisme muntat abans que faci falta, que és quan tens 700 proves i 11 minuts i tothom està esperant.
Node 20.6+ porta --test-shard=<index>/<total>, que reparteix fitxers de manera determinista:
node --test --test-shard=1/2 test/ # primera meitat
node --test --test-shard=2/2 test/ # segona meitatA la matriu, dues dimensions creuades:
strategy:
fail-fast: false
matrix:
node: ['20', '22']
shard: [1, 2]
steps:
# ...
- name: Proves (shard ${{ matrix.shard }}/2)
run: node --test --test-shard=${{ matrix.shard }}/2 test/Resultat: quatre jobs en paral·lel. Mesura típica en aquest projecte:
| Configuració | Temps de paret del pas de test | Minuts de màquina consumits |
|---|---|---|
| 1 job, tota la suite | ~2,0 s | 1× |
| 2 shards | ~1,2 s | ~2× |
| 4 jobs (2 Node × 2 shards) | ~1,2 s | ~4× |
El guany aquí és ridícul perquè el repartiment és per fitxer i tenim quatre fitxers molt desiguals: el shard que conté e2e.test.js (que arrenca un procés) domina el temps. Aquesta és la lliçó real del sharding, i la 06-03 ja l'anticipava: repartir per nombre de fitxers dona resultats pobres; repartir per temps històrics dona resultats bons. CircleCI ho porta de sèrie; a GitHub Actions cal construir-ho o acceptar el repartiment ingenu.
Regla pràctica: no shardegis fins que la suite passi de 3-4 minuts, i quan ho facis, equilibra manualment els fitxers o implementa un repartiment per temps. Un sharding mal balancejat multiplica el cost sense reduir el temps.
- El
ci.yml complet
ci.yml complet# .github/workflows/ci.yml - VERSIO 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:
qualitat:
name: Qualitat
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: Proves
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: Executar 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()` per pujar l informe TAMBE quan les proves fallen,
# que es justament quan l informe serveix per a alguna cosa.
- name: Pujar informe de proves
if: always()
uses: actions/upload-artifact@v4
with:
name: tests-node${{ matrix.node }}-shard${{ matrix.shard }}
path: informes/
retention-days: 7
- name: Anotar fallades al PR
if: failure()
run: node scripts/anotar-fallades.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 amb cobertura
run: |
mkdir -p informes
npm run test:cobertura
- name: Llindars de cobertura
run: npm run cobertura:comprovar
env:
LLINDAR_LINIES: '85'
LLINDAR_BRANQUES: '75'
- name: Pujar LCOV
if: always()
uses: actions/upload-artifact@v4
with:
name: cobertura-lcov
path: informes/lcov.info
build:
name: Construir
runs-on: ubuntu-latest
needs: [qualitat, 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: l UNICA comprovacio requerida a la proteccio de branca.
# Aixi la matriu pot creixer o encongir sense tocar el ruleset.
ci-ok:
name: CI OK
runs-on: ubuntu-latest
needs: [qualitat, test, cobertura, build]
if: always() # s executa encara que algun `needs` hagi fallat o s hagi saltat
steps:
- name: Avaluar el resultat del pipeline
run: |
echo "qualitat: ${{ needs.qualitat.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::Almenys un job del pipeline no ha passat."
exit 1
fi
echo "Pipeline complet en verd." >> "$GITHUB_STEP_SUMMARY"Actualitza el ruleset perquè l'única comprovació obligatòria sigui CI OK:
# Veure l id del ruleset creat a la 07-01
gh api "repos/{owner}/{repo}/rulesets" --jq '.[] | "\(.id) \(.name)"'I a Settings → Rules → protegir-main, substitueix les tres comprovacions per una de sola: CI OK.
Per què
if: always()i noif: success(). Sensealways(), si unneedsfalla, el job agregador s'omet en comptes de fallar. Una comprovació omesa no reporta estat, i GitHub ho interpreta com a «pendent» per sempre. El PR queda bloquejat amb una comprovació grisa i ningú no entén per què. Ambalways(), el job sempre corre i sempre reporta: verd o vermell, però reporta.
- Fabricar una prova flaky i aplicar-hi la quarantena
Aquesta és la part de la lliçó que més et servirà en una feina real. Crearem una prova que falla de vegades, la veurem fallar, i li aplicarem el procediment complet.
11.1 Fabricar-la
Crea test/reserves-avui.test.js:
// test/reserves-avui.test.js
// ATENCIO: aquesta prova esta MALAMENT expressament. Es l exemple de flaky de la llico.
import test from 'node:test';
import assert from 'node:assert/strict';
import { calcularForats } from '../src/disponibilitat.js';
const HORARI = [{ inici: '09:00', fi: '14:00' }, { inici: '16:00', fi: '20:00' }];
/** Retorna els forats que encara no han passat, segons el rellotge del sistema. */
function foratsRestantsAvui(cites = []) {
const ara = new Date();
const hhmm = `${String(ara.getHours()).padStart(2, '0')}:${String(ara.getMinutes()).padStart(2, '0')}`;
return calcularForats(HORARI, cites, 60).filter((f) => f.inici >= hhmm);
}
test('queden forats disponibles avui', () => {
// FLAKY: depen de l hora a la qual s executi el pipeline.
// Verd de dia, vermell de nit, vermell en un runner amb TZ=UTC si tu ets a UTC+2.
assert.ok(foratsRestantsAvui().length > 0, 'no queda cap forat avui');
});
test('el primer forat d avui comenca a les 09:00', () => {
// FLAKY encara pitjor: nomes passa si son menys de les 09:00.
assert.equal(foratsRestantsAvui()[0]?.inici, '09:00');
});Executa-la diverses vegades amb hores simulades per veure el patró sense esperar a la nit:
# A Linux/macOS amb la llibreria faketime instal·lada:
faketime '10:00' npm test # la primera passa, la segona falla
faketime '21:00' npm test # les dues fallen
# Sense faketime, canvia la zona horaria del proces:
TZ=Pacific/Auckland npm test # a Europa, aixo sol donar la nit d alla
TZ=UTC npm testQuè has de veure: el mateix commit, el mateix codi, resultats diferents. Puja-ho al pipeline i reexecuta el flux de treball diverses vegades amb Re-run all jobs; hi veuràs execucions verdes i vermelles sobre un codi idèntic.
11.2 El dany que fa
| Conseqüència | Efecte mesurable |
|---|---|
| La gent reexecuta el job en comptes d'investigar | El «Re-run» es converteix en el reflex per defecte |
| El vermell deixa de significar «està trencat» | Es comença a fer merge amb comprovacions vermelles «perquè és aquell flaky» |
| Es perd el senyal de les fallades reals | Un bug de debò es confon amb el flaky i arriba a producció |
| Es dispara el temps de merge | Cada PR necessita 2-3 passades |
Una sola prova flaky en una suite de 300 n'hi ha prou per degradar la confiança en les 299 restants. Per això la 02-04 hi insistia: una flaky no és una fallada menor, és una fallada del senyal.
11.3 La política de quarantena, pas a pas
Pas 1 — Detectar i etiquetar. Marca la prova, no l'esborris:
// test/reserves-avui.test.js
import test from 'node:test';
// QUARANTENA #12 - flaky per dependencia del rellotge del sistema.
// Aillada el 2026-04-10 per @el-teu-usuari. Data limit: 2026-04-24.
// Si el 24 no esta arreglada, s ESBORRA. Veure la politica a CONTRIBUTING.md.
test('queden forats disponibles avui', { skip: 'flaky: depen del rellotge (#12)' }, () => {
/* ... */
});Pas 2 — Aïllar. Que surti del camí crític però continuï executant-se, per no perdre-la de vista. Afegeix un job informatiu:
flaky-vigilades:
name: Proves en quarantena (informatiu)
runs-on: ubuntu-latest
# NO esta al `needs` de ci-ok: no bloqueja res.
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Executar la quarantena 5 vegades
run: |
FALLADES=0
for i in 1 2 3 4 5; do
node --test --test-skip-pattern='^$' test/reserves-avui.test.js || FALLADES=$((FALLADES+1))
done
echo "### Quarantena: $FALLADES/5 fallades" >> "$GITHUB_STEP_SUMMARY"
[ "$FALLADES" -eq 0 ] && echo "Candidata a sortir de quarantena." >> "$GITHUB_STEP_SUMMARY"Executar la prova N vegades és la manera correcta de mesurar la flakiness: una execució no distingeix «trencada» d'«inestable»; cinc sí. I el job informa sense bloquejar, que és el que significa «quarantena».
Pas 3 — Obrir el tiquet amb data límit.
gh issue create \
--title "Flaky: 'queden forats disponibles avui' depen del rellotge del sistema" \
--body "$(cat <<'EOF'
**Simptoma:** falla de manera intermitent segons l hora d execucio del runner.
**Frequencia:** ~40 % de les execucions (100 % despres de les 19:00 UTC).
**Causa arrel:** `foratsRestantsAvui()` crida `new Date()` directament.
**Estat:** en quarantena des del 2026-04-10 (`skip`).
**Data limit:** 2026-04-24. Si no esta arreglada, s esborra la prova.
**Arranjament proposat:** injectar el rellotge com a parametre.
EOF
)" --label flakyPas 4 — Arreglar la causa arrel. Un flaky per rellotge s'arregla injectant el rellotge, mai amb un sleep ni amb reintents:
// src/agenda.js
import { calcularForats } from './disponibilitat.js';
/**
* Forats que encara no han comencat a una hora donada.
* @param {object} opcions
* @param {() => Date} opcions.rellotge Injectable: a produccio `() => new Date()`,
* a les proves una funcio que retorna una data fixa.
*/
export function foratsRestants(horari, cites, duracioMin, { rellotge = () => new Date() } = {}) {
const ara = rellotge();
const hhmm = `${String(ara.getHours()).padStart(2, '0')}:${String(ara.getMinutes()).padStart(2, '0')}`;
return calcularForats(horari, cites, duracioMin).filter((f) => f.inici >= hhmm);
}// test/agenda.test.js (substitueix reserves-avui.test.js)
import test, { describe } from 'node:test';
import assert from 'node:assert/strict';
import { foratsRestants } from '../src/agenda.js';
const HORARI = [{ inici: '09:00', fi: '14:00' }, { inici: '16:00', fi: '20:00' }];
const aLes = (hhmm) => () => new Date(`2026-04-15T${hhmm}:00`);
describe('foratsRestants (amb rellotge injectat: determinista)', () => {
test('a les 08:00 queden tots els forats del dia', () => {
assert.equal(foratsRestants(HORARI, [], 60, { rellotge: aLes('08:00') }).length, 9);
});
test('a les 12:30 nomes queden els de 13:00 endavant', () => {
const forats = foratsRestants(HORARI, [], 60, { rellotge: aLes('12:30') });
assert.deepEqual(forats.map((f) => f.inici), ['13:00', '16:00', '17:00', '18:00', '19:00']);
});
test('a les 21:00 no en queda cap', () => {
assert.deepEqual(foratsRestants(HORARI, [], 60, { rellotge: aLes('21:00') }), []);
});
test('el cas limit de les 20:00 en punt: el darrer forat ja ha comencat', () => {
assert.deepEqual(foratsRestants(HORARI, [], 60, { rellotge: aLes('20:00') }), []);
});
});Executa la suite deu vegades seguides per demostrar el determinisme:
for i in $(seq 1 10); do npm test > /dev/null 2>&1 && echo "run $i: OK" || echo "run $i: FALLADA"; done
# 10 vegades OKEl que cal endur-se'n. Una prova flaky gairebé sempre amaga una dependència oculta de l'entorn: el rellotge, l'ordre d'execució, un port fix, un fitxer compartit, una condició de cursa real, la xarxa. Arreglar-la millora el codi, no només la prova. En aquest cas, injectar el rellotge fa la funció testejable i permet implementar demà «veure l'agenda d'un negoci en una altra zona horària» sense tocar res. Els reintents automàtics, en canvi, amaguen el problema i sovint amaguen un bug de concurrència de debò.
Fonts més freqüents i el seu arranjament:
| Font | Símptoma | Arranjament correcte | Arranjament fals (no ho facis) |
|---|---|---|---|
| Rellotge / data | Falla de nit, a final de mes, en una altra TZ | Injectar el rellotge | TZ=Europe/Madrid a la CI |
| Ordre de proves | Falla en shardejar o en paral·lel | beforeEach que neteja l'estat |
Forçar execució en sèrie |
| Port fix | EADDRINUSE intermitent |
listen(0), port efímer |
Un sleep abans |
| Espera fixa | Falla quan el runner va lent | Sondeig amb reintents i límit | Apujar el sleep |
| Servei extern | Falla quan la xarxa falla | Doble de prova en integració; el real només en E2E | Reintents |
- Informes com a artefacte i anotacions al PR
El flux de treball ja puja els .tap. Falta convertir les fallades en anotacions sobre el codi del PR. GitHub Actions llegeix ordres especials de la sortida estàndard:
// scripts/anotar-fallades.js
// Converteix un informe TAP de node:test en anotacions de GitHub Actions.
// Us: node scripts/anotar-fallades.js informes/tests-20-1.tap
import { readFile } from 'node:fs/promises';
const ruta = process.argv[2];
if (!ruta) {
console.error('Us: node scripts/anotar-fallades.js <fitxer.tap>');
process.exit(2);
}
const linies = (await readFile(ruta, 'utf8')).split('\n');
let fallades = 0;
for (let i = 0; i < linies.length; i++) {
const fallada = linies[i].match(/^not ok \d+ - (.+)$/);
if (!fallada) continue;
fallades++;
const nom = fallada[1].trim();
// El bloc YAML posterior porta file/line/failureType/error.
let fitxer = '';
let linia = '1';
let missatge = '';
for (let j = i + 1; j < Math.min(i + 30, linies.length); j++) {
const m = linies[j].match(/^\s*(file|line|error):\s*(.*)$/);
if (!m) continue;
const valor = m[2].replace(/^['"]|['"]$/g, '').trim();
if (m[1] === 'file') fitxer = valor.replace(`${process.cwd()}/`, '');
if (m[1] === 'line') linia = valor;
if (m[1] === 'error') missatge = valor;
if (linies[j].startsWith(' ...')) break;
}
// Escapat obligatori: els salts de linia i els ':' trenquen l ordre.
const net = `${nom}: ${missatge}`.replace(/%/g, '%25').replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D');
console.log(`::error file=${fitxer || 'test'},line=${linia},title=Prova fallida::${net}`);
}
console.log(`::notice::${fallades} prova/es fallida/es a ${ruta}`);Què has de veure quan un test falla en un PR: a la pestanya Files changed, un requadre vermell sobre la línia exacta del fitxer de prova, amb el missatge de l'assert. Ja no cal obrir el log.
Prova la cadena completa: trenca un assert de test/api.test.js (canvia assert.equal(cos.total, 9) per 10), empeny i observa:
- Dos dels quatre jobs de
Provesen vermell (els que contenen aquest fitxer segons el shard). - L'artefacte
tests-node20-shard1descarregable malgrat la fallada, gràcies aif: always(). - L'anotació vermella sobre la línia de l'assert.
Construiromès.CI OKen vermell, no gris.- El botó de merge deshabilitat.
Reverteix el canvi abans de continuar.
- Verificació final
| # | Comprovació | Com | Esperat |
|---|---|---|---|
| 1 | Tres capes presents | ls test/ |
disponibilitat, repositori, api, e2e, agenda |
| 2 | Suite verda en local | npm test |
~38 proves, 0 fallades |
| 3 | Contracte del repositori | Log de npm test |
El bloc es repeteix per a memoria i sqlite |
| 4 | E2E arrenca el procés real | Log | Línies [servidor] Mini-Reservalia e2e-test escoltant... |
| 5 | Cobertura publicada | Portada del run | Taula «Cobertura» amb percentatges |
| 6 | El llindar trenca | LLINDAR_LINIES=99 npm run cobertura:comprovar |
Sortida 1 |
| 7 | Matriu de 4 jobs | Graf del run | Proves (20,1), (20,2), (22,1), (22,2) |
| 8 | fail-fast: false funciona |
Trencar un test i mirar | Els 4 jobs s'executen; no es cancel·len |
| 9 | Informe com a artefacte en fallada | Secció Artifacts d'un run vermell | .tap descarregable |
| 10 | Anotació al PR | Files changed | Requadre vermell sobre l'assert |
| 11 | La flaky ja no existeix | 10 execucions seguides | 10 verdes |
| 12 | CI OK és l'única comprovació requerida |
Ruleset | Una sola comprovació |
Errors Comuns i Consells
Símptoma: Error: Cannot find module 'better-sqlite3' o was compiled against a different Node.js version.
Causa: mòdul natiu compilat per a una altra versió de Node (típic en canviar de versió amb nvm sense reinstal·lar), o memòria cau de npm restaurada d'una versió diferent.
Arranjament: en local, rm -rf node_modules && npm ci. A la CI, assegura't que setup-node va abans de npm ci. Si persisteix, inclou la versió de Node a la clau de memòria cau.
Símptoma: el job de proves acaba, però el procés triga 30 s extra a sortir.
Causa: un servidor o una base de dades oberts que no es tanquen. L'after() no es va executar, o falta el repositori.tancar().
Arranjament: tanca-ho tot a l'after(). Per diagnosticar: node --test --test-force-exit test/ acaba igualment; si amb això va ràpid, tens un recurs sense tancar.
Símptoma: EADDRINUSE: address already in use 127.0.0.1:3100 a l'E2E, només a la CI.
Causa: dos shards al mateix runner arrencant el procés al mateix port.
Arranjament: o bé fes servir port 0 i llegeix el port del log del fill, o desplaça el port per shard amb la variable DESPLACAMENT_PORT que ja està prevista al codi: env: { DESPLACAMENT_PORT: ${{ matrix.shard }} }.
Símptoma: la cobertura surt al 0 % o informes/lcov.info és buit.
Causa: el directori informes/ no existia quan el reporter va intentar escriure.
Arranjament: mkdir -p informes abans d'executar les proves (per això és al flux de treball).
Símptoma: el PR queda bloquejat amb una comprovació grisa Expected — Proves.
Causa: el ruleset exigeix un nom de comprovació que ja no es genera, perquè la matriu va canviar els noms.
Arranjament: el job agregador CI OK de l'apartat 10, i que sigui l'única comprovació requerida.
Símptoma: npm test passa en local i falla a la CI amb diferències en l'ordre de les cites.
Causa habitual: SQLite retorna les files en ordre d'inserció quan no hi ha ORDER BY, i aquest ordre pot diferir. Si la teva prova depèn de l'ordre, l'ordre ha de ser a la consulta.
Arranjament: ORDER BY inici explícit (ja hi és al repositori) i assert.deepEqual sobre llistes ordenades, o comparació insensible a l'ordre.
Consell — com repartir l'esforç. La proporció sana en un projecte com aquest: ~70 % unitàries, ~25 % integració, ~5 % E2E. No per dogma, sinó per economia: una unitària costa microsegons i assenyala la línia exacta; una E2E costa segons i només et diu «alguna cosa del flux està trencada». Si la teva suite triga massa, mira primer si has escrit com a E2E una cosa que era una unitària.
Consell — l'ordre de les etapes. El que és barat, primer. Lint (5 s) abans que unitàries (2 s) abans que integració (10 s) abans que E2E (30 s). Un PR amb un error de sintaxi hauria de morir al primer job, no al cap de quatre minuts.
Exercicis
Exercici 1: un test de contracte per a una tercera implementació
Escriu RepositoriJson, que persisteix en un fitxer JSON, i fes que passi la mateixa bateria de contracte sense modificar test/repositori.test.js més que per afegir-la a la llista. Si la teva bateria de contracte està ben escrita, hauria de detectar almenys un bug de la teva implementació al primer intent.
Exercici 2: cobertura mínima per fitxer, no només global
El llindar global té un forat: pots tenir un 90 % global amb un fitxer crític al 40 %. Modifica scripts/cobertura.js perquè a més falli si algun fitxer de src/ baixa del 70 % de línies, amb una llista d'exclusions configurable.
Exercici 3: detectar flaky abans que arribi a main
Afegeix un job que, només als PR que toquen test/, executi les proves modificades 5 vegades i falli si no són deterministes. És la manera que una flaky nova no entri mai.
Solucions
Solució 1.
// src/repositori-json.js
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { validarCita } from './repositori.js';
export class RepositoriJson {
#ruta;
#dades;
constructor(ruta) {
this.#ruta = ruta;
mkdirSync(dirname(ruta), { recursive: true });
this.#dades = existsSync(ruta)
? JSON.parse(readFileSync(ruta, 'utf8'))
: { seguentId: 1, cites: [] };
}
#desar() {
// Escriptura atomica: escriure a un temporal i reanomenar. Sense aixo, una
// fallada a mig escriure deixa el fitxer corrupte i l app no torna a arrencar.
const temporal = `${this.#ruta}.tmp`;
writeFileSync(temporal, JSON.stringify(this.#dades, null, 2));
require('node:fs').renameSync(temporal, this.#ruta);
}
llistarCites(data) {
return this.#dades.cites
.filter((c) => c.data === data)
.sort((a, b) => a.inici.localeCompare(b.inici));
}
crearCita(dades) {
const cita = { id: this.#dades.seguentId++, ...validarCita(dades) };
this.#dades.cites.push(cita);
this.#desar();
return cita;
}
esborrarTot() {
this.#dades = { seguentId: 1, cites: [] };
this.#desar();
}
tancar() {
this.#desar();
}
}(Amb ESM, substitueix el require per un import { renameSync } from 'node:fs' a dalt: aquest és precisament un dels bugs que la bateria detecta al primer intent, perquè require no existeix en un mòdul ESM.)
Al test, una línia:
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { RepositoriJson } from '../src/repositori-json.js';
const IMPLEMENTACIONS = [
['memoria', () => new RepositoriMemoria()],
['sqlite', () => new RepositoriSqlite(':memory:')],
['json', () => new RepositoriJson(join(mkdtempSync(join(tmpdir(), 'repo-')), 'cites.json'))],
];Bugs que la bateria sol caçar al primer intent: l'id retornat com a string, el client per defecte no aplicat (si oblides cridar validarCita), i la barreja de dates si el filtre està malament. Aquest és el valor d'un test de contracte: una bateria, N implementacions, zero duplicació, i la garantia que són de debò intercanviables.
Solució 2.
// Al final de scripts/cobertura.js, abans de l exit final:
const LLINDAR_PER_FITXER = Number(process.env.LLINDAR_FITXER ?? 70);
const EXCLOSOS = (process.env.COBERTURA_EXCLOURE ?? 'src/servidor.js')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const insuficients = fitxers
.filter((f) => f.fitxer.includes('/src/'))
.map((f) => ({ ruta: f.fitxer.replace(`${process.cwd()}/`, ''), pct: pct(f.LH, f.LF) }))
.filter((f) => f.pct < LLINDAR_PER_FITXER && !EXCLOSOS.includes(f.ruta));
if (insuficients.length > 0) {
const detall = insuficients.map((f) => `- \`${f.ruta}\`: ${f.pct.toFixed(1)} % (min ${LLINDAR_PER_FITXER} %)`);
const bloc = ['', '### ❌ Fitxers per sota del llindar individual', '', ...detall].join('\n');
console.error(bloc);
if (process.env.GITHUB_STEP_SUMMARY) {
await appendFile(process.env.GITHUB_STEP_SUMMARY, `${bloc}\n`);
}
for (const f of insuficients) {
console.log(`::error file=${f.ruta}::Cobertura ${f.pct.toFixed(1)} %, per sota del minim de ${LLINDAR_PER_FITXER} %`);
}
process.exit(1);
}La llista d'exclusions ha de ser explícita i estar al repositori, no amagada en un secret ni a la interfície d'una eina. I cada exclusió hauria de portar comentari i data, exactament com les excepcions de vulnerabilitats de la 07-05: una excepció sense data de caducitat és una excepció permanent.
Solució 3.
deteccio-flaky:
name: Deteccio de flaky
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # necessari per fer diff contra la base del PR
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Localitzar les proves modificades en aquest PR
id: canvis
run: |
FITXERS=$(git diff --name-only \
"${{ github.event.pull_request.base.sha }}" HEAD \
-- 'test/**/*.test.js' | tr '\n' ' ')
echo "fitxers=$FITXERS" >> "$GITHUB_OUTPUT"
echo "Proves modificades: ${FITXERS:-(cap)}"
- name: Executar 5 vegades i exigir determinisme
if: steps.canvis.outputs.fitxers != ''
run: |
set -u
FITXERS="${{ steps.canvis.outputs.fitxers }}"
FALLADES=0
for i in 1 2 3 4 5; do
echo "--- Passada $i de 5 ---"
if node --test $FITXERS; then
echo "passada $i: OK"
else
echo "passada $i: FALLADA"
FALLADES=$((FALLADES + 1))
fi
done
{
echo "## Deteccio de flaky"
echo ""
echo "Fitxers analitzats: \`$FITXERS\`"
echo ""
echo "Resultat: **$((5 - FALLADES))/5 passades en verd**"
} >> "$GITHUB_STEP_SUMMARY"
if [ "$FALLADES" -gt 0 ] && [ "$FALLADES" -lt 5 ]; then
echo "::error::Comportament NO DETERMINISTA: $FALLADES de 5 passades han fallat sobre el mateix codi."
echo "Les proves d aquest PR no son deterministes. Revisa rellotge, ordre, ports i esperes fixes." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
if [ "$FALLADES" -eq 5 ]; then
echo "::error::Les proves fallen SEMPRE: no es flaky, esta trencada."
exit 1
fi
echo "Deterministes en 5 passades."La distinció del final és la clau de l'exercici i la que gairebé ningú no implementa: 5/5 fallades = trencada (el senyal és correcte, arregla el codi); 1-4/5 fallades = flaky (el senyal està corromput, arregla la prova). Són dos problemes diferents amb dues respostes diferents, i confondre'ls és exactament el que porta un equip a normalitzar el vermell.
Per anar més lluny: executa també en un runner carregat (stress-ng de fons) per caçar les flaky per timing que només apareixen quan la màquina va lenta. És el que distingeix «verd al meu portàtil» de «verd al runner del divendres a la tarda».
Repte opcional
Reescriu el repartiment de shards perquè sigui per temps històrics en comptes de per nombre de fitxers: desa la durada de cada fitxer de prova en un artefacte, recupera'l a l'execució següent, i reparteix els fitxers amb un algorisme voraç de «el fitxer següent va al shard menys carregat». Amb quatre fitxers de durades 0,2 s / 0,3 s / 0,4 s / 1,5 s, el repartiment ingenu dona 0,5 s i 1,9 s; el voraç dona 1,5 s i 0,9 s. És exactament el que fa CircleCI amb --split-by=timings (06-03), i fer-ho a mà t'ensenya per què és una funcionalitat i no pas una línia de configuració.
Què has construït
- Una capa de persistència amb tres peces intercanviables i un test de contracte que garanteix que ho són.
- Les tres capes de la piràmide: 17 unitàries amb casos límit reals, integració contra SQLite i contra les rutes HTTP, i un end-to-end contra el procés arrencat amb la seva configuració per entorn.
- Cobertura mesurada, publicada al resum del run i amb llindar que trenca el build, comprovat pel costat de la fallada.
- Una matriu de 4 jobs (2 versions de Node × 2 shards) amb
fail-fast: false, i la comprensió de per què el sharding ingenu rendeix poc. - Un job agregador
CI OKque desacobla la protecció de branca de la forma del pipeline. - Informes com a artefacte fins i tot quan falla i anotacions sobre el codi al PR.
- Una prova flaky fabricada, diagnosticada, posada en quarantena amb data límit i arreglada en la seva causa arrel, amb la lliçó que l'arranjament va millorar el codi de producció.
Conclusió
El pipeline ha passat de «el codi compila i set proves passen» a «el codi està verificat en tres nivells, en dues versions de Node, amb una cobertura coneguda i amb un mecanisme que impedeix que una prova inestable corrompi el senyal». Aquesta diferència és el que separa un CI decoratiu d'un en què es pot confiar per desplegar sense mirar.
I aquesta última frase és la frontissa cap a la lliçó següent. Tot el que has construït té un únic propòsit: permetre que un canvi arribi a producció sense que ningú l'hagi de revisar a mà. De moment, el pipeline produeix un directori dist/ que es guarda set dies i no va enlloc.
A la 07-03 tanquem el circuit. Empaquetaràs Mini-Reservalia en un Dockerfile multietapa amb usuari no-root i HEALTHCHECK, el construiràs amb Buildx i memòria cau, el publicaràs a ghcr.io identificat pel seu digest (gratis, sense AWS, amb el GITHUB_TOKEN), separaràs CI de CD amb un cd.yml disparat per workflow_run, crearàs Environments amb staging automàtic i produccio amb revisor requerit —i veuràs l'execució esperant la teva aprovació—, desplegaràs amb un script idempotent, comprovaràs amb un smoke test amb reintents, promocionaràs per digest de staging a producció verificant que és el mateix byte a byte, i escriuràs un rollback.yml que executaràs cronòmetre en mà. I, com sempre, trencaràs alguna cosa expressament: desplegaràs una versió que falla el /salut per veure la porta tancar-se i el rollback funcionar.
Curs de CI/CD: Integració i Desplegament Continu
Mòdul 1: Introducció al CI/CD
- Conceptes Bàsics de CI/CD
- Beneficis del CI/CD
- Eines Populars de CI/CD
- El Projecte del Curs: l'Aplicació que Automatitzarem
- Mètriques DORA: Com es Mesura el Lliurament de Programari
Mòdul 2: Integració Contínua (CI)
- Introducció a la Integració Contínua
- Configuració d'un Entorn de CI
- Automatització de la Construcció
- Proves Automatitzades
- Qualitat de Codi i Anàlisi Estàtica
- Artefactes, Versionat i Promoció
- Integració amb el Control de Versions
Mòdul 3: Desplegament Continu (CD)
- Introducció al Desplegament Continu
- Automatització del Desplegament
- Infraestructura com a Codi i Entorns Reproduïbles
- Estratègies de Desplegament
- Feature Flags, Rollback i Recuperació davant Errors
- Monitoratge i Retroalimentació
Mòdul 4: Pràctiques Avançades de CI/CD
- Pipelines de CI/CD
- Gestió de Dependències
- Seguretat en CI/CD
- Escalabilitat i Rendiment
- Pipeline as Code: Plantilles, Reutilització i Proves del Pipeline
- Bases de Dades al Pipeline: Migracions Segures
Mòdul 5: Implementació de CI/CD en Projectes Reals
- Cas d'Estudi: Projecte Web
- Cas d'Estudi: Aplicació Mòbil
- Cas d'Estudi: Microserveis
- Cas d'Estudi: Modernitzar un Projecte Legacy
Mòdul 6: Eines i Tecnologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker i Kubernetes
- GitHub Actions a Fons
- Comparativa i Criteris per Triar Eina
Mòdul 7: Exercicis Pràctics
- Exercici 1: Configuració d'un Pipeline Bàsic
- Exercici 2: Integració de Proves Automatitzades
- Exercici 3: Desplegament en un Entorn de Producció
- Exercici 4: Monitoratge i Retroalimentació
- Exercici 5: Enfortir el Pipeline amb Seguretat i Secrets
- Projecte Final: Pipeline Complet d'Extrem a Extrem
