La lliçó anterior va deixar les quatre capes de Nómada Tasques cobertes per proves unitàries, cadascuna aïllada de totes les altres. I aquí hi ha el punt cec: totes aquelles proves donen per suposat un contracte que ningú ha comprovat. Que el JSON que produeix toJSON() és exactament el que desDeJSON sap llegir. Que el que desa RepositoriLocal és el que Tauler.importar espera rebre. Que el data-id que escriu pintarTargeta és el que el controlador delegat de 06-04 sap interpretar. Cada peça compleix la seva part amb dobles que responen just el que la prova els ha dit, i les peces reals no s'han mirat mai a la cara. Aquesta lliçó munta les costures: Tauler amb RepositoriLocal de debò, la vista completa renderitzada en un DOM sense navegador amb jsdom, consultes per rol accessible amb Testing Library, clics i escriptura reals amb user-event, i el recorregut sencer formulari → model → render. I acabaràs sabent per què les proves d'integració són les que més fallades troben per línia escrita… i també les que amb més facilitat es tornen intermitents.

Contingut

  1. Què significa integrar
  2. Les cinc fallades que només apareixen en ajuntar peces
  3. On encaixa la integració: piràmide i testing trophy
  4. Integrar model i dades: Tauler + RepositoriLocal
  5. El viatge d'anada i tornada: toJSON → cadena → desDeJSON
  6. Migracions de versió
  7. jsdom: un DOM sense navegador
  8. Muntar l'HTML mínim i renderitzar
  9. Testing Library: consultar com ho faria una persona
  10. Les consultes i quan fer servir cadascuna
  11. user-event: interactuar de debò
  12. Provar la delegació d'esdeveniments
  13. El flux complet: formulari → model → render
  14. Provar els errors de xarxa a la interfície
  15. MSW: simular el servidor al nivell correcte
  16. Cobertura combinada: què hi afegeix la integració
  17. Proves intermitents i com fer-les deterministes
  18. La suite d'integració de Nómada Tasques
  19. Errors Habituals i Consells
  20. Exercicis
  21. Conclusió

  1. Què significa integrar

Una prova unitària comprova una peça aïllada, substituint tot el que l'envolta. Una prova d'integració comprova diverses peces reals treballant juntes, substituint només el que és fora del sistema: la xarxa, el rellotge, el disc.

flowchart TD
    subgraph U["Prova UNITÀRIA de Tauler"]
        T1["Tauler real"] --> D1["Tasca real"]
        T1 -.-> R1["RepositoriLocal<br/>DOBLE"]
        style R1 fill:#fecaca,stroke:#b91c1c
    end
    subgraph I["Prova d'INTEGRACIÓ"]
        T2["Tauler real"] --> D2["Tasca real"]
        T2 --> R2["RepositoriLocal REAL"]
        R2 --> A2["magatzem en memòria<br/>DOBLE (és a fora)"]
        style R2 fill:#bbf7d0,stroke:#15803d
        style A2 fill:#fde68a,stroke:#b45309
    end

La frontera es traça així: dins del sistema, tot real; fora del sistema, dobles. RepositoriLocal és codi teu, així que en integració hi va el de debò. localStorage és del navegador, així que se substitueix. L'API de tasques viu en un altre servidor, així que se simula.

El que es guanya és exactament el que les unitàries no poden donar: la comprovació que els contractes entre els teus mòduls es compleixen.

  1. Les cinc fallades que només apareixen en ajuntar peces

No són fallades hipotètiques: són les cinc famílies que apareixen una vegada i una altra en qualsevol projecte.

Família Què passa Exemple a Nómada Tasques
Contracte mal entès A produeix una forma, B n'espera una altra toJSON() emet horesEstimades; importar llegeix hores
Tipus a la costura La dada creua una frontera i canvia de tipus L'id que surt com a cadena de dataset o de l'API (cas 1 de 08-01)
Formats de data Cada capa assumeix el seu El model fa servir '2026-09-20'; algú desa un Date que se serialitza amb hora i zona
Errors que ningú captura Cada capa creu que l'altra ho gestiona El repositori llança i la vista no s'ho espera: pantalla en blanc
Ordre i cicle de vida Es fa servir una cosa abans que existeixi El controlador es connecta abans que la vista hagi renderitzat els nodes

Les cinc tenen una cosa en comú: cada peça, per separat, funciona perfectament. Les seves proves unitàries són en verd. La fallada viu a l'espai entre elles, i aquest espai no el cobreix cap prova unitària perquè el doble sempre respon el que la prova espera.

L'exemple del format de data és especialment instructiu:

// La vista desa un Date perquè l'<input type="date"> l'hi ha donat així
tasca.dataLimit = new Date('2026-09-30');

// El model compara cadenes ISO
estaVencuda(dataLimit, estat, avui) { return dataLimit < avui && estat !== 'feta'; }
// new Date(...) < '2026-09-20'  →  comparació entre objecte i cadena: sempre false

// I en serialitzar
JSON.stringify(tasca)   // "dataLimit": "2026-09-30T00:00:00.000Z"   ← ja no casa amb 'yyyy-MM-dd'

Cap prova unitària ho detecta: la de la vista comprova que desa el que rep, la del model comprova amb cadenes, i la del repositori serialitza el que li donin. Només en ajuntar-les es veu que la tasca no apareix mai vençuda i que en recarregar la data canvia de format.

  1. On encaixa la integració: piràmide i testing trophy

A la piràmide de 08-03, la integració és el nivell intermedi: més lenta i menys precisa que la unitària, més ràpida i estable que la d'extrem a extrem.

Però hi ha un model alternatiu que encaixa millor amb aplicacions d'interfície: el testing trophy.

flowchart TD
    E["E2E · poques<br/>recorreguts crítics"]
    I["INTEGRACIÓ · la majoria<br/>el millor equilibri confiança/cost"]
    U["Unitàries · les justes<br/>lògica amb molts casos"]
    S["Estàtiques · ESLint, @ts-check<br/>la base, gratis i sempre activa"]
    E --> I --> U --> S
    style I fill:#bbf7d0,stroke:#15803d
    style S fill:#e0e7ff,stroke:#4338ca

El seu argument: la base del trofeu són les comprovacions estàtiques de 08-02, que costen gairebé zero i s'executen contínuament; i el cos ample és la integració, perquè en una aplicació d'interfície la majoria de les fallades reals viuen a les costures, no dins d'una funció.

Les dues formes conviuen bé si s'entén la regla que hi ha a sota:

Tipus de codi Nivell que més rendeix
Lògica pura amb molts casos (validacions, càlculs, transicions) Unitari: nou combinacions de R6 en nou línies
Col·laboració entre mòduls propis (model + dades, vista + model) Integració
Recorreguts complets que importen al negoci E2E, pocs i ben triats

Nómada Tasques hi encaixa perfectament: el model, ple de regles, es va cobrir a 08-03 amb 48 proves unitàries; la vista i la persistència es cobreixen aquí; i a 08-06 quedaran tres recorreguts E2E.

  1. Integrar model i dades: Tauler + RepositoriLocal

Primera costura. Peces reals: Tasca, Tauler, RepositoriLocal. Doble: només el magatzem, perquè localStorage és del navegador.

// proves/integracio/persistencia.test.js
import { Tauler } from '../../js/model/tauler.js';
import { RepositoriLocal } from '../../js/dades/repositori-local.js';
import { magatzemFals } from '../ajudes/magatzem-fals.js';
import { unTauler, AVUI } from '../ajudes/backlog-de-prova.js';

describe('Integració · Tauler ↔ RepositoriLocal', () => {
  let magatzem, repositori;

  beforeEach(() => {
    magatzem = magatzemFals();
    repositori = new RepositoriLocal({ magatzem });   // ← el repositori REAL
  });

  test('el tauler desat i recuperat conserva els números canònics', () => {
    repositori.desar(unTauler());

    const recuperat = repositori.carregar();

    expect(recuperat).toBeInstanceOf(Tauler);
    expect(recuperat.resum(AVUI)).toStrictEqual({
      total: 6, obertes: 5, horesTotals: 48,
      horesObertes: 45, vencudes: 1, esforc: 124
    });
  });

  test("els canvis d'estat sobreviuen al viatge complet", () => {
    const tauler = unTauler();
    tauler.canviarEstat(2, 'en-curs');
    tauler.canviarEstat(1, 'feta');

    repositori.desar(tauler);
    const recuperat = repositori.carregar();

    expect(recuperat.cercarPerId(2).estat).toBe('en-curs');
    expect(recuperat.cercarPerId(1).estat).toBe('feta');
    expect(recuperat.horesObertes).toBe(33);          // 45 − 12
  });

  test('el que es recupera són instàncies de Tasca amb tots els seus mètodes', () => {
    repositori.desar(unTauler());

    const tasca = repositori.carregar().cercarPerId(6);

    // No n'hi ha prou que les dades hi siguin: han de ser objectes vius
    expect(tasca.estaVencuda(AVUI)).toBe(true);       // el mètode existeix i funciona
    expect(tasca.esforc).toBe(15);                    // 5 h × pes 3 de prioritat alta
    expect(() => tasca.canviarEstat('feta')).toThrow();   // R6 continua vigent
  });

  test('el que es desa es revalida en carregar: una dada que viola R3 es rebutja', () => {
    const avisos = jest.spyOn(console, 'warn').mockImplementation(() => {});
    magatzem.setItem('nomada:tauler:v1', JSON.stringify({
      nom: 'Taller Nómada', versio: 1,
      tasques: [{ id: 1, titol: 'Manipulada', horesEstimades: 999, dataLimit: '2026-10-01' }]
    }));

    expect(repositori.carregar()).toBeNull();         // ← mai es confia en el magatzem
    expect(avisos).toHaveBeenCalled();

    avisos.mockRestore();
  });
});

