El pipeline de Reservalia ja construeix un artefacte reproduïble, però construir no és el mateix que funcionar: tsc pot compilar sense queixar-se un codi que retorna la resposta equivocada. Les proves automatitzades són el que converteix el verd del pipeline en una afirmació amb contingut. En aquesta lliçó veurem quins tipus de prova existeixen i quins té sentit executar a cada pull request i quins no, perquè ficar-ho tot és la via més ràpida a un pipeline de 40 minuts que l'equip acaba ignorant. Escriurem codi real amb Vitest: una prova unitària de la lògica de disponibilitat de cites i una d'integració contra el PostgreSQL del services: que vam aixecar a la 02-02. Parlarem de cobertura sense convertir-la en un objectiu, dedicarem un apartat sencer al destructor més gran de confiança en un pipeline —les proves inestables— i tancarem amb la paral·lelització i amb el criteri de què bloqueja exactament un merge. El que no tocarem aquí són els linters i l'anàlisi estàtica, que són la lliçó 02-05.

Contingut

  1. La piràmide de proves aplicada al pipeline
  2. Què s'executa a cada pull request i què no
  3. Una prova unitària de la disponibilitat de cites
  4. Una prova d'integració contra PostgreSQL
  5. Cobertura de codi: un senyal, no un objectiu
  6. Proves inestables (flaky) i política de quarantena
  7. Paral·lelització i matrix
  8. El job test de Reservalia i què bloqueja el merge
  9. Errors Comuns i Consells
  10. Exercicis
  11. Conclusió

  1. La piràmide de proves aplicada al pipeline

La piràmide de proves diu una cosa molt simple: moltes proves ràpides i barates a baix, poques lentes i cares a dalt. La raó no és estètica, és econòmica: cada nivell és aproximadament un ordre de magnitud més lent i més fràgil que l'anterior.

flowchart TD
    E["E2E · poques desenes · minuts"] --> C["Contracte · desenes · segons"]
    C --> I["Integracio · centenars · segons"] --> U["Unitaries · milers · millisegons"]
Tipus Què verifica Què necessita Velocitat típica Fragilitat
Unitària Una funció o classe aïllada Res extern 1-10 ms Molt baixa
Integració Diverses peces juntes: codi + base de dades PostgreSQL real 50-500 ms Baixa
Contracte Que api i web continuen entenent-se Un esquema compartit 10-100 ms Baixa
End-to-end (E2E) Un recorregut complet d'usuari Tot el sistema desplegat 5-60 s Alta

A Reservalia la traducció és directa. Unitària: donat un horari de 9:00 a 14:00 amb un descans al migdia, quins forats de 30 minuts queden lliures? Integració: en inserir una cita, la restricció de la base de dades impedeix crear-ne una altra a sobre? Contracte: el tipus Cita que retorna apps/api, és el que apps/web espera? —en un monorepo amb tipus compartits, bona part d'això ho fa tsc de franc—. E2E: un client entra a la web pública, tria negoci, dia i hora, i rep un correu de confirmació.

  1. Què s'executa a cada pull request i què no

És la decisió de disseny més important del pipeline de proves, i es regeix per una fórmula senzilla: valor de la informació ÷ temps que costa obtenir-la.

Prova A cada PR? Bloqueja el merge? Motiu
Unitàries Segons; atrapen la majoria d'errors de lògica
Integració Un parell de minuts; atrapen el que les unitàries no veuen
Contracte Barates i eviten trencar l'altra aplicació
E2E crítiques (2-3 recorreguts) És el flux que dona diners: reservar una cita
E2E completes (~40 recorreguts) No No 25 minuts; s'executen de nit sobre main
Rendiment, càrrega i seguretat No No Matèria de les lliçons 04-04 i 04-03

Reservalia acorda que el conjunt que bloqueja un PR ha de cabre en 10 minuts, la regla 5 de l'acord de la 02-01. Els recorreguts E2E complets s'executen en un flux de treball a part, programat amb schedule de matinada: si alguna cosa es trenca allà, apareix a primera hora com a incidència, no com un PR bloquejat.

