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
- Què significa integrar
- Les cinc fallades que només apareixen en ajuntar peces
- On encaixa la integració: piràmide i testing trophy
- Integrar model i dades:
Tauler+RepositoriLocal - El viatge d'anada i tornada:
toJSON→ cadena →desDeJSON - Migracions de versió
jsdom: un DOM sense navegador- Muntar l'HTML mínim i renderitzar
- Testing Library: consultar com ho faria una persona
- Les consultes i quan fer servir cadascuna
user-event: interactuar de debò- Provar la delegació d'esdeveniments
- El flux complet: formulari → model → render
- Provar els errors de xarxa a la interfície
- MSW: simular el servidor al nivell correcte
- Cobertura combinada: què hi afegeix la integració
- Proves intermitents i com fer-les deterministes
- La suite d'integració de Nómada Tasques
- Errors Habituals i Consells
- Exercicis
- Conclusió
- 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.
- 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.
- 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.
- Integrar model i dades:
Tauler + RepositoriLocal
Tauler + RepositoriLocalPrimera 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ó.
- El viatge d'anada i tornada:
toJSON → cadena → desDeJSON
toJSON → cadena → desDeJSONLa 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.
- 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.
jsdom: un DOM sense navegador
jsdom: un DOM sense navegadorSegona 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.
Es pot activar globalment o —millor— fitxer a fitxer, amb un comentari a la capçalera:
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 sí 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ò.
- 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 pergetByLabelText, 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.
- 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.
import { screen, within } from '@testing-library/dom';
import '@testing-library/jest-dom'; // matchers com toBeVisible, toHaveTextContentLa 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.
- 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 | Sí (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 textI 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');
user-event: interactuar de debò
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.
- 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.
- 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.
- 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.
- 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 | Sí |
| 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.
- Cobertura combinada: què hi afegeix la integració
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.
- 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.
- 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 sCent 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:
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.htmlsencer 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
awaitambuser-evento ambfindBy…. Tots dos retornen promeses. Senseawait, l'asserció s'executa abans que passi res. - Combinar
jest.useFakeTimers()ambuser-eventsenseadvanceTimers. La prova es penja fins a esgotar el temps límit, i el missatge d'error no ajuda gens. - Esperar amb
setTimeouten lloc dewaitFor/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
documentdins 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 agetByRole('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 --randomizede 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 toJSON → stringify → setItem → getItem → parse → desDeJSON 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
- Què és JavaScript?
- Configuració del teu Entorn de Desenvolupament
- El teu Primer Programa en JavaScript
- Sintaxi i Conceptes Bàsics de JavaScript
- Variables i Tipus de Dades
- Operadors Bàsics
- Conversió de Tipus i Comparacions
- El Projecte del Curs: Nómada Tasques
Mòdul 2: Estructures de Control
- Sentències Condicionals
- Bucles: for, while, do-while
- Sentències Switch
- Control del Flux: break, continue i Bucles Imbricats
- Gestió d'Errors amb try-catch
Mòdul 3: Funcions
- Definició i Crida de Funcions
- Expressions de Funció i Funcions Fletxa
- Paràmetres i Valors de Retorn
- Àmbit i Closures
- Hoisting i el Context d'Execució
- Funcions d'Ordre Superior
- Recursivitat
Mòdul 4: Objectes i Arrays
- Introducció als Objectes
- Mètodes d'Objecte i la Paraula Clau
this - Arrays: Conceptes Bàsics i Mètodes
- Iteració sobre Arrays
- Cercar, Ordenar i Agregar Dades: find, sort i reduce
- Desestructuració d'Arrays
- Desestructuració d'Objectes, Spread i Rest
- JSON i Còpies d'Objectes
Mòdul 5: Objectes i Funcions Avançades
- Prototips i Herència
- Classes i Programació Orientada a Objectes
- Encapsulació: Getters, Setters i Camps Privats
- Mòduls i Importació/Exportació
- JavaScript Asíncron: Callbacks
- Promeses i Async/Await
- El Bucle d'Esdeveniments i la Cua de Microtasques
- Iteradors i Generadors
Mòdul 6: El Model d'Objectes del Document (DOM)
- Introducció al DOM
- Selecció i Manipulació d'Elements del DOM
- Gestió d'Esdeveniments
- Propagació, Delegació i Esdeveniments Personalitzats
- Creació i Eliminació d'Elements del DOM
- Renderitzat de Llistes i Plantilles HTML
- Gestió i Validació de Formularis
Mòdul 7: APIs del Navegador i Temes Avançats
- Emmagatzematge Local i de Sessió
- Fetch API i AJAX
- Peticions Robustes: Errors, Timeouts i AbortController
- WebSockets
- Service Workers i Aplicacions Web Progressives (PWAs)
- APIs del Navegador Essencials
- Introducció a WebAssembly
Mòdul 8: Proves i Depuració
- Depuració de JavaScript
- Qualitat de Codi: ESLint, Prettier i Convencions
- Proves Unitàries amb Jest
- Dobles de Prova: Mocks, Stubs i Spies
- Proves d'Integració
- Proves d'Extrem a Extrem amb Cypress
Mòdul 9: Rendiment i Optimització
- Mesurar Abans d'Optimitzar: DevTools i Web Vitals
- Optimització del Rendiment de JavaScript
- Gestió de Memòria
- Manipulació Eficient del DOM
- Càrrega Diferida i Divisió de Codi
Mòdul 10: Frameworks i Llibreries de JavaScript
- Per Què Existeixen els Frameworks
- Introducció a React
- Gestió d'Estat amb Redux
- Conceptes Bàsics de Vue.js
- Conceptes Bàsics d'Angular
- Triar el Framework Adequat