La tercera prova és la que justifica el nivell d'integració. Comprovar que les dades hi són seria una prova unitària del repositori; comprovar que el que es recupera té estaVencuda(), calcula esforc i continua aplicant R6 és comprovar que el contracte entre les dues capes és intacte. Si algú "optimitzés" carregar() retornant objectes plans en lloc d'instàncies, les dades continuarien sent correctes i l'aplicació es trencaria sencera. Aquesta prova ho impedeix.

La quarta és igual d'important i expressa una política de seguretat: localStorage és editable per l'usuari des del panell Application de 08-01. Mai es confia en el que surt del magatzem, i aquesta prova blinda aquella decisió.

  1. El viatge d'anada i tornada: toJSON → cadena → desDeJSON

La costura més subtil, i la que reprèn directament 04-08 i 07-01. El recorregut té cinc salts i a cadascun s'hi pot perdre alguna cosa:

flowchart LR
    A["Tasca<br/>amb #estat privat"] -->|toJSON| B["objecte pla"]
    B -->|JSON.stringify| C["cadena de text"]
    C -->|setItem| D["localStorage"]
    D -->|getItem + JSON.parse| E["objecte pla"]
    E -->|desDeJSON| F["Tasca<br/>reconstruïda"]

Què es perd a cada salt si alguna cosa no està bé:

Salt Què es pot perdre Com es detecta
toJSON Els camps privats (#estat, #hores): no apareixen sols La tasca torna sempre en 'pendent'
JSON.stringify Els undefined, les funcions, els Map/Set Un camp desapareix sense avís
setItem Res, però tot es converteix en cadena Un número desat torna com a text
JSON.parse Les classes: tot torna com a objecte pla instanceof Tasca dóna false
desDeJSON Res, si revalida Dades invàlides entren al model

Les proves que blinden el recorregut complet:

describe("Integració · el viatge d'anada i tornada", () => {
  test('el camp privat estat sobreviu al viatge sencer', () => {
    const tauler = unTauler();
    tauler.canviarEstat(2, 'en-curs');

    // El viatge complet, salt a salt, escrit a mà per veure'l
    const objecte = tauler.toJSON();
    const cadena = JSON.stringify(objecte);
    magatzem.setItem('nomada:tauler:v1', cadena);
    const recuperat = Tauler.importar(JSON.parse(magatzem.getItem('nomada:tauler:v1')));

    expect(recuperat.cercarPerId(2).estat).toBe('en-curs');
  });

  test('cap camp no es perd pel camí', () => {
    const original = unTauler();

    repositori.desar(original);
    const recuperat = repositori.carregar();

    // Comparem les representacions completes: si falta un camp, salta aquí
    expect(recuperat.toJSON()).toStrictEqual(original.toJSON());
  });

  test('les dates continuen sent cadenes ISO de 10 caràcters, no objectes Date', () => {
    repositori.desar(unTauler());

    for (const tasca of repositori.carregar()) {
      expect(typeof tasca.dataLimit).toBe('string');
      expect(tasca.dataLimit).toMatch(/^\d{4}-\d{2}-\d{2}$/);
    }
  });

  test('un revisor null es conserva com a null, no es converteix en undefined (R8)', () => {
    repositori.desar(unTauler());

    const tasca = repositori.carregar().cercarPerId(2);   // la 2 no té revisor

    expect(tasca.revisor).toBeNull();
    expect('revisor' in tasca.toJSON()).toBe(true);        // la clau EXISTEIX
  });

  test('les etiquetes es conserven com a array, no com a cadena', () => {
    repositori.desar(unTauler());

    expect(repositori.carregar().cercarPerId(1).etiquetes).toEqual(['espai', 'disseny']);
  });
});

La prova del revisor és un cas real que mossega molta gent. Si toJSON retornés revisor: undefined en lloc de null, JSON.stringify eliminaria la clau sencera (04-08). En reconstruir, dades.revisor seria undefined, el ?? null del constructor el salvaria per casualitat, i tot semblaria funcionar… fins que algú comparés 'revisor' in dades i obtingués false. La prova fixa el contracte de manera explícita.

I la de les dates és exactament la fallada de l'apartat 2: una cadena de deu caràcters, no un Date.

  1. Migracions de versió

RepositoriLocal fa servir la clau nomada:tauler:v1. El dia que el format canviï —perquè s'afegeix un camp obligatori, o se'n reanomena un altre— hi haurà usuaris amb dades del format antic al seu navegador. Ignorar-los significa perdre la seva feina.

Suposem que la versió 2 reanomena horesEstimades a hores i afegeix creada:

// js/dades/migracions.js
const MIGRACIONS = {
  1: (dades) => ({
    ...dades,
    versio: 2,
    tasques: dades.tasques.map((t) => ({
      ...t,
      hores: t.horesEstimades,          // reanomenat
      creada: t.creada ?? '2026-01-01', // camp nou, amb valor raonable
      horesEstimades: undefined
    }))
  })
};

/** Aplica les migracions necessàries fins a arribar a la versió actual. */
export function migrar(dades, versioActual = 2) {
  let actuals = dades;
  while ((actuals.versio ?? 1) < versioActual) {
    const migracio = MIGRACIONS[actuals.versio ?? 1];
    if (!migracio) throw new ErrorDeDades(`No hi ha migració des de la versió ${actuals.versio}`);
    actuals = migracio(actuals);
  }
  return actuals;
}

I les proves d'integració, que són de les més valuoses que existeixen perquè protegeixen dades de persones reals:

describe('Integració · migracions de format', () => {
  test('un tauler en format v1 es migra i conserva les hores', () => {
    magatzem.setItem('nomada:tauler:v1', JSON.stringify({
      nom: 'Taller Nómada', versio: 1,
      tasques: [{ id: 1, titol: 'Redissenyar la sala', responsable: 'Iván', prioritat: 'alta',
                  estat: 'en-curs', etiquetes: ['espai'], horesEstimades: 12,
                  dataLimit: '2026-09-30', revisor: 'Marta' }]
    }));

    const tauler = repositori.carregar();

    expect(tauler.total).toBe(1);
    expect(tauler.cercarPerId(1).hores).toBe(12);         // migrat
    expect(tauler.cercarPerId(1).creada).toBe('2026-01-01');  // emplenat
  });

  test('un tauler ja en v2 es carrega sense tocar', () => {
    const original = unTauler();
    repositori.desar(original);

    expect(repositori.carregar().toJSON()).toStrictEqual(original.toJSON());
  });

  test('un format futur desconegut no destrueix les dades', () => {
    magatzem.setItem('nomada:tauler:v1', JSON.stringify({ versio: 99, tasques: [] }));

    expect(repositori.carregar()).toBeNull();             // es descarta amb cura…
    expect(magatzem.getItem('nomada:tauler:v1')).not.toBeNull();   // …però NO s'esborra
  });
});

Aquesta última prova codifica una decisió important: davant de dades que no s'entenen, descartar en memòria però no esborrar del magatzem. Si l'usuari ha obert per error una versió antiga de l'aplicació, les seves dades continuen allà quan torni a la nova.

  1. jsdom: un DOM sense navegador

Segona costura: la vista. Tot el Mòdul 6 viu sobre document, que a Node no existeix. jsdom és una implementació dels estàndards del DOM i HTML escrita en JavaScript pur: crea document, window, Element, Event, localStorage i centenars d'APIs més, en memòria i sense finestra.

npm install --save-dev jest-environment-jsdom

Es pot activar globalment o —millor— fitxer a fitxer, amb un comentari a la capçalera:

/**
 * @jest-environment jsdom
 */
import { TaulerVista } from '../../js/vista/tauler-vista.js';

Així les proves del model continuen a l'entorn node, que és més ràpid, i només les que necessiten DOM paguen el cost de muntar-lo.

O per patró de fitxers, que és el més còmode quan n'hi ha moltes:

// jest.config.js
export default {
  projects: [
    {
      displayName: 'unitaries',
      testEnvironment: 'node',
      testMatch: ['**/proves/{model,util,dades}/**/*.test.js'],
      transform: {}
    },
    {
      displayName: 'integracio',
      testEnvironment: 'jsdom',
      testMatch: ['**/proves/integracio/**/*.test.js'],
      transform: {}
    }
  ]
};

Què sí i què no ofereix jsdom. És important saber-ho per no perseguir fallades inexistents:

jsdom que implementa jsdom no implementa
L'arbre del DOM, querySelector, classList, dataset Disseny i pintat: totes les mesures són 0
Esdeveniments, propagació, delegació, CustomEvent getBoundingClientRect() retorna zeros
Formularis, FormData, validació de restriccions scrollIntoView, IntersectionObserver (cal simular-los)
localStorage, sessionStorage Service workers i Cache API
fetch (a versions recents) i AbortController Renderitzat de CSS: getComputedStyle és limitat
Accessibilitat bàsica: rols implícits, aria-* Comportaments reals del navegador (focus entre finestres, historial complet)

D'aquesta llista se'n deriva la regla del mòdul: el que depengui de píxels, de pintat o d'un service worker no es prova en jsdom. Va a les proves d'extrem a extrem de 08-06, en un navegador de debò.

  1. Muntar l'HTML mínim i renderitzar

TaulerVista espera uns contenidors i unes <template> que a l'aplicació viuen a index.html. A la prova es munten a mà, amb el mínim imprescindible:

// proves/ajudes/montar-dom.js

/** Estructura mínima que TaulerVista necessita per funcionar. */
const HTML = `
  <main>
    <form id="form-tasca" novalidate>
      <label for="titol">Títol</label>
      <input id="titol" name="titol" required minlength="3">

      <label for="responsable">Responsable</label>
      <select id="responsable" name="responsable">
        <option value="">Sense assignar</option>
        <option value="Iván">Iván</option>
        <option value="Lucía">Lucía</option>
        <option value="Marta">Marta</option>
      </select>

      <label for="horesEstimades">Hores estimades</label>
      <input id="horesEstimades" name="horesEstimades" type="number" min="1" max="40" required>

      <label for="dataLimit">Data límit</label>
      <input id="dataLimit" name="dataLimit" type="date" required>

      <label for="etiquetes">Etiquetes</label>
      <input id="etiquetes" name="etiquetes">

      <button type="submit">Crear tasca</button>
      <div id="errors-form" role="alert" hidden></div>
    </form>

    <div id="tauler"></div>
    <output id="resum" aria-live="polite"></output>
  </main>

  <template id="plantilla-columna">
    <section class="columna"><h2 class="columna__titol"></h2><ul class="columna__llista"></ul></section>
  </template>

  <template id="plantilla-tasca">
    <li class="tasca">
      <h3 class="tasca__titol"></h3>
      <p class="tasca__meta"></p>
      <ul class="tasca__etiquetes"></ul>
      <button data-accio="avancar"></button>
      <button data-accio="reobrir">Reobrir</button>
    </li>
  </template>
`;

export function montarDom() {
  document.body.innerHTML = HTML;
  return {
    contenidor: document.querySelector('#tauler'),
    resum: document.querySelector('#resum'),
    formulari: document.querySelector('#form-tasca')
  };
}

export function netejarDom() {
  document.body.innerHTML = '';
}

Dues decisions d'aquesta ajuda mereixen explicació:

  • Cada <input> té la seva <label for>. No és decoració: és el que permetrà consultar per getByLabelText, i de passada obliga que el formulari sigui accessible. Hi tornarem a l'apartat 10.
  • L'HTML és el mínim, no una còpia d'index.html. Copiar l'HTML real lliga la prova a cada canvi de maquetació. Es munta el que la vista necessita, i res més.

I la primera prova de renderitzat:

/**
 * @jest-environment jsdom
 */
import { TaulerVista } from '../../js/vista/tauler-vista.js';
import { montarDom, netejarDom } from '../ajudes/montar-dom.js';
import { unTauler, AVUI } from '../ajudes/backlog-de-prova.js';

describe('Integració · TaulerVista sobre el DOM', () => {
  let dom, tauler, vista;

  beforeEach(() => {
    dom = montarDom();
    tauler = unTauler();
    vista = new TaulerVista({ contenidor: dom.contenidor, resum: dom.resum, tauler, avui: AVUI });
    vista.render();
  });

  afterEach(() => { netejarDom(); });

  test('pinta les sis tasques repartides a les seves tres columnes', () => {
    expect(document.querySelectorAll('[data-id]')).toHaveLength(6);
    expect(document.querySelectorAll('[data-estat="pendent"]')).toHaveLength(3);
    expect(document.querySelectorAll('[data-estat="en-curs"]')).toHaveLength(2);
    expect(document.querySelectorAll('[data-estat="feta"]')).toHaveLength(1);
  });

  test("marca visualment l'única tasca vençuda (R10)", () => {
    const vencudes = document.querySelectorAll('.tasca--vencuda');

    expect(vencudes).toHaveLength(1);
    expect(vencudes[0].dataset.id).toBe('6');
    expect(vencudes[0].textContent).toContain('Pressupost de la fusteria');
  });

  test('el resum mostra les 45 hores obertes', () => {
    expect(dom.resum.textContent).toContain('45');
  });

  test('reconciliar reaprofita els nodes en lloc de recrear-los', () => {
    const nodeAbans = document.querySelector('[data-id="2"]');

    tauler.canviarEstat(2, 'en-curs');
    vista.actualitzar();

    const nodeDespres = document.querySelector('[data-id="2"]');

    expect(nodeDespres).toBe(nodeAbans);                   // ← el MATEIX node (toBe, identitat)
    expect(nodeDespres.dataset.estat).toBe('en-curs');     // …actualitzat
  });
});

L'última prova és de les més valuoses del fitxer, i només és possible en integració. reconciliar() existeix precisament per no destruir nodes i així conservar el focus i les transicions (06-06). Comprovar-ho requereix un DOM real i la vista real, i es fa amb toBe sobre nodes: identitat, no contingut. És l'ús deliberat de toBe amb objectes del qual parlava 08-03.

  1. Testing Library: consultar com ho faria una persona

querySelectorAll('.tasca--vencuda') funciona, però té un problema de fons: prova la implementació. El dia que algú reanomeni la classe a .tasca--endarrerida, la prova es posa vermella sense que res estigui trencat.

Testing Library proposa una altra cosa: consultar el DOM com ho faria una persona —o un lector de pantalla—. Pel seu rol, pel seu text, per la seva etiqueta.

npm install --save-dev @testing-library/dom @testing-library/user-event @testing-library/jest-dom
import { screen, within } from '@testing-library/dom';
import '@testing-library/jest-dom';        // matchers com toBeVisible, toHaveTextContent

La diferència, amb el mateix objectiu:

// ❌ Acoblada a la implementació: es trenca en reanomenar una classe
expect(document.querySelector('.tasca__titol').textContent).toBe('Redissenyar la sala polivalent');
const boto = document.querySelector('[data-accio="avancar"]');

// ✅ Acoblada al que percep l'usuari: sobreviu a qualsevol refactor de CSS
expect(screen.getByRole('heading', { name: 'Redissenyar la sala polivalent' })).toBeVisible();
const boto = screen.getByRole('button', { name: 'Començar: Redissenyar la sala polivalent' });

I l'argument decisiu, que gairebé ningú esmenta la primera vegada: si no pots consultar un element pel seu rol o el seu text accessible, probablement un lector de pantalla tampoc no el pugui trobar. La prova es torna una auditoria d'accessibilitat contínua. Un <div onclick> no té rol de botó: no apareix a getByRole('button') i tampoc és assolible amb el teclat. La prova t'obliga a arreglar-ho, i arreglar-ho millora l'aplicació de debò.

Consultar per… Robustesa davant de refactors Relació amb l'accessibilitat
Classe CSS Molt baixa Cap
id o selector d'estructura Baixa Cap
data-testid Alta Cap
Text accessible Alta Directa
Rol + nom accessible Alta Directa

data-testid és el recurs per al que no té rol ni text —un contenidor decoratiu, una regió sense nom—. És legítim, però ha de ser l'excepció: cada data-testid és una consulta que no comprova res sobre l'experiència real.

  1. Les consultes i quan fer servir cadascuna

Testing Library té tres famílies de consultes, i triar malament produeix proves que fallen sense motiu:

Prefix Si no troba Si troba diversos Espera Quan fer-lo servir
getBy… Llança error Llança error No L'element ha de ser-hi ja
queryBy… Retorna null Llança error No Comprovar que no hi és
findBy… Llança després del temps límit Llança error (retorna promesa) L'element apareixerà després d'un await
…AllBy… Variant que retorna array Diversos elements
// getBy: ja està renderitzat
const titol = screen.getByRole('heading', { name: 'Actualitzar el web de reserves' });

// queryBy: l'única manera correcta d'afirmar una absència
expect(screen.queryByText('Carregant…')).not.toBeInTheDocument();

// findBy: espera que aparegui (després d'una petició, d'un render asíncron…)
const avis = await screen.findByRole('alert');

// AllBy: diversos
expect(screen.getAllByRole('listitem')).toHaveLength(6);

L'error clàssic: fer servir getBy per comprovar que una cosa no hi és. getByText('Carregant…') llança si no el troba, així que la prova falla precisament quan el comportament és correcte. Per a absències, sempre queryBy.

Les consultes per rol, ordenades per freqüència d'ús en un projecte com aquest:

screen.getByRole('button', { name: 'Crear tasca' });
screen.getByRole('heading', { name: /redissenyar la sala/i });   // regex: tolerant a majúscules
screen.getByRole('textbox', { name: 'Títol' });                  // input de text, per la seva <label>
screen.getByRole('combobox', { name: 'Responsable' });           // <select>
screen.getByRole('spinbutton', { name: 'Hores estimades' });     // input type=number
screen.getByRole('list');                                        // <ul> / <ol>
screen.getByRole('listitem');                                    // <li>
screen.getByRole('alert');                                       // role="alert" o aria-live=assertive
screen.getByRole('status');                                      // aria-live="polite"
screen.getByLabelText('Data límit');                             // per la seva etiqueta
screen.getByText(/45 h obertes/);                                // pel seu text

I within per acotar la cerca a una regió, que és imprescindible quan el mateix text apareix a diverses columnes:

const columnaPendents = screen.getByRole('region', { name: 'Pendents' });
const tasques = within(columnaPendents).getAllByRole('listitem');

expect(tasques).toHaveLength(3);
expect(within(tasques[0]).getByRole('button')).toHaveTextContent('Començar');

Els matchers de @testing-library/jest-dom que més es fan servir:

expect(element).toBeInTheDocument();
expect(element).toBeVisible();                  // considera hidden, display:none, visibility
expect(boto).toBeDisabled();
expect(camp).toHaveValue('Revisar els extintors');
expect(camp).toBeInvalid();                     // aria-invalid o validació nativa
expect(element).toHaveClass('tasca--vencuda');
expect(element).toHaveTextContent(/45/);
expect(camp).toHaveFocus();
expect(camp).toHaveAccessibleName('Hores estimades');

  1. user-event: interactuar de debò

Disparar element.dispatchEvent(new MouseEvent('click')) produeix un esdeveniment. Quan una persona prem un botó, el navegador produeix una seqüència: pointerdown, mousedown, focus, pointerup, mouseup, click. Si el teu codi escolta mousedown o depèn del focus, la prova amb un sol esdeveniment passa i l'aplicació real falla.

user-event simula la seqüència completa:

import userEvent from '@testing-library/user-event';

test('escriure al formulari dispara la validació després del retard', async () => {
  const usuari = userEvent.setup();           // ← una vegada per prova

  await usuari.type(screen.getByLabelText('Títol'), 'Revisar els extintors');
  await usuari.selectOptions(screen.getByLabelText('Responsable'), 'Marta');
  await usuari.click(screen.getByRole('button', { name: 'Crear tasca' }));
});

Les accions més útils:

Acció Què simula
click(el) La seqüència completa de pulsació, amb focus
dblClick(el) Doble pulsació
type(el, text) Tecla a tecla, amb keydown/keypress/input/keyup per caràcter
clear(el) Seleccionar-ho tot i esborrar
selectOptions(el, valor) Triar en un <select>
tab() Moure el focus amb el tabulador
keyboard('{Enter}') Pulsacions concretes: {Enter}, {Escape}, {ArrowDown}
hover(el) / unhover(el) Entrada i sortida del punter

type escrivint tecla a tecla és el que permet provar de debò el debounce de 06-07 en el seu context, no aïlladament com a 08-04.

I un avís important sobre temporitzadors falsos. user-event fa servir temporitzadors internament per simular el ritme d'escriptura. Si combines jest.useFakeTimers() amb user-event, cal avisar-lo:

beforeEach(() => { jest.useFakeTimers(); });

test('el cercador filtra 300 ms després de deixar de teclejar', async () => {
  const usuari = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });

  await usuari.type(screen.getByLabelText('Cercar'), 'fuster');

  expect(screen.getAllByRole('listitem')).toHaveLength(6);   // encara sense filtrar

  jest.advanceTimersByTime(300);

  expect(screen.getAllByRole('listitem')).toHaveLength(1);   // ja filtrat
});