El parany del "posem-ho tot al PR". És una decisió que sembla prudent i que es paga en confiança: quan el pipeline triga 40 minuts, l'equip comença a fusionar sense esperar, a rellançar sense mirar i a considerar el vermell un soroll de fons. Un conjunt de proves més petit que es respecta protegeix més que un d'enorme que s'ignora.

  1. Una prova unitària de la disponibilitat de cites

La lògica viu a apps/api/src/domini/agenda.ts, amb aquesta signatura —horari és la jornada del negoci, ocupats són cites i descansos, i duracioMin la durada del servei—:

export interface Interval { inici: string; fi: string }   // "HH:MM"
export function calcularForats(
  horari: Interval, ocupats: Interval[], duracioMin: number,
): Interval[] { /* ... */ }

I aquesta és la prova, a apps/api/tests/unitat/agenda.test.ts:

import { describe, it, expect } from 'vitest';
import { calcularForats } from '../../src/domini/agenda';

describe('calcularForats', () => {
  const jornada = { inici: '09:00', fi: '11:00' };

  it('retorna tots els forats quan l agenda es buida', () => {
    const forats = calcularForats(jornada, [], 30);
    expect(forats).toHaveLength(4);                      // 9:00, 9:30, 10:00, 10:30
    expect(forats[0]).toEqual({ inici: '09:00', fi: '09:30' });
  });

  it('exclou l interval d una cita existent', () => {
    const forats = calcularForats(jornada, [{ inici: '09:30', fi: '10:00' }], 30);
    expect(forats.map(f => f.inici)).toEqual(['09:00', '10:00', '10:30']);
  });

  it('no ofereix un forat que se solapi amb el descans', () => {
    const descans = [{ inici: '09:45', fi: '10:15' }];   // ← el cas de l error real
    expect(calcularForats(jornada, descans, 30).map(f => f.inici))
      .toEqual(['09:00', '10:30']);
  });

  it('no ofereix un forat que se surti de la jornada', () => {
    expect(calcularForats({ inici: '09:00', fi: '09:40' }, [], 30))
      .toEqual([{ inici: '09:00', fi: '09:30' }]);
  });
});

Quatre coses que fan bona aquesta prova:

  • describe agrupa i it descriu un comportament en llenguatge natural. Quan falla, el nom del test ja t'explica què s'ha trencat sense obrir el codi.
  • No hi ha rellotge ni base de dades. Totes les dades són literals del mateix test. Per això triga millisegons i no pot ser inestable.
  • El tercer cas és l'error real d'en Diego: un forat de 30 minuts que comença a les 9:30 se solapa amb un descans que arrenca a les 9:45. Tota correcció d'un error hauria de començar per una prova que el reprodueixi; així el pipeline garanteix que no torna.
  • Els casos límit estan coberts: agenda buida, ocupació parcial, solapament i forat que no hi cap. S'executa amb npm run test:unitat --workspace apps/api, que per sota és vitest run tests/unitat.

  1. Una prova d'integració contra PostgreSQL

Les proves unitàries no veuen les restriccions de la base de dades: que l'aplicació calculi bé els forats no impedeix que dues peticions simultànies creïn dues cites solapades, i això només ho garanteix PostgreSQL.

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

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

beforeEach(async () => {
  await pool.query('TRUNCATE cites, negocis RESTART IDENTITY CASCADE');  // 2
  await pool.query(`INSERT INTO negocis (id, nom, hora_obertura, hora_tancament)
                    VALUES (1, 'Perruqueria Sol', '09:00', '14:00')`);
});

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

