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

  1. Objectiu, requisits previs i punt de partida
  2. La capa de persistència: interfície, memòria i SQLite
  3. El servidor sobre el repositori
  4. Capa 1: proves unitàries amb casos límit de debò
  5. Capa 2: proves d'integració contra la persistència real
  6. Capa 3: la prova end-to-end contra el procés arrencat
  7. Cobertura: mesurar-la, publicar-la i posar-li un llindar
  8. Matriu de versions de Node
  9. Sharding: partir la suite en dos
  10. El ci.yml complet
  11. Fabricar una prova flaky i aplicar-hi la quarantena
  12. Informes com a artefacte i anotacions al PR
  13. Verificació final
  14. Errors Comuns i Consells
  15. Exercicis
  16. Conclusió

  1. 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:

git checkout main && git pull
git checkout -b proves-automatitzades

  1. 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.

npm install better-sqlite3

É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:sqlite de 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 servir better-sqlite3 perquè 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.
  • prepare fora 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.yml de 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 test

Si ho vols fer així, escriu un RepositoriPostgres amb 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.

  1. 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

  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ó.

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

  1. 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:

  1. Port 0. No fixis mai un port. Dos shards paral·lels al mateix runner amb el port 3000 fix es trepitgen i produeixen un EADDRINUSE intermitent: acabes de crear un flaky sense voler.
  2. beforeEach que neteja. L'aïllament entre proves no és opcional. Sense ell, l'ordre d'execució importa, i l'ordre canvia quan shardeges.
  3. La prova negativa comprova l'efecte secundari. rebutja una cita invalida ... I NO la persisteix no 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.

  1. 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í:

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

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 test a 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.

  1. Cobertura: mesurar-la, publicar-la i posar-li un llindar

Node porta cobertura integrada:

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

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:

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

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 $?   # 1

L'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.

  1. 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 test

Què 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.

  1. 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 meitat

A 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
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.

  1. El 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 no if: success(). Sense always(), si un needs falla, 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è. Amb always(), el job sempre corre i sempre reporta: verd o vermell, però reporta.

  1. 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 test

Què 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 flaky

Pas 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') }), []);
  });
});
rm test/reserves-avui.test.js
npm test    # verd, i verd SEMPRE, a qualsevol hora, en qualsevol zona

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 OK

El 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

  1. 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:

  1. Dos dels quatre jobs de Proves en vermell (els que contenen aquest fitxer segons el shard).
  2. L'artefacte tests-node20-shard1 descarregable malgrat la fallada, gràcies a if: always().
  3. L'anotació vermella sobre la línia de l'assert.
  4. Construir omès.
  5. CI OK en vermell, no gris.
  6. El botó de merge deshabilitat.

Reverteix el canvi abans de continuar.

  1. 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 OK que 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

Mòdul 2: Integració Contínua (CI)

Mòdul 3: Desplegament Continu (CD)

Mòdul 4: Pràctiques Avançades de CI/CD

Mòdul 5: Implementació de CI/CD en Projectes Reals

Mòdul 6: Eines i Tecnologies

Mòdul 7: Exercicis Pràctics

Mòdul 8: Recursos Addicionals

© Copyright 2026. Tots els drets reservats