Sense l'opció advanceTimers, user-event es queda esperant un rellotge que no avança i la prova es penja fins a esgotar el temps límit. És un dels embussos més freqüents en començar, i ara ja saps què mirar.

  1. Provar la delegació d'esdeveniments

Aquí es comprova de debò el mecanisme de 06-04. connectarAccions posa un sol oient al contenidor i fa servir closest('[data-accio]') per identificar el botó. Aquesta arquitectura té una virtut que només es demostra en integració: funciona amb targetes que no existien quan es va connectar l'oient.

describe("Integració · delegació d'esdeveniments (06-04)", () => {
  let usuari, dom, tauler, vista;

  beforeEach(() => {
    usuari = userEvent.setup();
    dom = montarDom();
    tauler = unTauler();
    vista = new TaulerVista({ contenidor: dom.contenidor, resum: dom.resum, tauler, avui: AVUI });
    vista.render();
    connectarAccions({ contenidor: dom.contenidor, tauler, vista });
  });

  afterEach(() => { netejarDom(); });

  test("prémer Començar canvia l'estat al model i a la vista", async () => {
    const targeta = screen.getByRole('listitem', { name: /cartelleria del taller/i });

    await usuari.click(within(targeta).getByRole('button', { name: /començar/i }));

    expect(tauler.cercarPerId(2).estat).toBe('en-curs');                // el MODEL
    expect(targeta.dataset.estat).toBe('en-curs');                      // la VISTA
    expect(within(targeta).getByRole('button', { name: /marcar feta/i })).toBeInTheDocument();
  });

  test('el resum es recalcula després de cada acció', async () => {
    expect(dom.resum).toHaveTextContent(/45/);

    const targeta = screen.getByRole('listitem', { name: /redissenyar la sala/i });
    await usuari.click(within(targeta).getByRole('button', { name: /marcar feta/i }));

    expect(dom.resum).toHaveTextContent(/33/);                          // 45 − 12
  });

  test('el botó de la tasca feta està desactivat i no fa res', async () => {
    const targeta = screen.getByRole('listitem', { name: /inventari de tintes/i });
    const boto = within(targeta).getByRole('button', { name: /completada/i });

    expect(boto).toBeDisabled();

    await usuari.click(boto);

    expect(tauler.cercarPerId(4).estat).toBe('feta');                   // sense canvis
  });

  test("la delegació funciona amb targetes creades DESPRÉS de connectar l'oient", async () => {
    tauler.afegir(unaTasca({ id: 7, titol: 'Revisar els extintors', estat: 'pendent',
                             horesEstimades: 2, responsable: 'Marta' }));
    vista.actualitzar();

    const nova = screen.getByRole('listitem', { name: /revisar els extintors/i });
    await usuari.click(within(nova).getByRole('button', { name: /començar/i }));

    expect(tauler.cercarPerId(7).estat).toBe('en-curs');
  });

  test('un clic al buit entre targetes no trenca res', async () => {
    await usuari.click(dom.contenidor);

    expect(tauler.resum(AVUI).horesObertes).toBe(45);
  });
});

La quarta prova és la que demostra el valor de la delegació: amb oients individuals per botó, aquella targeta nova no en tindria cap i el clic no faria res. La cinquena protegeix el closest() que retorna null quan es prem fora d'un botó: sense la comprovació, seria un TypeError.

  1. El flux complet: formulari → model → render

La costura més llarga de l'aplicació, i la que travessa més contractes: FormData → normalització → validació de la vista → validació del model (regles R2, R3, R6…) → Tauler.afegir → esdeveniment → render → persistència.

flowchart LR
    A["L'usuari escriu<br/>i envia"] --> B["FormData<br/>+ normalitzar"]
    B --> C["Validació<br/>de la vista"]
    C -->|errors| D["aria-invalid<br/>+ focus al camp"]
    C -->|ok| E["new Tasca()<br/>regles R2/R3/R8/R9"]
    E -->|ErrorDeValidacio| D
    E --> F["tauler.afegir<br/>R1: id únic"]
    F --> G["esdeveniment TASCA_CREADA"]
    G --> H["render"]
    G --> I["repositori.desar"]