describe('crearCita', () => {
  it('desa una cita valida', async () => {
    const cita = await crearCita(pool, {
      negociId: 1, inici: '2026-03-02T10:00:00+01:00', duracioMin: 30,
    });
    const { rows } = await pool.query('SELECT * FROM cites WHERE id = $1', [cita.id]);
    expect(rows).toHaveLength(1);
    expect(rows[0].negoci_id).toBe(1);
  });

  it('rebutja una cita que se solapa amb una altra existent', async () => {
    await crearCita(pool, { negociId: 1, inici: '2026-03-02T10:00:00+01:00', duracioMin: 30 });
    await expect(
      crearCita(pool, { negociId: 1, inici: '2026-03-02T10:15:00+01:00', duracioMin: 30 }),
    ).rejects.toThrow('solapament');                                     // 4
  });
});
  1. DATABASE_URL ve de l'entorn, no està escrita al codi. En local la posa el docker-compose.yml; en CI, el services: de la 02-02. La prova és idèntica als dos llocs. L'esquema es crea abans, executant npm run migrate, de manera que el pipeline verifica de passada que les migracions funcionen (lliçó 04-06).
  2. beforeEach amb TRUNCATE és la clau de l'aïllament: cada prova parteix d'una base coneguda. Sense això, l'ordre d'execució canvia el resultat, i això és exactament una prova inestable.
  3. afterAll tanca el pool. Si no ho fas, el procés de Vitest no acaba i el job es queda penjat fins al timeout-minutes. (4) El segon cas només es pot verificar aquí: el rebuig el produeix una restricció d'exclusió de PostgreSQL, no el codi de l'aplicació.

En local n'hi ha prou amb docker compose up -d seguit d'npm run test:integracio --workspace apps/api.

  1. Cobertura de codi: un senyal, no un objectiu

La cobertura mesura quin percentatge del codi s'ha executat durant les proves. Es genera amb npm run test --workspace apps/api -- --coverage i es configura així a apps/api/vitest.config.ts:

coverage: {
  provider: 'v8',
  reporter: ['text', 'lcov', 'json-summary'],             // consola, eines, resum
  exclude: ['**/migracions/**', '**/*.d.ts', 'tests/**'], // el que no aporta senyal
  thresholds: { lines: 70, functions: 70, branches: 60 },
}

Per publicar-la com a resum del job, $GITHUB_STEP_SUMMARY és un fitxer especial: el que hi escriguis apareix a la pàgina del flux de treball, sense cap eina externa.

      - name: Publicar resum de cobertura
        if: always()                                        # encara que alguna prova falli
        run: |
          echo "### Cobertura d apps/api" >> $GITHUB_STEP_SUMMARY
          npx nyc report --reporter=text-summary >> $GITHUB_STEP_SUMMARY

Ara la part incòmoda. La cobertura mesura el que s'executa, no el que es comprova. Una prova com it('no trenca res', () => { calcularForats(jornada, [], 30); }) —sense un sol expect— dona el 100 % de cobertura d'aquesta funció sense verificar absolutament res.

Per això el llindar és un senyal: serveix per detectar que un mòdul nou ha entrat sense cap prova, no per certificar qualitat. Com fer-lo servir bé:

  • Fixa el llindar en el nivell actual, no en un ideal. Si avui ets al 68 %, posa 68 i puja'l quan el superis de manera natural. Un llindar inassolible s'acaba desactivant.
  • Vigila la cobertura del codi nou, no la global —és la idea del quality gate de "nou codi net" de la 02-05— i exclou el que no aporta: migracions, configuració, tipus.
  • No converteixis mai la cobertura en un objectiu d'equip. És un cas de manual de la llei de Goodhart (lliçó 01-05): tan bon punt es premia el número, apareixen proves sense assercions que el pugen sense verificar res.

  1. Proves inestables (flaky) i política de quarantena

Una prova inestable és la que, sense canviar el codi, unes vegades passa i altres falla. És el problema més corrosiu d'un pipeline, perquè destrueix el significat del vermell: si el vermell pot ser "mala sort", ningú no el torna a investigar.

6.1. Per què apareixen