describe('Integració · formulari → model → render (06-07)', () => {
  let usuari, dom, tauler, vista, repositori;

  beforeEach(() => {
    usuari = userEvent.setup();
    dom = montarDom();
    tauler = unTauler();
    repositori = new RepositoriLocal({ magatzem: magatzemFals() });
    vista = new TaulerVista({ contenidor: dom.contenidor, resum: dom.resum, tauler, avui: AVUI });
    vista.render();
    connectarFormulari({ formulari: dom.formulari, tauler, seguentId: () => 7,
                         enCrear: () => { vista.actualitzar(); repositori.desar(tauler); }, avui: AVUI });
  });

  afterEach(() => { netejarDom(); });

  async function omplir({ titol = 'Revisar els extintors', responsable = 'Marta',
                          hores = '2', data = '2026-10-20', etiquetes = 'seguretat' } = {}) {
    await usuari.type(screen.getByLabelText('Títol'), titol);
    if (responsable) await usuari.selectOptions(screen.getByLabelText('Responsable'), responsable);
    await usuari.type(screen.getByLabelText('Hores estimades'), hores);
    await usuari.type(screen.getByLabelText('Data límit'), data);
    if (etiquetes) await usuari.type(screen.getByLabelText('Etiquetes'), etiquetes);
  }

  test('una tasca vàlida arriba al model, a la vista i al magatzem', async () => {
    await omplir();
    await usuari.click(screen.getByRole('button', { name: 'Crear tasca' }));

    // 1 · El model
    expect(tauler.total).toBe(7);
    expect(tauler.cercarPerId(7)).toMatchObject({
      titol: 'Revisar els extintors', responsable: 'Marta',
      horesEstimades: 2, estat: 'pendent'                       // R5
    });

    // 2 · La vista
    expect(screen.getByRole('listitem', { name: /revisar els extintors/i })).toBeVisible();

    // 3 · El magatzem
    expect(repositori.carregar().total).toBe(7);

    // 4 · El formulari s'ha buidat
    expect(screen.getByLabelText('Títol')).toHaveValue('');
  });

  test('les etiquetes es normalitzen segons R9 a tot el recorregut', async () => {
    await omplir({ etiquetes: 'Seguretat, SEGURETAT , extintors' });
    await usuari.click(screen.getByRole('button', { name: 'Crear tasca' }));

    expect(tauler.cercarPerId(7).etiquetes).toEqual(['seguretat', 'extintors']);
    expect(repositori.carregar().cercarPerId(7).etiquetes).toEqual(['seguretat', 'extintors']);
  });

  test("un títol buit mostra l'error accessible i NO toca el model", async () => {
    await omplir({ titol: '' });
    await usuari.click(screen.getByRole('button', { name: 'Crear tasca' }));

    expect(tauler.total).toBe(6);                               // el model intacte
    expect(screen.getByRole('alert')).toBeVisible();
    expect(screen.getByLabelText('Títol')).toBeInvalid();       // aria-invalid="true"
    expect(screen.getByLabelText('Títol')).toHaveFocus();       // el focus va al camp
  });

  test("99 hores incompleix R3 i l'error identifica el camp correcte", async () => {
    await omplir({ hores: '99' });
    await usuari.click(screen.getByRole('button', { name: 'Crear tasca' }));

    expect(tauler.total).toBe(6);
    expect(screen.getByRole('alert')).toHaveTextContent(/40/);
    expect(screen.getByLabelText('Hores estimades')).toBeInvalid();
  });

  test('una data límit anterior a avui es rebutja (R4)', async () => {
    await omplir({ data: '2026-09-01' });
    await usuari.click(screen.getByRole('button', { name: 'Crear tasca' }));

    expect(tauler.total).toBe(6);
    expect(screen.getByLabelText('Data límit')).toBeInvalid();
  });

  test("després de corregir l'error, el segon enviament funciona i els avisos desapareixen", async () => {
    await omplir({ titol: '' });
    await usuari.click(screen.getByRole('button', { name: 'Crear tasca' }));

    await usuari.type(screen.getByLabelText('Títol'), 'Revisar els extintors');
    await usuari.click(screen.getByRole('button', { name: 'Crear tasca' }));

    expect(tauler.total).toBe(7);
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();   // ← queryBy per a l'absència
  });

  test('el formulari es pot completar i enviar només amb el teclat', async () => {
    await usuari.tab();                                     // Títol
    await usuari.keyboard('Revisar els extintors');
    await usuari.tab();                                     // Responsable
    await usuari.keyboard('Marta');
    await usuari.tab();                                     // Hores
    await usuari.keyboard('2');
    await usuari.tab();                                     // Data
    await usuari.keyboard('2026-10-20');
    await usuari.tab();                                     // Etiquetes
    await usuari.tab();                                     // Botó
    await usuari.keyboard('{Enter}');

    expect(tauler.total).toBe(7);
  });
});

Set proves que cobreixen el recorregut sencer, inclosa la que gairebé ningú escriu: el segon enviament després de corregir un error. Netejar l'estat d'error és de les coses que més sovint s'obliden, i produeix formularis que mostren un avís vermell eternament. I l'última —completar el formulari només amb el teclat— comprova d'una vegada l'ordre de tabulació, el focus i l'enviament amb Enter: tres propietats d'accessibilitat que cap prova unitària no toca.

  1. Provar els errors de xarxa a la interfície

Última costura: què veu l'usuari quan la xarxa falla. A 08-04 vas comprovar que api-tasques.js produeix l'ErrorDeApi correcte; aquí comproves que la màquina d'estats d'interfície de 07-03 reacciona com ha de fer.

describe("Integració · estats d'interfície davant de fallades de xarxa (07-03)", () => {
  let usuari, dom, xarxa;

  beforeEach(() => {
    usuari = userEvent.setup();
    dom = montarDom();
    xarxa = instalarFetchFals();
  });

  afterEach(() => { netejarDom(); jest.restoreAllMocks(); });

  test('mostra "carregant", després les sis tasques', async () => {
    let resoldre;
    xarxa.mockReturnValue(new Promise((r) => { resoldre = r; }));

    const carrega = arrencarAplicacio({ dom });

    expect(screen.getByRole('status')).toHaveTextContent(/carregant/i);

    resoldre(respostaJson(dadesBacklog));
    await carrega;

    expect(await screen.findAllByRole('listitem')).toHaveLength(6);
    expect(screen.queryByText(/carregant/i)).not.toBeInTheDocument();
  });

  test('un 500 mostra un missatge accessible i un botó de reintentar', async () => {
    xarxa.mockResolvedValue(respostaJson({ missatge: 'Error intern' }, { status: 500 }));

    await arrencarAplicacio({ dom });

    const avis = await screen.findByRole('alert');
    expect(avis).toHaveTextContent(/servidor/i);
    expect(screen.getByRole('button', { name: /reintentar/i })).toBeVisible();
  });

  test('el botó de reintentar torna a demanar i mostra les tasques', async () => {
    xarxa.mockResolvedValueOnce(respostaJson({ missatge: 'Error' }, { status: 500 }))
         .mockResolvedValueOnce(respostaJson(dadesBacklog));

    await arrencarAplicacio({ dom });
    await usuari.click(await screen.findByRole('button', { name: /reintentar/i }));

    expect(await screen.findAllByRole('listitem')).toHaveLength(6);
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
    expect(xarxa).toHaveBeenCalledTimes(2);
  });

  test("una llista buida mostra l'estat buit, no un error", async () => {
    xarxa.mockResolvedValue(respostaJson([]));

    await arrencarAplicacio({ dom });

    expect(await screen.findByText(/no hi ha tasques/i)).toBeVisible();
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  });

  test("sense connexió, s'avisa i es conserva el que hi hagués al magatzem", async () => {
    const magatzem = magatzemFals();
    new RepositoriLocal({ magatzem }).desar(unTauler());
    xarxa.mockRejectedValue(new TypeError('Failed to fetch'));

    await arrencarAplicacio({ dom, magatzem });

    expect(await screen.findByRole('alert')).toHaveTextContent(/connexió/i);
    expect(screen.getAllByRole('listitem')).toHaveLength(6);   // ← el que és local continua visible
  });
});

La primera prova fa servir una tècnica que convé conèixer: una promesa la resolució de la qual es controla des de la prova (let resoldre). És l'única manera d'observar l'estat intermedi «carregant», que amb un mockResolvedValue normal desapareixeria abans de poder comprovar-lo.

I l'última prova comprova la degradació elegant: sense xarxa, l'aplicació no es queda en blanc, mostra el que té desat i avisa. Aquest comportament travessa tres capes i no el cobreix cap prova unitària.

  1. MSW: simular el servidor al nivell correcte

Substituir globalThis.fetch funciona, però té un inconvenient: estàs simulant l'eina, no el servidor. Si demà una part del codi fa servir XMLHttpRequest, o navigator.sendBeacon, o un client HTTP diferent, el teu doble no ho cobreix. I les proves s'omplen de detalls de fetch que no tenen res a veure amb el negoci.

MSW (Mock Service Worker) resol això interceptant a la capa de xarxa: al navegador amb un service worker (els de 07-05), i a Node amb un interceptor de peticions. El codi sota prova fa peticions de debò; simplement no surten mai de la màquina.

// proves/ajudes/servidor-fals.js
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { dadesBacklog } from '../../js/dades/backlog.js';

const BASE = 'https://api.tallernomada.example/v1';

export const gestors = [
  http.get(`${BASE}/tasques`, ({ request }) => {
    const responsable = new URL(request.url).searchParams.get('responsable');
    const tasques = responsable
      ? dadesBacklog.filter((t) => t.responsable === responsable)
      : dadesBacklog;
    return HttpResponse.json(tasques);
  }),

  http.post(`${BASE}/tasques`, async ({ request }) => {
    const dades = await request.json();
    if (!dades.titol?.trim()) {
      return HttpResponse.json({ missatge: 'El títol és obligatori' }, { status: 422 });
    }
    return HttpResponse.json({ ...dades, id: 7, estat: 'pendent' }, { status: 201 });
  }),

  http.patch(`${BASE}/tasques/:id`, async ({ params, request }) =>
    HttpResponse.json({ ...dadesBacklog.find((t) => t.id === Number(params.id)),
                        ...(await request.json()) }))
];

export const servidor = setupServer(...gestors);
// Ús a les proves
beforeAll(() => servidor.listen({ onUnhandledRequest: 'error' }));
afterEach(() => servidor.resetHandlers());     // desfà les sobreescriptures per prova
afterAll(() => servidor.close());

test("un 503 puntual activa l'estat d'error", async () => {
  // Sobreescriure NOMÉS per a aquesta prova
  servidor.use(http.get(`${BASE}/tasques`, () =>
    HttpResponse.json({ missatge: 'No disponible' }, { status: 503 })));

  await arrencarAplicacio({ dom });

  expect(await screen.findByRole('alert')).toHaveTextContent(/servidor/i);
});
Substituir fetch MSW
Nivell d'intercepció La funció fetch La petició de xarxa
Cobreix altres clients HTTP No
Es reaprofita a E2E (08-06) No Sí, amb els mateixos handlers
Configuració Zero Un paquet i un setupServer
Llegibilitat Mitjana Alta: es llegeix com una API
Quan triar-lo Proves puntuals d'un mòdul Suites d'integració completes

onUnhandledRequest: 'error' mereix un comentari: fa fallar qualsevol petició que no tingui gestor. És una garantia valuosíssima —si un mòdul comença a cridar un endpoint nou, te n'assabentes—, i evita l'escenari silenciós en què una prova es connecta a internet de debò sense que ningú ho noti.

  1. Cobertura combinada: què hi afegeix la integració

npm test -- --coverage
File                     | % Stmts | % Branch | Δ vs. només unitàries
-------------------------|---------|----------|----------------------
 js/model/tasca.js       |   98.2  |   96.1   |  +1.8   (poc: ja hi era)
 js/model/tauler.js      |  100.0  |  100.0   |   0.0
 js/dades/repositori…    |   94.7  |   89.5   | +63.4   ← el salt gran
 js/vista/tauler-vista   |   88.3  |   76.2   | +88.3   ← de zero
 js/vista/targeta.js     |   95.1  |   88.9   | +95.1   ← de zero
 js/vista/controlador.js |   91.4  |   80.0   | +91.4   ← de zero
 js/vista/formulari.js   |   86.9  |   79.3   | +86.9   ← de zero

Dues lectures d'aquesta taula:

  • La integració amb prou feines mou la cobertura del model. És lògic: ja estava cobert per les unitàries, que a més ho fan millor (nou combinacions de R6 en nou línies). Duplicar aquella cobertura en integració seria pur cost.
  • Tota la capa de vista passa de zero a gairebé noranta. És on la integració aporta valor de manera exclusiva, perquè provar un render, una delegació o un formulari requereix un DOM.

I un advertiment que ja coneixes de 08-03, ara amb un matís nou: la cobertura d'integració és especialment enganyosa. Una sola prova que arrenqui l'aplicació sencera executa centenars de línies de cop i dispara els percentatges sense comprovar gairebé res. Els números pugen; la confiança, no necessàriament. Mira la columna branch i, sobretot, compta les assercions.

  1. Proves intermitents i com fer-les deterministes

Una prova flaky és la que de vegades passa i de vegades falla sense que el codi canviï. És més perjudicial que una prova que falla sempre, perquè ensenya l'equip a ignorar el vermell: «torna-la a llançar, segur que passa». Tan bon punt això arrela, la suite ha deixat de servir.

Les causes, en ordre de freqüència en proves d'integració:

Causa Símptoma Solució
Esperes per temps Falla en una màquina lenta o a CI findBy… / waitFor, mai un sleep
Estat compartit Falla només si una altra prova ha corregut abans beforeEach que reconstrueix tot, afterEach que neteja
Ordre d'execució Falla en executar en paral·lel o en reordenar Independència total; mai dependre de l'ordre
Dates reals Falla un dimarts, o el dia 1 de mes AVUI com a dada; temporitzadors falsos
Aleatorietat Falla una de cada vint vegades Fixar Math.random (08-04)
Promeses sense esperar Falla de manera impredictible await a tot; findBy… per al que apareix després

La primera és la reina, i el seu antídot convé veure'l amb detall:

// ❌ Fràgil: 100 ms pot bastar avui al teu portàtil i no bastar a CI
await new Promise((r) => setTimeout(r, 100));
expect(screen.getByRole('listitem')).toBeInTheDocument();

// ✅ Espera la CONDICIÓ, no un temps. Reintenta fins que es compleixi
expect(await screen.findByRole('listitem')).toBeInTheDocument();

// ✅ Per a condicions que no són "un element apareix"
await waitFor(() => {
  expect(tauler.total).toBe(7);
});

// ✅ Per esperar que alguna cosa DESAPAREGUI
await waitForElementToBeRemoved(() => screen.queryByText(/carregant/i));

findBy i waitFor reintenten la comprovació cada pocs mil·lisegons fins que passa o fins a esgotar el temps límit. En una màquina ràpida acaben en 5 ms; en una CI saturada, en 400. Un setTimeout fix, en canvi, o sobra temps (i la suite és lenta) o en falta (i falla). Mai esperis un temps: espera una condició.

Quatre mesures més que estabilitzen una suite d'integració:

// 1 · Detectar dependències d'ordre executant en ordre aleatori
//     jest --randomize

// 2 · Netejar SEMPRE el DOM i els dobles entre proves
afterEach(() => {
  document.body.innerHTML = '';
  jest.restoreAllMocks();
  localStorage.clear();                  // jsdom sí que l'implementa
});

// 3 · Fixar el rellotge quan el codi el llegeixi directament
beforeEach(() => { jest.useFakeTimers({ now: new Date('2026-09-20T09:00:00Z') }); });

// 4 · Fer fallar qualsevol petició no prevista
beforeAll(() => servidor.listen({ onUnhandledRequest: 'error' }));

I una política que convé acordar per escrit: una prova intermitent s'arregla o s'esborra; mai es reintenta a cegues. L'opció de reintentar automàticament les fallades existeix en alguns executors i és temptadora, però converteix un problema visible en un d'invisible: la prova continua delatant una cursa real al teu codi, i ara ja ningú la veu.

  1. La suite d'integració de Nómada Tasques

El mapa complet del que s'ha escrit en aquesta lliçó:

proves/
  integracio/
    persistencia.test.js       model + RepositoriLocal + migracions        (node)
    serialitzacio.test.js      el viatge toJSON → cadena → desDeJSON       (node)
    render.test.js             TaulerVista + targeta + reconciliar         (jsdom)
    delegacio.test.js          controlador + esdeveniments + model + vista (jsdom)
    formulari.test.js          formulari → model → render → magatzem       (jsdom)
    xarxa.test.js              estats d'interfície davant de fallades      (jsdom)
  ajudes/
    montar-dom.js              l'HTML mínim
    magatzem-fals.js           el fake de Web Storage (08-04)
    xarxa-falsa.js             respostes de fetch (08-04)
    servidor-fals.js           els gestors de MSW
    backlog-de-prova.js        AVUI, unaTasca, unTauler, capturar
$ npm test

 PASS  unitaries  proves/model/tasca.test.js
 PASS  unitaries  proves/model/tauler.test.js
 PASS  unitaries  proves/util/dates.test.js
 PASS  unitaries  proves/util/temps.test.js
 PASS  unitaries  proves/dades/api-tasques.test.js
 PASS  unitaries  proves/dades/http.test.js
 PASS  unitaries  proves/dades/repositori-local.test.js
 PASS  integracio  proves/integracio/persistencia.test.js
 PASS  integracio  proves/integracio/serialitzacio.test.js
 PASS  integracio  proves/integracio/render.test.js
 PASS  integracio  proves/integracio/delegacio.test.js
 PASS  integracio  proves/integracio/formulari.test.js
 PASS  integracio  proves/integracio/xarxa.test.js

Test Suites: 13 passed, 13 total
Tests:       124 passed, 124 total
Time:        3.71 s

Cent vint-i-quatre comprovacions en menys de quatre segons, sense obrir un navegador. I el pas a integració contínua és una línia del flux de 08-02, que ja estava preparat:

      - name: Executar les proves
        run: npm test -- --coverage --ci

Errors Habituals i Consells

  • Convertir una prova d'integració en una unitària amb dobles de més. Si substitueixes RepositoriLocal, ja no estàs provant la costura: només la peça. Real tot el que és teu; doble només el que és extern.
  • Copiar index.html sencer a la prova. Lliga la prova a cada canvi de maquetació. Munta el mínim que la vista necessita.
  • Consultar per classe CSS. Es trenca en reanomenar una classe, sense que res estigui trencat. Consulta per rol i nom accessible.
  • Fer servir getBy… per comprovar una absència. Llança si no troba, així que falla just quan el comportament és correcte. Per a absències, queryBy….
  • Oblidar await amb user-event o amb findBy…. Tots dos retornen promeses. Sense await, l'asserció s'executa abans que passi res.
  • Combinar jest.useFakeTimers() amb user-event sense advanceTimers. La prova es penja fins a esgotar el temps límit, i el missatge d'error no ajuda gens.
  • Esperar amb setTimeout en lloc de waitFor/findBy. És la causa número u de proves intermitents: el temps que basta al teu portàtil no basta a CI.
  • Buscar fallades de disseny visual en jsdom. No hi ha pintat: totes les mesures són zero i getComputedStyle és limitat. Això va a E2E, a 08-06.
  • No netejar el DOM ni l'emmagatzematge entre proves. jsdom comparteix document dins d'un mateix fitxer: la targeta de la prova anterior encara hi és.
  • Consell: si consultar per rol és impossible, arregla l'HTML. Un <div onclick> que no apareix a getByRole('button') tampoc és assolible amb el teclat. La prova t'està assenyalant un problema real.
  • Consell: escriu una prova d'integració per cada fallada de costura que trobis. Són les més rendibles: cobreixen moltes línies i detecten la classe de fallada que més cara surt a producció.
  • Consell: fes servir jest --randomize de tant en tant. És la manera més ràpida de descobrir dependències ocultes d'ordre entre proves.