Causa Exemple a Reservalia Com s'arregla
Temps real Una prova fa servir new Date() i falla a mitjanit o en una altra zona horària Injectar la data o congelar el rellotge
Ordre d'execució Una prova deixa cites que una altra troba TRUNCATE al beforeEach
Concurrència Dues proves en paral·lel comparteixen la mateixa base Base o esquema per procés
Esperes fixes await sleep(500) confiant que n'hi ha prou Esperar a la condició, no al rellotge
Recursos externs Una prova crida un servei real o demana el port 3000 Doble de prova, port dinàmic

6.2. Com detectar-les i què fer-ne

La manera més simple és repetir: npx vitest run tests/integracio/cites.test.ts --repeat 20 delata una prova que falla una de cada vint vegades. Al pipeline, un flux de treball nocturn que executa tot el conjunt diverses vegades sobre el mateix commit troba les inestables abans que les trobi un company a les sis de la tarda; registrar cada fallada (prova, data, commit) permet veure quines reincideixen.

La política de quarantena de Reservalia. Quan es detecta una prova inestable, el mateix dia: es marca com a omesa amb referència explícita a la incidència —it.skip('envia el recordatori 24 h abans [INESTABLE · RES-412]', ...)—; s'obre una incidència amb l'enllaç a l'execució que va fallar i a la que va passar; s'assigna a algú amb data límit, perquè sense data la quarantena es converteix en un cementiri; i si en dues setmanes ningú no l'arregla, s'esborra, ja que una prova desactivada indefinidament dona una falsa sensació de cobertura.

I reintentar a cegues és un parany. Moltes eines ofereixen retry: 3. És temptador i és un error, per tres motius: amaga errors reals de concurrència que també passaran en producció, amb clients de debò; emmascara el deteriorament, perquè una prova que falla 2 de cada 3 vegades continua passant el pipeline; i castiga el temps, ja que els reintents multipliquen la durada dels pitjors casos. Si tot i així els necessites com a mesura temporal, que sigui amb mètrica: registra quantes proves van necessitar reintent i tracta aquest número com a deute a reduir.

  1. Paral·lelització i matrix

Hi ha dues maneres d'accelerar, i no són el mateix. El paral·lelisme dins del job el fa Vitest per defecte, executant fitxers en diversos processos: és gratis per a les unitàries, però per a les d'integració exigeix que cada procés tingui el seu propi espai de dades, o tornaràs al problema de l'apartat anterior.

El repartiment entre jobs (sharding) divideix el conjunt en trossos que corren en runners diferents. Cada job paga la seva pròpia arrencada (checkout + npm ci), així que només compensa quan el conjunt dura diversos minuts:

  test-unitat:
    runs-on: ubuntu-22.04
    strategy:
      fail-fast: false                     # que un shard vermell no cancelli els altres
      matrix:
        shard: [1, 2, 3]                   # → tres jobs en parallel
    steps:
      # ... checkout, setup-node i npm ci ...
      - run: npx vitest run tests/unitat --shard=${{ matrix.shard }}/3

matrix per a diverses versions de Node. El mateix mecanisme serveix per provar en diversos entorns alhora: n'hi ha prou amb matrix: { node: ['20.11.0', '22.4.0'] } i passar node-version: ${{ matrix.node }} a setup-node. Reservalia fixa Node 20.11.0, així que avui no ho necessita; sí que ho necessitaria una llibreria que declari admetre diverses versions. Compte amb el creixement combinatori: 3 versions × 3 sistemes són 9 jobs, i 8 d'ells no t'explicaran res de nou.

  1. El job test de Reservalia i què bloqueja el merge

Substituïm el job test provisional de la 02-02 per la seva versió definitiva, ordenada per donar retroalimentació ràpida:

  test:
    name: Proves
    runs-on: ubuntu-22.04
    timeout-minutes: 15
    services:
      postgres:                          # el mateix bloc de la llico 02-02,
        image: postgres:16.3             # amb el seu healthcheck de pg_isready
        env: { POSTGRES_USER: reservalia, POSTGRES_PASSWORD: ci, POSTGRES_DB: reservalia_test }
        ports: ['5432:5432']
        options: >-
          --health-cmd "pg_isready -U reservalia -d reservalia_test"
          --health-interval 5s --health-timeout 3s --health-retries 10
    env:
      TZ: Europe/Madrid
      DATABASE_URL: postgres://reservalia:ci@localhost:5432/reservalia_test
    steps:
      # ... checkout, setup-node i npm ci ...
      - name: Proves unitaries           # ~40 s · talla abans si falla res evident
        run: npm run test:unitat --workspaces --if-present

      - name: Migracions sobre la base de proves
        run: npm run migrate --workspace apps/api

      - name: Proves d'integracio        # ~2 min
        run: npm run test:integracio --workspace apps/api

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

L'ordre unitàries → migracions → integració no és casual: el que és barat primer. Si una unitària falla, el job s'atura en 40 segons en comptes de en tres minuts. Què bloqueja el merge a Reservalia: el check test complet (unitàries, migracions i integració), el check qualitat de la lliçó següent i el check build. Què no bloqueja: la cobertura per sota del llindar —s'informa al resum i es comenta a la revisió— i el conjunt E2E complet nocturn. La configuració tècnica d'aquests checks obligatoris és la lliçó 02-07.

Errors Comuns i Consells

Error 1: proves que depenen del rellotge del sistema. new Date() dins de la lògica fa impossible provar un dilluns el que només passa un dissabte. Passa la data com a paràmetre: a més de fer la prova estable, millora el disseny. Error 2: no aïllar l'estat entre proves d'integració; sense TRUNCATE al beforeEach, les proves es contaminen i l'ordre decideix el resultat, que és la fàbrica número u d'inestabilitat.

Error 3: convertir la cobertura en un objectiu. El resultat previsible són proves sense assercions que pugen el número sense verificar res. Error 4: ficar les E2E completes a cada PR, la manera més ràpida d'arribar als 40 minuts i perdre la confiança de l'equip. Error 5: normalitzar el reintent, perquè retry: 3 amaga problemes de concurrència que sí que apareixeran en producció, on no hi ha reintents.

Consell 1: cada error corregit comença per una prova que el reprodueix. És la millor font de proves útils que existeix, perquè cobreix exactament allò que el sistema ja va demostrar no saber fer. Consell 2: quan una prova falli, llegeix-la abans que el codi —moltes vegades la fallada és a la prova— i mesura el temps de les teves proves: vitest --reporter=verbose t'explicarà quines són les cinc més lentes, i arreglar aquestes cinc sol reduir a la meitat el temps total.

Exercicis

Exercici 1

Classifica cada prova (unitària, integració, contracte o E2E) i digues si hauria de bloquejar un PR:

  1. Que calcularForats respecta un descans al migdia.
  2. Que un client pot reservar des de la web pública i rep el correu.
  3. Que la restricció de la base de dades impedeix dues cites solapades.
  4. Que el JSON de GET /cites/:id encaixa amb el tipus Cita de tipus-compartits.
  5. Que l'API respon en menys de 200 ms amb 500 peticions per segon.

Exercici 2

Aquesta prova falla en CI aproximadament una vegada de cada cinc, sempre a la nit. Identifica-hi dos problemes i reescriu-la.

it('crea la cita per a dema', async () => {
  const dema = new Date(Date.now() + 24 * 60 * 60 * 1000);
  await crearCita(pool, { negociId: 1, inici: dema.toISOString(), duracioMin: 30 });
  const { rows } = await pool.query('SELECT * FROM cites');
  expect(rows).toHaveLength(1);
});

Exercici 3

L'equip proposa: "pugem el llindar de cobertura al 95 % i així garantim la qualitat". Dona tres arguments tècnics en contra i una alternativa concreta.

Solucions

Solució 1. (1) Unitària, bloqueja: millisegons, lògica pura. (2) E2E, bloqueja només si és un dels 2-3 recorreguts crítics —ho és: reservar una cita és el flux que genera ingressos; la resta d'E2E van de nit—. (3) Integració, bloqueja: només la base de dades ho pot garantir. (4) Contracte, bloqueja: és barata i evita trencar apps/web. (5) Rendiment, no bloqueja: lenta i sorollosa en un runner compartit, i és matèria de la 04-04.