Exercicis

Exercici 1 — La integració dels filtres amb la URL. Escriu proves/integracio/filtres.test.js que provi la costura completa entre encaminador.js (07-06), TaulerVista i el controlador, en jsdom. Ha de comprovar: (a) arrencar amb ?responsable=Iván a la URL mostra exactament 3 targetes i el resum continua dient 45 h obertes —perquè filtrar és presentació i no toca el model—; (b) prémer el filtre de la Lucía actualitza la URL amb pushState i deixa 1 targeta visible; (c) escriure al cercador fa servir replaceState i filtra 300 ms després de deixar de teclejar; (d) popstate (el botó enrere) restaura el filtre anterior. Recorda el detall dels temporitzadors falsos amb user-event.

Exercici 2 — Migració de format v1 → v2 amb dades reals. La versió 2 del format de RepositoriLocal afegeix un camp obligatori creada (data ISO) i converteix revisor de cadena a un objecte { nom, notificat }. Escriu la funció migrar i la seva suite d'integració, cobrint: un tauler v1 complet amb les 6 tasques del backlog que després de migrar conserva els números canònics; una tasca amb revisor: null que es converteix en revisor: null i no en { nom: null }; un tauler ja en v2 que no es toca; un format de versió desconeguda que es descarta sense esborrar l'original; i la comprovació que després de migrar i tornar a desar, la clau del magatzem és nomada:tauler:v2.

Exercici 3 — Diagnosticar tres proves intermitents. Aquestes tres proves fallen «de vegades» a la integració contínua i mai en local. Identifica la causa de cadascuna, explica en quines condicions falla i reescriu-la perquè sigui determinista.

// A
test('les tasques apareixen després de carregar', async () => {
  arrencarAplicacio({ dom });
  await new Promise((r) => setTimeout(r, 200));
  expect(screen.getAllByRole('listitem')).toHaveLength(6);
});

// B
const repositori = new RepositoriLocal({ magatzem: magatzemFals() });

test('desa el tauler', () => {
  repositori.desar(unTauler());
  expect(repositori.carregar().total).toBe(6);
});

test('netejar deixa el magatzem buit', () => {
  repositori.netejar();
  expect(repositori.carregar()).toBeNull();
});

// C
test('la tasca vençuda es marca', () => {
  const vista = new TaulerVista({ contenidor: dom.contenidor, tauler: unTauler() });
  vista.render();
  expect(document.querySelectorAll('.tasca--vencuda')).toHaveLength(1);
});

Solucions

Solució 1

/**
 * @jest-environment jsdom
 */
import { jest } from '@jest/globals';
import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';

import { TaulerVista } from '../../js/vista/tauler-vista.js';
import { connectarEncaminador, llegirEstatDeUrl } from '../../js/vista/encaminador.js';
import { connectarFiltres } from '../../js/vista/controlador.js';
import { montarDom, netejarDom } from '../ajudes/montar-dom.js';
import { unTauler, AVUI } from '../ajudes/backlog-de-prova.js';

describe('Integració · filtres ↔ URL (07-06)', () => {
  let usuari, dom, tauler, vista;

  function arrencar(cerca = '') {
    // jsdom permet reescriure la URL sense recarregar
    history.replaceState(null, '', `/${cerca}`);

    dom = montarDom();
    tauler = unTauler();
    vista = new TaulerVista({ contenidor: dom.contenidor, resum: dom.resum, tauler, avui: AVUI });
    vista.actualitzar({ filtres: llegirEstatDeUrl() });
    connectarFiltres({ contenidor: document.body, vista });
    connectarEncaminador(vista);
  }

  beforeEach(() => {
    jest.useFakeTimers();
    usuari = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });  // ← imprescindible
  });

  afterEach(() => {
    jest.useRealTimers();
    netejarDom();
    history.replaceState(null, '', '/');
  });

  test('(a) arrencar amb ?responsable=Iván mostra 3 targetes sense tocar el model', () => {
    arrencar('?responsable=Iván');

    expect(screen.getAllByRole('listitem')).toHaveLength(3);
    expect(tauler.total).toBe(6);                         // el MODEL no es filtra
    expect(dom.resum).toHaveTextContent(/45/);            // el resum és del tauler complet
  });

  test("(b) prémer el filtre de la Lucía afegeix una entrada a l'historial", async () => {
    arrencar();
    const abans = history.length;

    await usuari.click(screen.getByRole('button', { name: 'Lucía' }));

    expect(new URL(location.href).searchParams.get('responsable')).toBe('Lucía');
    expect(history.length).toBe(abans + 1);               // pushState, no replaceState
    expect(screen.getAllByRole('listitem')).toHaveLength(1);
  });

  test('(c) el cercador fa servir replaceState i filtra després de 300 ms', async () => {
    arrencar();
    const abans = history.length;

    await usuari.type(screen.getByLabelText('Cercar'), 'fuster');

    expect(screen.getAllByRole('listitem')).toHaveLength(6);   // encara sense filtrar

    jest.advanceTimersByTime(300);

    expect(screen.getAllByRole('listitem')).toHaveLength(1);
    expect(new URL(location.href).searchParams.get('q')).toBe('fuster');
    expect(history.length).toBe(abans);                        // replaceState: no creix
  });

  test('(d) popstate restaura el filtre anterior', async () => {
    arrencar();

    await usuari.click(screen.getByRole('button', { name: 'Iván' }));
    expect(screen.getAllByRole('listitem')).toHaveLength(3);

    // jsdom no navega sol: se simula l'esdeveniment amb l'estat que hi hauria
    history.replaceState({ responsable: null, text: '', ordre: 'prioritat' }, '', '/');
    window.dispatchEvent(new PopStateEvent('popstate', { state: { responsable: null } }));

    expect(screen.getAllByRole('listitem')).toHaveLength(6);
  });
});

Solució 2

// js/dades/migracions.js
import { ErrorDeDades } from '../model/errors.js';

export const VERSIO_ACTUAL = 2;

const MIGRACIONS = {
  1: (dades) => ({
    nom: dades.nom,
    versio: 2,
    tasques: dades.tasques.map((t) => ({
      ...t,
      creada: t.creada ?? '2026-01-01',
      // null es conserva com a null: NO s'embolcalla en un objecte buit
      revisor: t.revisor == null ? null : { nom: t.revisor, notificat: false }
    }))
  })
};

export function migrar(dades) {
  let actuals = dades;
  let voltes = 0;

  while ((actuals.versio ?? 1) < VERSIO_ACTUAL) {
    if (voltes++ > 10) throw new ErrorDeDades('Bucle de migració detectat');
    const pas = MIGRACIONS[actuals.versio ?? 1];
    if (!pas) throw new ErrorDeDades(`Sense migració des de v${actuals.versio}`);
    actuals = pas(actuals);
  }

  if ((actuals.versio ?? 1) > VERSIO_ACTUAL) {
    throw new ErrorDeDades(`Format v${actuals.versio} més recent que l'aplicació`);
  }
  return actuals;
}
// proves/integracio/migracions.test.js
import { jest } from '@jest/globals';
import { RepositoriLocal } from '../../js/dades/repositori-local.js';
import { magatzemFals } from '../ajudes/magatzem-fals.js';
import { unTauler, AVUI } from '../ajudes/backlog-de-prova.js';
import { dadesBacklog } from '../../js/dades/backlog.js';

const v1 = () => ({ nom: 'Taller Nómada', versio: 1, tasques: dadesBacklog });

describe('Integració · migració v1 → v2', () => {
  let magatzem, repositori, avisos;

  beforeEach(() => {
    magatzem = magatzemFals();
    repositori = new RepositoriLocal({ magatzem });
    avisos = jest.spyOn(console, 'warn').mockImplementation(() => {});
  });

  afterEach(() => { avisos.mockRestore(); });

  test('un tauler v1 complet migra conservant els números canònics', () => {
    magatzem.setItem('nomada:tauler:v1', JSON.stringify(v1()));

    const tauler = repositori.carregar();

    expect(tauler.total).toBe(6);
    expect(tauler.resum(AVUI)).toMatchObject({
      horesObertes: 45, horesTotals: 48, vencudes: 1, esforc: 124
    });
    expect(tauler.cercarPerId(1).creada).toBe('2026-01-01');
  });

  test('un revisor amb nom es converteix en objecte', () => {
    magatzem.setItem('nomada:tauler:v1', JSON.stringify(v1()));

    expect(repositori.carregar().cercarPerId(1).revisor)
      .toStrictEqual({ nom: 'Marta', notificat: false });
  });

  test('un revisor null continua sent null, no un objecte amb nom null', () => {
    magatzem.setItem('nomada:tauler:v1', JSON.stringify(v1()));

    const tasca = repositori.carregar().cercarPerId(2);     // la 2 no té revisor

    expect(tasca.revisor).toBeNull();
    expect(tasca.revisor).not.toStrictEqual({ nom: null, notificat: false });
  });

  test('un tauler ja en v2 no es toca', () => {
    const original = unTauler();
    repositori.desar(original);

    expect(repositori.carregar().toJSON()).toStrictEqual(original.toJSON());
  });

  test("una versió futura desconeguda es descarta SENSE esborrar l'original", () => {
    const futur = JSON.stringify({ nom: 'X', versio: 99, tasques: [] });
    magatzem.setItem('nomada:tauler:v2', futur);

    expect(repositori.carregar()).toBeNull();
    expect(magatzem.getItem('nomada:tauler:v2')).toBe(futur);   // intacte
    expect(avisos).toHaveBeenCalled();
  });

  test('després de migrar i desar, les dades viuen sota la clau v2', () => {
    magatzem.setItem('nomada:tauler:v1', JSON.stringify(v1()));

    repositori.desar(repositori.carregar());

    expect(magatzem.getItem('nomada:tauler:v2')).not.toBeNull();
    expect(JSON.parse(magatzem.getItem('nomada:tauler:v2')).versio).toBe(2);
  });
});

Solució 3

Prova Causa Quan falla
A Espera per temps fix (200 ms) i a més no espera arrencarAplicacio A CI, amb la màquina carregada, la càrrega triga 250 ms i getAllByRole llança perquè encara no hi ha cap <li>
B Estat compartit entre proves: un sol repositori de mòdul La segona prova depèn que la primera hagi desat. Si s'executa sola, o l'ordre canvia (--randomize), falla
C Data real: el TaulerVista es crea sense avui, així que fa servir la del sistema Passa avui i falla l'1 d'octubre de 2026, quan la tasca 3 també estarà vençuda i n'hi haurà 2
// A · corregida: esperar la CONDICIÓ, no un temps
test('les tasques apareixen després de carregar', async () => {
  await arrencarAplicacio({ dom });                        // ← esperar la promesa

  expect(await screen.findAllByRole('listitem')).toHaveLength(6);   // ← findBy reintenta
});

// B · corregida: estat nou a cada prova
describe('RepositoriLocal', () => {
  let magatzem, repositori;

  beforeEach(() => {
    magatzem = magatzemFals();
    repositori = new RepositoriLocal({ magatzem });
  });

  test('desar i carregar retorna les sis tasques', () => {
    repositori.desar(unTauler());

    expect(repositori.carregar().total).toBe(6);
  });

  test('netejar deixa el magatzem buit', () => {
    repositori.desar(unTauler());                          // ← prepara EL SEU propi estat

    repositori.netejar();

    expect(repositori.carregar()).toBeNull();
    expect(magatzem.length).toBe(0);
  });
});

// C · corregida: la data entra com a dada, i es consulta per rol
test('la tasca vençuda es marca visualment (R10)', () => {
  const vista = new TaulerVista({
    contenidor: dom.contenidor, resum: dom.resum,
    tauler: unTauler(), avui: AVUI                         // ← '2026-09-20', fix
  });
  vista.render();

  const vencuda = screen.getByRole('listitem', { name: /pressupost de la fusteria/i });

  expect(vencuda).toHaveClass('tasca--vencuda');
  expect(document.querySelectorAll('.tasca--vencuda')).toHaveLength(1);
});

Conclusió

Nómada Tasques ja no només té peces provades: té costures provades. Saps què distingeix una prova d'integració d'una unitària —dins del sistema tot real, fora del sistema dobles— i coneixes les cinc famílies de fallades que només apareixen en ajuntar: contractes mal entesos, tipus que canvien en creuar una frontera, formats de data divergents, errors que cada capa creu que gestiona l'altra, i problemes d'ordre i cicle de vida. Les cinc tenen en comú que cada peça, per separat, és en verd. Situes la integració a la piràmide i coneixes el testing trophy, que eixampla aquest nivell per a aplicacions d'interfície i posa les comprovacions estàtiques de 08-02 com a base gratuïta de tot.

Has muntat la costura de model i dades: Tauler amb RepositoriLocal real sobre un magatzem en memòria, comprovant no només que les dades tornen, sinó que tornen com a instàncies vives amb estaVencuda(), esforc i R6 vigent; que el que surt del magatzem es revalida sempre, perquè qualsevol el pot editar des del panell Application; i que el viatge complet toJSONstringifysetItemgetItemparsedesDeJSON no perd el camp privat #estat, no converteix les dates en objectes Date, conserva el revisor: null com a clau present en lloc de deixar que stringify l'esborri, i manté les etiquetes com a array. I has escrit les migracions de versió, aquelles proves poc glamuroses que protegeixen dades de persones reals, amb la política de descartar en memòria sense esborrar del magatzem el que no s'entén.

Has muntat la costura de la vista sense obrir un navegador: jsdom com a entorn, amb els seus límits ben delimitats —hi ha arbre, esdeveniments, formularis i localStorage; no hi ha pintat, ni mesures, ni service workers—, un HTML mínim muntat a mà en lloc d'una còpia d'index.html, i la comprovació que reconciliar() reaprofita el mateix node amb toBe, que és l'únic lloc on comparar identitat d'objectes és exactament el que vols. Amb Testing Library consultes com ho faria una persona: getByRole amb el seu nom accessible, getByLabelText, queryBy… per a les absències —mai getBy, que llança just quan el comportament és correcte— i findBy… per al que apareix després d'un await. I entens l'argument decisiu: si no pots trobar un element pel seu rol, un lector de pantalla tampoc; la prova es converteix en una auditoria d'accessibilitat contínua. Amb user-event simules seqüències completes d'interacció —tecla a tecla, amb focus, amb tabulació— i saps que combinar-lo amb temporitzadors falsos exigeix passar-li advanceTimers o la prova es penja.

Amb això has provat la delegació d'esdeveniments de 06-04, inclosa la propietat que la justifica —que funciona amb targetes creades després de connectar l'oient— i el clic al buit que retorna null a closest. Has provat el recorregut complet formulari → validació de vista → regles del model → tauler → render → magatzem, amb els casos que gairebé ningú escriu: el segon enviament després de corregir un error, i el formulari completat només amb el teclat. I has provat els estats d'interfície davant de fallades de xarxa, amb la tècnica de la promesa controlada des de la prova per poder observar el «carregant», el botó de reintentar de 07-03 i la degradació elegant que mostra el que està desat quan no hi ha connexió. Coneixes MSW i per què interceptar la petició és un nivell més correcte que substituir fetch, amb onUnhandledRequest: 'error' com a xarxa de seguretat davant de crides imprevistes. Saps llegir la cobertura combinada —la integració amb prou feines mou el model i aixeca la vista de zero— i saps que un sol arrencada de l'aplicació infla els percentatges sense comprovar gairebé res. I tens el catàleg de causes de les proves intermitents amb el seu antídot central: mai esperis un temps, espera una condició, amb findBy i waitFor en lloc de setTimeout; més --randomize per descobrir dependències d'ordre i la política d'arreglar o esborrar, mai reintentar a cegues.

Cent vint-i-quatre proves en menys de quatre segons, tretze suites, i tota l'aplicació coberta… llevat d'una cosa. Tot això s'executa en jsdom, que és una imitació del navegador: no pinta res, totes les mesures valen zero, getComputedStyle amb prou feines funciona, el service worker de 07-05 no existeix, i l'index.html, l'estils.css i el manifest.json reals no s'han carregat ni una vegada. Un botó tapat per un altre element, un z-index que fa inaccessible el formulari, un CSS que amaga la columna de tasques fetes, un mòdul que no carrega perquè falta l'extensió en un import, o un service worker que serveix una versió antiga de l'aplicació: res d'això no apareix a les 124 proves i tot això deixa la Marta mirant una pantalla que no funciona. Per veure-ho cal obrir l'aplicació de debò, en un navegador de debò, i fer-la servir com la faria servir ella. Això és Proves d'Extrem a Extrem amb Cypress, on escriuràs els tres recorreguts crítics de Nómada Tasques —crear una tasca, completar-la i comprovar el resum, i filtrar per responsable amb la URL compartible— i aprendràs per què precisament aquestes proves, les més convincents de totes, han de ser poques i estar molt ben triades.

Curs de JavaScript: De Principiant a Avançat

Mòdul 1: Introducció a JavaScript

Mòdul 2: Estructures de Control

Mòdul 3: Funcions

Mòdul 4: Objectes i Arrays

Mòdul 5: Objectes i Funcions Avançades

Mòdul 6: El Model d'Objectes del Document (DOM)

Mòdul 7: APIs del Navegador i Temes Avançats

Mòdul 8: Proves i Depuració

Mòdul 9: Rendiment i Optimització

Mòdul 10: Frameworks i Llibreries de JavaScript

Mòdul 11: Projecte Final

© Copyright 2026. Tots els drets reservats