Solució 2. Els dos problemes: (a) fa servir Date.now(), així que a les 23:30 de Barcelona "demà" cau en un altre dia en UTC i el càlcul es desplaça —d'aquí el "sempre a la nit"—; (b) no aïlla l'estat, perquè SELECT * FROM cites compta totes les files, incloses les que hi deixin altres proves, i toHaveLength(1) acaba depenent de l'ordre d'execució.

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

it('crea la cita per al dia indicat', async () => {
  const inici = '2026-03-02T10:00:00+01:00';           // data fixa i explicita
  const cita = await crearCita(pool, { negociId: 1, inici, duracioMin: 30 });
  const { rows } = await pool.query('SELECT * FROM cites WHERE id = $1', [cita.id]);
  expect(rows).toHaveLength(1);                        // consulta acotada a aquesta cita
});

I al pipeline, TZ: Europe/Madrid elimina la classe sencera de problemes horaris.

Solució 3. Tres arguments: (1) la cobertura mesura execució, no verificació, així que es pot arribar al 95 % amb proves sense una sola asserció; (2) el darrer tram, del 80 % al 95 %, sol consistir en gestors d'error i branques defensives el cost de prova de les quals és alt i el valor baix, i aquest temps no es dedica a provar bé la lògica de negoci; (3) un llindar inassolible acaba desactivat o esquivat amb exclusions, amb la qual cosa es perd també el senyal que sí que servia. Alternativa: fixar el llindar global en el valor actual (perquè no baixi) i aplicar un quality gate sobre el codi nou —per exemple, 80 % de cobertura a les línies que el PR afegeix o modifica—, juntament amb l'exigència que tota correcció d'un error vingui acompanyada de la prova que el reprodueix.

Conclusió

Reservalia ja sap si el seu codi funciona, i ho sap ràpid:

  • La piràmide de proves ordena l'esforç: milers d'unitàries en millisegons, centenars d'integració amb PostgreSQL real, unes desenes de contracte i molt poques E2E. I no tot va a cada PR: unitàries, integració, contracte i dues o tres E2E crítiques bloquegen el merge; el conjunt E2E complet, el rendiment i la seguretat s'executen a part, amb el criteri que el conjunt bloquejant càpiga en 10 minuts.
  • Les proves unitàries de calcularForats no toquen rellotge ni base de dades, i una d'elles reprodueix l'error real del descans al migdia. Les d'integració fan servir la DATABASE_URL de l'entorn, s'aïllen amb TRUNCATE al beforeEach i verifiquen l'única cosa que el codi no pot garantir per si sol: la restricció de solapament de la base de dades.
  • La cobertura es genera amb --coverage, es publica a $GITHUB_STEP_SUMMARY i s'interpreta com a senyal: llindar realista, atenció al codi nou i mai com a objectiu d'equip.
  • Les proves inestables tenen causes identificables —temps, ordre, concurrència, esperes fixes, recursos externs—, es detecten repetint l'execució i es gestionen amb una quarantena amb responsable i data límit. Reintentar a cegues amaga problemes que sí que passaran en producció.
  • La paral·lelització per shards i la matrix de versions acceleren, però cada job paga la seva pròpia arrencada. I el job test definitiu ordena la feina de barat a car —unitàries, migracions, integració, cobertura— sobre el services: de PostgreSQL 16.3 i amb TZ: Europe/Madrid.

Ens queda una família sencera de problemes que cap prova no detecta: codi que funciona però és incoherent, il·legible o innecessàriament complicat. A la lliçó següent, Qualitat de Codi i Anàlisi Estàtica, veurem per què això pertany al pipeline i no a la revisió humana, en què es diferencien exactament Prettier, ESLint i tsc --noEmit amb exemples del que cadascun detecta i els altres no, què és un quality gate i per què el criteri ha de ser "nou codi net" en comptes d'arreglar tot el deute de cop. I afegirem al ci.yml el job qualitat.

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