La lliçó anterior va deixar 48 proves verdes en menys d'un segon… i mitja aplicació sense cobrir. repositori-local.js al 31 % perquè a Node no existeix localStorage; api-tasques.js sense ni una prova perquè provar-lo significaria cridar un servidor que no existeix; el debounce esperant 300 mil·lisegons reals per prova; els reintents amb retrocés exponencial de http.js trigant gairebé dos segons cadascun. Xarxa, rellotge, emmagatzematge i aleatorietat: les quatre dependències que converteixen una suite ràpida i fiable en una de lenta i intermitent. Aquesta lliçó ensenya a substituir-les per peces controlades. Aprendràs el vocabulari precís —dummy, stub, spy, mock i fake no són sinònims—, dominaràs jest.fn() i jest.spyOn, simularàs mòduls complets, provaràs api-tasques.js sense xarxa comprovant que ErrorDeApi surt amb el status correcte, congelaràs el rellotge perquè estaVencuda no depengui del dia en què s'executin les proves, i veuràs per què la injecció de dependències sol ser millor idea que el mockeig agressiu.
Contingut
- Les quatre dependències que espatllen una prova
- Què és un doble de prova
- El vocabulari precís: dummy, stub, spy, mock i fake
jest.fn(): la funció espia- Assercions sobre crides
- Programar el retorn d'un doble
jest.spyOn: observar sense substituir- Simular mòduls complets
- Simular només una part d'un mòdul
- Simular
fetch: provarapi-tasques.jssense xarxa - Els quatre escenaris de xarxa que cal cobrir
- Simular el rellotge: temporitzadors falsos
- Provar el
debouncesense esperar - Provar els reintents amb retrocés exponencial
- Congelar la data:
AVUIsense sorpreses - Simular
localStorageamb un doble en memòria - Injecció de dependències davant del mockeig agressiu
- Proves acoblades a la implementació
- Errors Habituals i Consells
- Exercicis
- Conclusió
- Les quatre dependències que espatllen una prova
Una bona prova té tres propietats: és ràpida, és determinista (mateix resultat sempre) i és aïllada (no depèn de res extern). Aquestes quatre dependències les destrueixen totes tres:
| Dependència | Què trenca | Exemple a Nómada Tasques |
|---|---|---|
| La xarxa | Velocitat, determinisme, aïllament | llistarTasques() crida api.tallernomada.example, que no resol mai |
| El rellotge | Determinisme i velocitat | estaVencuda() sense argument; el dormir() d'ambReintents |
| L'emmagatzematge | Aïllament (estat que persisteix entre proves) | localStorage, que a més no existeix a Node |
| L'aleatorietat | Determinisme | El jitter amb Math.random() d'ambReintents |
I el cost real, mesurat:
Sense dobles: api-tasques.test.js ✗ falla: getaddrinfo ENOTFOUND api.tallernomada.example temps.test.js ✓ 4 proves · 1.4 s (4 × 350 ms d'espera real) http.test.js ✓ 3 proves · 6.8 s (reintents amb backoff de debò) repositori.test.js ✗ falla: localStorage is not defined Amb dobles: api-tasques.test.js ✓ 12 proves · 0.09 s temps.test.js ✓ 4 proves · 0.02 s http.test.js ✓ 8 proves · 0.05 s repositori.test.js ✓ 11 proves · 0.03 s
Vuit segons i dues suites trencades davant de dues dècimes i 35 proves. I l'important no és la velocitat: és que les proves amb xarxa fallen de vegades sense que res estigui malament, i una suite que falla sense motiu deixa de mirar-se en dues setmanes.
- Què és un doble de prova
Un doble de prova és un objecte que ocupa el lloc d'una dependència real durant una prova. El terme ve del cinema: el doble d'acció substitueix l'actor a l'escena perillosa.
flowchart LR
subgraph P["En producció"]
A1["llistarTasques"] --> B1["fetch real"] --> C1["Servidor<br/>api.tallernomada"]
end
subgraph T["A la prova"]
A2["llistarTasques"] --> B2["fetch DOBLE"] --> C2["Resposta<br/>fabricada aquí"]
end
style C2 fill:#bbf7d0,stroke:#15803d
style C1 fill:#fecaca,stroke:#b91c1c
El codi sota prova —llistarTasques— no se n'assabenta: crida fetch com sempre. El que canvia és què hi ha a l'altra banda. Això permet tres coses impossibles amb la dependència real:
- Controlar la resposta: avui un 200 amb sis tasques, ara un 500, ara una fallada de xarxa. Provocar un 500 en un servidor real és difícil; en un doble és una línia.
- Observar les crides: s'ha cridat
fetchamb la URL correcta? Amb el mètodePATCH? Quantes vegades? - Anar a la velocitat que vulguis: sense latència, sense esperes, sense temporitzadors reals.
- El vocabulari precís: dummy, stub, spy, mock i fake
A la pràctica gairebé tothom diu «mock» per a tot, i Jest ho agreuja anomenant jest.fn() un objecte que sol fer de spy. Però els cinc termes designen coses diferents, i conèixer-los ajuda a decidir quin necessites.
| Tipus | Què fa | Retorna valors? | Es comprova sobre ell? | Exemple a Nómada Tasques |
|---|---|---|---|---|
| Dummy | Res. Només omple un buit obligatori | No | No | Un signal que es passa perquè la signatura ho exigeix i aquesta prova no cancel·la |
| Stub | Retorna respostes fixes | Sí | No | Un fetch que sempre retorna el backlog canònic |
| Spy | Registra com el criden, deixant passar la crida real | Opcionalment | Sí | Vigilar que repositori.desar es crida en canviar d'estat |
| Mock | Stub + expectatives sobre com ha de ser cridat | Sí | Sí | Un fetch que retorna 201 i del qual es comprova mètode, URL i cos |
| Fake | Implementació real però simplificada | Sí | No | Un localStorage en memòria amb un Map |
Un exemple de cadascun, sobre el projecte:
// ── DUMMY · només ocupa un buit. No es fa servir, no es comprova ───────
const senyalDummy = new AbortController().signal;
await llistarTasques({ responsable: 'Iván' }, { signal: senyalDummy });
// ── STUB · resposta fixa; ningú mira com se li ha cridat ───────────────
const fetchStub = jest.fn().mockResolvedValue(
new Response(JSON.stringify(dadesBacklog), { status: 200,
headers: { 'content-type': 'application/json' } })
);
// ── SPY · observa, i (aquí) deixa passar la crida real ─────────────────
const espia = jest.spyOn(repositori, 'desar');
tauler.canviarEstat(2, 'en-curs');
expect(espia).toHaveBeenCalledTimes(1); // ← la comprovació ÉS l'objectiu
// ── MOCK · resposta programada I expectatives sobre la crida ───────────
const fetchMock = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ id: 7 }), { status: 201,
headers: { 'content-type': 'application/json' } })
);
await crearTasca(dadesNoves);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/tasques'),
expect.objectContaining({ method: 'POST' })
);
// ── FAKE · implementació real, simplificada ────────────────────────────
function magatzemFals(inicial = {}) {
const mapa = new Map(Object.entries(inicial));
return {
getItem: (k) => (mapa.has(k) ? mapa.get(k) : null),
setItem: (k, v) => { mapa.set(k, String(v)); },
removeItem: (k) => { mapa.delete(k); },
clear: () => mapa.clear(),
get length() { return mapa.size; }
};
}La distinció que més importa a la pràctica és la que separa stub de mock, perquè marca on poses l'asserció:
- Amb un stub, l'asserció va sobre el resultat: «
llistarTasquesha retornat sis instàncies deTasca». - Amb un mock, l'asserció va sobre la interacció: «s'ha cridat
fetchambPATCHi aquest cos».
La primera prova comportament; la segona prova implementació. La primera sobreviu a una refactorització; la segona no sempre. És el tema de l'apartat 18, i convé tenir-ho present des d'ara: fes servir stubs per defecte i mocks només quan la interacció sigui, en si mateixa, el comportament que vols garantir (que s'enviï un DELETE i no un PATCH, per exemple, sí que és comportament observable).
jest.fn(): la funció espia
jest.fn(): la funció espiajest.fn() crea una funció que registra tot el que li passa.
import { jest } from '@jest/globals'; // ← amb mòduls ES, jest s'IMPORTA
const enCrear = jest.fn();
enCrear({ id: 7, titol: 'Revisar els extintors' });
enCrear({ id: 8, titol: 'Demanar tinta negra' });
console.log(enCrear.mock.calls);
// [ [{ id: 7, titol: 'Revisar els extintors' }],
// [{ id: 8, titol: 'Demanar tinta negra' }] ]Aquest import { jest } from '@jest/globals' és obligatori amb el camí de mòduls ES natius que vas configurar a 08-03. Amb CommonJS, jest és una global injectada; amb ESM no ho és, i oblidar la importació produeix un ReferenceError: jest is not defined que confon bastant la primera vegada.
La propietat .mock és el registre complet:
| Propietat | Conté |
|---|---|
.mock.calls |
Array d'arrays: els arguments de cada crida |
.mock.results |
Què ha retornat (o llançat) cada crida |
.mock.instances |
Els this de cada crida, si s'ha fet servir amb new |
.mock.lastCall |
Els arguments de l'última crida |
enCrear.mock.calls.length; // 2 → quantes vegades s'ha cridat
enCrear.mock.calls[0][0]; // { id: 7, … } → primer argument de la primera crida
enCrear.mock.lastCall[0]; // { id: 8, … } → primer argument de l'últimaLi pots donar un cos des del principi:
const seguentId = jest.fn(() => 7); // implementació inicial
const registrar = jest.fn((nivell, esdeveniment) => `[${nivell}] ${esdeveniment}`);
- Assercions sobre crides
Jest porta matchers específics per a dobles, molt més llegibles que inspeccionar .mock.calls a mà:
| Matcher | Comprova |
|---|---|
toHaveBeenCalled() |
S'ha cridat almenys una vegada |
toHaveBeenCalledTimes(n) |
S'ha cridat exactament n vegades |
toHaveBeenCalledWith(...args) |
Alguna crida ha tingut aquests arguments |
toHaveBeenLastCalledWith(...args) |
L'última crida ha tingut aquests arguments |
toHaveBeenNthCalledWith(n, ...args) |
La crida número n ha tingut aquests arguments |
toHaveReturnedWith(v) |
Ha retornat aquest valor alguna vegada |
not.toHaveBeenCalled() |
Mai s'ha cridat |
test("en crear una tasca s'avisa l'oient amb la tasca creada", () => {
const enCrear = jest.fn();
const formulari = { titol: 'Revisar els extintors', horesEstimades: 2 };
crearDesDeFormulari(formulari, { enCrear });
expect(enCrear).toHaveBeenCalledTimes(1);
expect(enCrear).toHaveBeenCalledWith(expect.objectContaining({
titol: 'Revisar els extintors',
estat: 'pendent' // R5: neix en pendent
}));
});Les comparacions asimètriques d'aquest exemple són imprescindibles quan no et vols lligar a l'objecte complet:
expect.anything() // qualsevol cosa excepte null i undefined
expect.any(Number) // qualsevol número (o String, Function, Tasca…)
expect.objectContaining({ a: 1 }) // un objecte que conté ALMENYS aquesta propietat
expect.arrayContaining([1, 2]) // un array que conté almenys aquests elements
expect.stringContaining('/tasques') // una cadena que conté aquest text
expect.stringMatching(/^https:/) // una cadena que casa amb l'expressió regular
expect.closeTo(8.16, 2) // un número decimal amb tolerànciaSense elles, comprovar una crida a fetch obligaria a escriure la URL exacta amb tots els seus paràmetres codificats i l'objecte d'opcions complet; amb elles, comproves només el que li importa a aquesta prova:
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/tasques/3'),
expect.objectContaining({ method: 'PATCH' })
);I un avís important: toHaveBeenCalledWith compara els arguments amb la semàntica de toEqual (estructural, recursiva). Però si l'argument és un objecte que el codi muta després, estaràs comparant el seu estat actual, no el que tenia en el moment de la crida. És el mateix parany que la consola de 08-01: si necessites la foto, clona al mateix doble.
// Un doble que desa una CÒPIA del que rep
const desar = jest.fn((tauler) => estatsDesats.push(structuredClone(tauler.toJSON())));
- Programar el retorn d'un doble
Un jest.fn() acabat de crear retorna undefined. Aquests mètodes li donen comportament:
| Mètode | Què fa |
|---|---|
mockReturnValue(v) |
Retorna v sempre |
mockReturnValueOnce(v) |
Retorna v només la propera vegada (encadenable) |
mockResolvedValue(v) |
Retorna una promesa resolta amb v |
mockRejectedValue(e) |
Retorna una promesa rebutjada amb e |
mockResolvedValueOnce(v) / mockRejectedValueOnce(e) |
Les versions d'una sola vegada |
mockImplementation(fn) |
Executa fn com a cos |
mockImplementationOnce(fn) |
Només la propera vegada |
Les variants ...Once són les que permeten simular seqüències, i aquí hi ha el 90 % del seu valor. Exemple real: provar que ambReintents insisteix després de dues fallades i acaba tenint èxit.
const demanar = jest.fn()
.mockRejectedValueOnce(new ErrorDeApi('503', { status: 503, codi: 'servidor' }))
.mockRejectedValueOnce(new ErrorDeApi('503', { status: 503, codi: 'servidor' }))
.mockResolvedValue({ tasques: dadesBacklog }); // a partir de la tercera, èxit
const resultat = await ambReintents(demanar, { intents: 3, baseMs: 0 });
expect(demanar).toHaveBeenCalledTimes(3);
expect(resultat.tasques).toHaveLength(6);Tres fallades programades, un comportament verificat, zero mil·lisegons d'espera. Reproduir això contra un servidor real seria, senzillament, impossible.
mockImplementation serveix quan la resposta depèn de l'argument:
const cercar = jest.fn((id) => dadesBacklog.find((t) => t.id === id) ?? null);
expect(cercar(3).titol).toBe('Actualitzar el web de reserves');
expect(cercar(99)).toBeNull();I tres mètodes de neteja que convé distingir bé, perquè confondre'ls causa fallades rares:
| Mètode | Esborra el registre de crides | Esborra la implementació | Restaura l'original |
|---|---|---|---|
mockClear() |
✅ | ❌ | ❌ |
mockReset() |
✅ | ✅ | ❌ |
mockRestore() |
✅ | ✅ | ✅ (només amb spyOn) |
I la manera de no haver-se'n de recordar mai:
// jest.config.js
export default {
restoreMocks: true, // mockRestore() automàtic després de cada prova
clearMocks: true // mockClear() automàtic abans de cada prova
};Amb aquestes dues línies, l'estat compartit entre proves —el problema de l'apartat 11 de 08-03— deixa de ser un risc també per als dobles.
jest.spyOn: observar sense substituir
jest.spyOn: observar sense substituirjest.fn() crea una funció nova. jest.spyOn(objecte, 'metode') embolcalla un mètode existent, i per defecte deixa passar la crida real.
test("canviar d'estat provoca exactament un desat", () => {
const repositori = new RepositoriLocal({ magatzem: magatzemFals() });
const tauler = unTauler();
connectarPersistencia(tauler, repositori);
const espia = jest.spyOn(repositori, 'desar'); // observa, NO substitueix
tauler.canviarEstat(2, 'en-curs');
expect(espia).toHaveBeenCalledTimes(1);
expect(espia).toHaveBeenCalledWith(tauler);
expect(repositori.carregar().total).toBe(6); // ← el desat REAL ha passat
});Aquesta última línia és la diferència essencial: amb spyOn pur, el mètode original s'executa i els seus efectes són reals. Estàs observant, no reemplaçant.
Si a més vols substituir el comportament, s'encadena:
// Observar I substituir: simulem que el magatzem està ple
jest.spyOn(repositori, 'desar').mockReturnValue(false);
// Observar i silenciar: evitar que la consola embruti la sortida de les proves
const avisos = jest.spyOn(console, 'warn').mockImplementation(() => {});
repositori.desar(tauler);
expect(avisos).toHaveBeenCalledWith(expect.stringContaining('Sense espai'));
avisos.mockRestore(); // ← imprescindible si no fas servir restoreMocksAquest patró sobre console.warn és dels més útils del dia a dia: comprova que l'avís s'emet i manté neta la sortida de la suite.
mockRestore() només funciona amb spyOn, perquè només aleshores hi ha un original a què tornar. Sobre un jest.fn() no fa res més que mockReset(). I oblidar-ho té conseqüències reals: un console.warn silenciat que no es restaura mai deixa muts tots els fitxers de proves posteriors.
jest.fn() |
jest.spyOn(obj, 'm') |
|
|---|---|---|
| Crea una funció | Nova, del no-res | Embolcalla una d'existent |
| Comportament per defecte | Retorna undefined |
Executa l'original |
| Es pot restaurar | No hi ha res a restaurar | Sí, amb mockRestore() |
| Ús típic | Callbacks, dependències injectades | Mètodes d'objectes que ja existeixen |
- Simular mòduls complets
De vegades la dependència no entra per paràmetre: està importada dins del mòdul sota prova. api-tasques.js importa Tasca; app.js importa llistarTasques. Per substituir això cal interceptar el mateix sistema de mòduls.
Amb CommonJS, l'eina és jest.mock('ruta'), que s'eleva automàticament per damunt dels require. Amb mòduls ES natius —el camí que vas triar a 08-03— no hi ha elevació, així que l'API és diferent i cal respectar un ordre estricte:
// proves/vista/controlador.test.js
import { jest } from '@jest/globals';
// 1 · Declarar el doble ABANS d'importar res del mòdul real
jest.unstable_mockModule('../../js/dades/api-tasques.js', () => ({
llistarTasques: jest.fn(),
crearTasca: jest.fn(),
actualitzarTasca: jest.fn(),
esborrarTasca: jest.fn()
}));
// 2 · Importar DESPRÉS, i de manera dinàmica (await import)
const { llistarTasques, crearTasca } = await import('../../js/dades/api-tasques.js');
const { carregarTauler } = await import('../../js/app-dades.js');
describe('carregarTauler', () => {
beforeEach(() => { jest.clearAllMocks(); });
test("construeix el tauler amb el que retorna l'API", async () => {
llistarTasques.mockResolvedValue(dadesBacklog.map((d) => new Tasca(d)));
const tauler = await carregarTauler();
expect(tauler.total).toBe(6);
expect(llistarTasques).toHaveBeenCalledTimes(1);
});
});Tres regles perquè això funcioni:
jest.unstable_mockModuleva abans de qualsevolimportdel mòdul real, inclosos els indirectes. Si un altre mòdul ja l'ha importat, el doble arriba tard.- Les importacions del mòdul simulat han de ser dinàmiques (
await import(...)), perquè elsimportestàtics es resolen abans que s'executi cap línia del fitxer. - El nom
unstable_espanta, però és l'API documentada per a ESM i funciona; el prefix reflecteix que la seva forma pot canviar, no que falli.
I una alternativa que evita tot aquest ball, disponible sempre que el mòdul estigui ben dissenyat:
// En lloc de simular el mòdul, INJECTAR la dependència
export async function carregarTauler({ llistar = llistarTasques } = {}) {
return new Tauler('Taller Nómada', await llistar());
}
// La prova, sense cap màgia de mòduls:
test("construeix el tauler amb el que retorna l'API", async () => {
const llistar = jest.fn().mockResolvedValue(crearBacklog());
const tauler = await carregarTauler({ llistar });
expect(tauler.total).toBe(6);
});Quatre línies davant de quinze, i sense dependències de l'executor. Torna el tema de l'apartat 17.
- Simular només una part d'un mòdul
Sovint vols substituir una funció d'un mòdul i conservar la resta. Es resol important el mòdul real dins de la fàbrica del doble:
import { jest } from '@jest/globals';
// Substituïm NOMÉS `dormir`; demanarJson i ambReintents continuen sent els de debò
jest.unstable_mockModule('../../js/util/temps.js', async () => {
const real = await import('../../js/util/temps.js');
return {
...real, // tot l'original…
dormir: jest.fn().mockResolvedValue() // …excepte això
};
});
const { debounce, dormir } = await import('../../js/util/temps.js');El patró s'anomena simulació parcial, i és gairebé sempre preferible a substituir un mòdul sencer: com menys superfície reemplacis, més a prop està la prova del codi real.
Un avís sobre js/util/format.js i altres mòduls d'utilitat: no els simulis. Són purs, ràpids i deterministes. Substituir una dependència que ja compleix les tres propietats d'una bona prova només afegeix una capa que es pot desincronitzar amb la realitat. Se simula el que fa nosa, no tot el que es pot simular.
- Simular
fetch: provar api-tasques.js sense xarxa
fetch: provar api-tasques.js sense xarxaAquest és el cas central de la lliçó. fetch és una global, així que la manera més directa de substituir-la és assignar-la:
// proves/ajudes/xarxa-falsa.js
import { jest } from '@jest/globals';
/** Construeix una Response real, amb el seu status i les seves capçaleres. */
export function respostaJson(cos, { status = 200, capcaleres = {} } = {}) {
return new Response(JSON.stringify(cos), {
status,
headers: { 'content-type': 'application/json', ...capcaleres }
});
}
/** Una Response sense cos, per a 204 No Content. */
export const respostaBuida = () => new Response(null, { status: 204 });
/** Una Response que NO és JSON: el proxy que retorna l'HTML d'un login. */
export const respostaHtml = (status = 200) =>
new Response('<!doctype html><h1>Inicia la sessió</h1>', {
status, headers: { 'content-type': 'text/html' }
});
/** Instal·la un fetch fals i retorna el doble per programar-lo. */
export function instalarFetchFals() {
const fals = jest.fn();
globalThis.fetch = fals;
return fals;
}Fer servir Response de debò —disponible de manera nativa a Node modern— en lloc d'un objecte inventat { ok: true, json: () => … } és una decisió important: la Response real té ok calculat a partir de status, capçaleres que es consulten igual que al navegador i un cos que només es pot llegir una vegada. Un objecte casolà es comporta diferent a les vores i dóna falsos verds.
I les proves:
// proves/dades/api-tasques.test.js
import { jest } from '@jest/globals';
import { llistarTasques, crearTasca, actualitzarTasca, esborrarTasca } from '../../js/dades/api-tasques.js';
import { Tasca } from '../../js/model/tasca.js';
import { ErrorDeApi } from '../../js/model/errors.js';
import { dadesBacklog } from '../../js/dades/backlog.js';
import { respostaJson, respostaBuida, instalarFetchFals } from '../ajudes/xarxa-falsa.js';
describe('api-tasques', () => {
let xarxa;
const fetchOriginal = globalThis.fetch;
beforeEach(() => { xarxa = instalarFetchFals(); });
afterEach(() => { globalThis.fetch = fetchOriginal; }); // ← tornar el món com estava
describe('llistarTasques', () => {
test('retorna instàncies de Tasca, no objectes plans', async () => {
xarxa.mockResolvedValue(respostaJson(dadesBacklog));
const tasques = await llistarTasques();
expect(tasques).toHaveLength(6);
expect(tasques[0]).toBeInstanceOf(Tasca); // ← la frontera fa la seva feina
expect(tasques[0].titol).toBe('Redissenyar la sala polivalent');
});
test('el tauler construït amb la resposta dóna els números canònics', async () => {
xarxa.mockResolvedValue(respostaJson(dadesBacklog));
const tauler = new Tauler('Taller Nómada', await llistarTasques());
expect(tauler.resum('2026-09-20')).toMatchObject({ horesObertes: 45, esforc: 124 });
});
test('afegeix els filtres com a paràmetres de consulta i omet els buits', async () => {
xarxa.mockResolvedValue(respostaJson([]));
await llistarTasques({ responsable: 'Iván', estat: 'pendent', text: '' });
const url = new URL(xarxa.mock.calls[0][0]);
expect(url.pathname).toBe('/v1/tasques');
expect(url.searchParams.get('responsable')).toBe('Iván');
expect(url.searchParams.get('estat')).toBe('pendent');
expect(url.searchParams.has('text')).toBe(false); // els buits NO s'envien
});
});
describe('crearTasca', () => {
test('envia POST amb el cos serialitzat i retorna la Tasca amb el seu id', async () => {
const nova = { titol: 'Revisar els extintors', responsable: 'Marta',
prioritat: 'mitjana', horesEstimades: 2,
dataLimit: '2026-10-20', etiquetes: ['seguretat'] };
xarxa.mockResolvedValue(respostaJson({ ...nova, id: 7, estat: 'pendent' }, { status: 201 }));
const creada = await crearTasca(nova);
// Comportament: retorna una Tasca amb l'id que ha assignat el servidor (R1)
expect(creada).toBeInstanceOf(Tasca);
expect(creada.id).toBe(7);
expect(creada.estat).toBe('pendent'); // R5
// Interacció: el mètode i el cos SÍ que són comportament observable
const [, opcions] = xarxa.mock.calls[0];
expect(opcions.method).toBe('POST');
expect(JSON.parse(opcions.body)).toMatchObject({ titol: 'Revisar els extintors' });
expect(opcions.headers['Content-Type']).toBe('application/json');
});
});
describe('esborrarTasca', () => {
test('envia DELETE i accepta una resposta 204 sense cos', async () => {
xarxa.mockResolvedValue(respostaBuida());
await expect(esborrarTasca(3)).resolves.toBe(true);
expect(xarxa).toHaveBeenCalledWith(
expect.stringContaining('/tasques/3'),
expect.objectContaining({ method: 'DELETE' })
);
});
});
});Fixa't en l'equilibri: la majoria de les assercions són sobre el resultat (stub), i només es comprova la interacció (mock) quan aquesta interacció és el comportament —que un esborrat faci servir DELETE i no PATCH importa de debò—.
- Els quatre escenaris de xarxa que cal cobrir
Amb la xarxa simulada, provocar fallades és trivial. I aquests són els que cal provar, perquè són els que passen:
describe("api-tasques · gestió d'errors (07-03)", () => {
let xarxa;
beforeEach(() => { xarxa = instalarFetchFals(); });
test('un 404 produeix ErrorDeApi amb status 404 i codi de client', async () => {
xarxa.mockResolvedValue(respostaJson({ missatge: 'No existeix la tasca 99' }, { status: 404 }));
const error = await capturarAsinc(() => obtenirTasca(99));
expect(error).toBeInstanceOf(ErrorDeApi);
expect(error.status).toBe(404);
expect(error.codi).toBe('client');
expect(error.reintentable).toBe(false); // ← un 404 NO es reintenta
expect(error.message).toBe('No existeix la tasca 99');
});
test('un 500 produeix ErrorDeApi reintentable amb codi de servidor', async () => {
xarxa.mockResolvedValue(respostaJson({ missatge: 'Error intern' }, { status: 500 }));
const error = await capturarAsinc(() => llistarTasques());
expect(error.status).toBe(500);
expect(error.codi).toBe('servidor');
expect(error.reintentable).toBe(true); // ← un 500 SÍ
});
test('una fallada de transport produeix ErrorDeApi de xarxa, sense status', async () => {
xarxa.mockRejectedValue(new TypeError('Failed to fetch')); // xarxa caiguda, DNS o CORS
const error = await capturarAsinc(() => llistarTasques());
expect(error).toBeInstanceOf(ErrorDeApi);
expect(error.codi).toBe('xarxa');
expect(error.status).toBe(0);
expect(error.causa).toBeInstanceOf(TypeError); // la causa original es conserva
});
test('una resposta HTML en lloc de JSON produeix ErrorDeApi de format', async () => {
xarxa.mockResolvedValue(respostaHtml(200)); // el proxy que retorna el login
const error = await capturarAsinc(() => llistarTasques());
expect(error.codi).toBe('format');
expect(error.message).toContain('text/html');
});
test('una cancel·lació produeix ErrorDeApi amb codi cancel·lat', async () => {
const abortError = new DOMException('The operation was aborted.', 'AbortError');
xarxa.mockRejectedValue(abortError);
const error = await capturarAsinc(() => llistarTasques());
expect(error.codi).toBe('cancel·lat');
});
});Amb l'ajuda corresponent:
// proves/ajudes/backlog-de-prova.js — versió asíncrona de capturar()
export async function capturarAsinc(fn) {
try { await fn(); return null; } catch (error) { return error; }
}Aquestes cinc proves cobreixen la taula de set fallades que obria 07-03, i cadascuna triga menys d'un mil·lisegon. Provocar un 500 real, un HTML inesperat d'un proxy i una caiguda de DNS a voluntat i a la mateixa suite només és possible amb dobles.
- Simular el rellotge: temporitzadors falsos
El segon gran enemic. jest.useFakeTimers() substitueix setTimeout, setInterval, clearTimeout, Date i performance.now per versions que tu controles.
jest.useFakeTimers(); // a partir d'aquí, el temps no passa sol
jest.advanceTimersByTime(300); // avançar 300 ms virtuals, a l'instant
jest.runAllTimers(); // executar TOTS els temporitzadors pendents
jest.runOnlyPendingTimers(); // només els actuals (evita bucles amb setInterval)
jest.advanceTimersToNextTimer(); // saltar just al següent
jest.useRealTimers(); // tornar el rellotge de debò| Mètode | Quan fer-lo servir |
|---|---|
advanceTimersByTime(ms) |
Control fi: comprovar què passa abans i després del llindar |
runAllTimers() |
Quan només importa l'estat final |
runOnlyPendingTimers() |
Amb setInterval o temporitzadors que es reprogramen |
advanceTimersByTimeAsync(ms) |
Quan pel mig hi ha await: el que necessitaràs amb promeses |
Aquest últim mereix atenció. Avançar el rellotge executa els callbacks dels temporitzadors, però les microtasques de les promeses encuades per aquests callbacks no es processen fins que se cedeixi el control (05-07). La versió ...Async cedeix, i és la que evita el clàssic «he avançat el rellotge i la promesa continua pendent».
- Provar el
debounce sense esperar
debounce sense esperarAmb temporitzadors falsos, js/util/temps.js es prova en microsegons:
// proves/util/temps.test.js
import { jest } from '@jest/globals';
import { debounce } from '../../js/util/temps.js';
describe('debounce', () => {
beforeEach(() => { jest.useFakeTimers(); });
afterEach(() => { jest.useRealTimers(); }); // ← imprescindible: no deixar el rellotge parat
test("no executa la funció abans que expiri l'espera", () => {
const cercar = jest.fn();
const ambRetard = debounce(cercar, 300);
ambRetard('fus');
jest.advanceTimersByTime(299);
expect(cercar).not.toHaveBeenCalled();
});
test("executa una sola vegada després de l'espera completa", () => {
const cercar = jest.fn();
const ambRetard = debounce(cercar, 300);
ambRetard('fus');
jest.advanceTimersByTime(300);
expect(cercar).toHaveBeenCalledTimes(1);
expect(cercar).toHaveBeenCalledWith('fus');
});
test("vuit pulsacions ràpides produeixen UNA sola crida, amb l'últim valor", () => {
const cercar = jest.fn();
const ambRetard = debounce(cercar, 300);
// L'Iván tecleja "fusteria" a 50 ms per lletra
for (const text of ['f', 'fu', 'fus', 'fust', 'fuste', 'fuster', 'fusteri', 'fusteria']) {
ambRetard(text);
jest.advanceTimersByTime(50);
}
expect(cercar).not.toHaveBeenCalled(); // encara no: cada tecla ha reiniciat el compte
jest.advanceTimersByTime(300); // l'Iván para de teclejar
expect(cercar).toHaveBeenCalledTimes(1);
expect(cercar).toHaveBeenCalledWith('fusteria'); // ← l'ÚLTIM valor, no el primer
});
test('una pausa llarga entre pulsacions produeix dues crides', () => {
const cercar = jest.fn();
const ambRetard = debounce(cercar, 300);
ambRetard('fus');
jest.advanceTimersByTime(400); // passa el llindar: primera crida
ambRetard('tinta');
jest.advanceTimersByTime(400); // segona
expect(cercar).toHaveBeenCalledTimes(2);
expect(cercar).toHaveBeenNthCalledWith(1, 'fus');
expect(cercar).toHaveBeenNthCalledWith(2, 'tinta');
});
});Quatre proves que documenten exactament què fa un debounce —inclòs el cas límit del mil·lisegon 299— i que en temps real haurien trigat 1,4 segons. Aquí triguen 20 mil·lisegons.
Fixa't en l'afterEach amb useRealTimers(). Sense ell, el rellotge fals es filtra als fitxers següents i qualsevol prova que depengui del temps es queda penjada. És el mateix principi d'higiene del beforeEach de 08-03: deixa el món com te l'has trobat.
- Provar els reintents amb retrocés exponencial
ambReintents combina les tres dificultats alhora: xarxa, temporitzadors i aleatorietat (el jitter amb Math.random()). Es resolen totes tres.
// proves/dades/http.test.js
import { jest } from '@jest/globals';
import { ambReintents } from '../../js/dades/http.js';
import { ErrorDeApi } from '../../js/model/errors.js';
const errorServidor = () => new ErrorDeApi('Caigut', { status: 503, codi: 'servidor' });
const errorClient = () => new ErrorDeApi('No existeix', { status: 404, codi: 'client' });
describe('ambReintents', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(Math, 'random').mockReturnValue(0.5); // jitter DETERMINISTA
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks(); // torna Math.random
});
test("no reintenta quan l'operació té èxit a la primera", async () => {
const operacio = jest.fn().mockResolvedValue('ok');
await expect(ambReintents(operacio)).resolves.toBe('ok');
expect(operacio).toHaveBeenCalledTimes(1);
});
test('reintenta un 503 i retorna el resultat del tercer intent', async () => {
const operacio = jest.fn()
.mockRejectedValueOnce(errorServidor())
.mockRejectedValueOnce(errorServidor())
.mockResolvedValue('recuperat');
const promesa = ambReintents(operacio, { intents: 3, baseMs: 300 });
await jest.advanceTimersByTimeAsync(300 + 150); // 1a espera: 300 + jitter(0.5 × 300 × 0.3)
await jest.advanceTimersByTimeAsync(600 + 270); // 2a espera: exponencial
await expect(promesa).resolves.toBe('recuperat');
expect(operacio).toHaveBeenCalledTimes(3);
});
test('NO reintenta un 404: no té remei', async () => {
const operacio = jest.fn().mockRejectedValue(errorClient());
await expect(ambReintents(operacio, { intents: 3 })).rejects.toMatchObject({ status: 404 });
expect(operacio).toHaveBeenCalledTimes(1); // ← una sola vegada
});
test("es rendeix després d'esgotar els intents i propaga l'últim error", async () => {
const operacio = jest.fn().mockRejectedValue(errorServidor());
const promesa = ambReintents(operacio, { intents: 3, baseMs: 300 });
const resultat = promesa.catch((e) => e); // capturem ja, per no deixar-la penjant
await jest.runAllTimersAsync();
await expect(resultat).resolves.toMatchObject({ status: 503 });
expect(operacio).toHaveBeenCalledTimes(3);
});
test("l'espera creix de manera exponencial entre intents", async () => {
const esperes = [];
jest.spyOn(globalThis, 'setTimeout').mockImplementation((fn, ms) => { esperes.push(ms); fn(); });
const operacio = jest.fn().mockRejectedValue(errorServidor());
await ambReintents(operacio, { intents: 4, baseMs: 100 }).catch(() => {});
// 100·1 + jitter, 100·2 + jitter, 100·4 + jitter — cadascuna més gran que l'anterior
expect(esperes).toHaveLength(3);
expect(esperes[1]).toBeGreaterThan(esperes[0]);
expect(esperes[2]).toBeGreaterThan(esperes[1]);
});
});Tres tècniques juntes en aquest bloc, i totes tres es reutilitzen constantment:
jest.spyOn(Math, 'random').mockReturnValue(0.5)fa determinista el jitter. Qualsevol aleatorietat al codi sota prova es doma així.advanceTimersByTimeAsyncavança el rellotge i processa les microtasques pendents; amb la versió síncrona, la promesa de l'await dormir(...)no arribaria a resoldre's.- Capturar el rebuig abans d'avançar el rellotge (
promesa.catch((e) => e)) evita que Node avisi d'un rebuig sense gestionar durant l'interval en què la promesa encara no s'ha esperat.
L'última prova mereix un comentari: comprova que les esperes creixen, no que valguin exactament 100, 200 i 400. Afirmar els valors exactes lligaria la prova a la fórmula concreta del jitter, que és un detall d'implementació. Comprovar la propietat —«cada espera és més gran que l'anterior»— captura la intenció real del retrocés exponencial i sobreviu a un ajust de la fórmula. Aquesta distinció, propietat davant de valor exacte, és una de les decisions més valuoses en escriure proves.
- Congelar la data:
AVUI sense sorpreses
AVUI sense sorpresesEl projecte va tenir la precaució que avui entrés com a paràmetre, així que la majoria de les proves no necessiten tocar el rellotge. Però hi ha codi que no ho permet —qualsevol funció que cridi new Date() internament—, i allà es congela el calendari:
describe('venciment amb el rellotge congelat', () => {
beforeEach(() => {
// El sistema sencer creu que és el 20 de setembre de 2026, a les 9:00
jest.useFakeTimers({ now: new Date('2026-09-20T09:00:00Z') });
});
afterEach(() => { jest.useRealTimers(); });
test('estaVencuda fa servir AVUI per defecte i dóna el mateix resultat qualsevol dia', () => {
const tasca = unaTasca({ dataLimit: '2026-09-05', estat: 'pendent' });
expect(tasca.estaVencuda()).toBe(true); // sense passar data
});
test('la data del sistema és la congelada', () => {
expect(new Date().toISOString().slice(0, 10)).toBe('2026-09-20');
});
test('el resum sense data explícita dóna una sola vençuda', () => {
expect(unTauler().resum()).toMatchObject({ vencudes: 1 });
});
});I una nota de disseny que convé subratllar: si el teu codi admet rebre la data com a paràmetre, prefereix-ho a congelar el rellotge. Congelar el rellotge és una intervenció global que afecta tot el que s'executi, inclòs codi de llibreries. Passar avui com a dada és explícit, local i no té efectes col·laterals. El rellotge fals és el pla B per quan no pots canviar el codi; el paràmetre és el pla A, i és la raó per la qual estaVencuda(dataLimit, estat, avui = AVUI) es va escriure així a 05-04.
- Simular
localStorage amb un doble en memòria
localStorage amb un doble en memòriaA Node no existeix localStorage. És el motiu que repositori-local.js estigués al 31 % de cobertura. I aquí es cobra una altra decisió de disseny de 07-01: el constructor accepta un magatzem.
// proves/ajudes/magatzem-fals.js
/**
* FAKE de Web Storage: implementació real, simplificada, en memòria.
* Compleix la interfície que fa servir RepositoriLocal, més un mode "ple" per
* poder provocar el QuotaExceededError sense omplir un disc de debò.
*/
export function magatzemFals({ inicial = {}, ple = false } = {}) {
const mapa = new Map(Object.entries(inicial));
return {
getItem: (clau) => (mapa.has(clau) ? mapa.get(clau) : null),
setItem: (clau, valor) => {
if (ple) {
const error = new Error('Quota superada');
error.name = 'QuotaExceededError'; // ← el que comprova el repositori
throw error;
}
mapa.set(clau, String(valor)); // Web Storage NOMÉS desa cadenes
},
removeItem: (clau) => { mapa.delete(clau); },
clear: () => mapa.clear(),
key: (i) => [...mapa.keys()][i] ?? null,
get length() { return mapa.size; },
// Utilitats només per a les proves
_abocar: () => Object.fromEntries(mapa)
};
}Aquest String(valor) és el que fa que el doble sigui fidel: si el fals admetés objectes, una prova passaria amb setItem('x', {a: 1}) i al navegador es desaria la cadena "[object Object]". Un doble que és més permissiu que l'original produeix falsos verds. La fidelitat d'un fake és la seva única virtut; tan bon punt se separa de l'original, menteix.
Les proves:
// proves/dades/repositori-local.test.js
import { jest } from '@jest/globals';
import { RepositoriLocal } from '../../js/dades/repositori-local.js';
import { magatzemFals } from '../ajudes/magatzem-fals.js';
import { unTauler } from '../ajudes/backlog-de-prova.js';
describe('RepositoriLocal', () => {
test('desa i recupera el tauler complet', () => {
const repositori = new RepositoriLocal({ magatzem: magatzemFals() });
const tauler = unTauler();
expect(repositori.desar(tauler)).toBe(true);
const recuperat = repositori.carregar();
expect(recuperat.total).toBe(6);
expect(recuperat.resum('2026-09-20')).toMatchObject({ horesObertes: 45, esforc: 124 });
});
test('desa sota la clau versionada del projecte', () => {
const magatzem = magatzemFals();
new RepositoriLocal({ magatzem }).desar(unTauler());
expect(Object.keys(magatzem._abocar())).toEqual(['nomada:tauler:v1']);
});
test('retorna null si no hi ha res desat', () => {
expect(new RepositoriLocal({ magatzem: magatzemFals() }).carregar()).toBeNull();
});
test('descarta dades corrompudes sense llançar i avisa per consola', () => {
const avisos = jest.spyOn(console, 'warn').mockImplementation(() => {});
const magatzem = magatzemFals({ inicial: { 'nomada:tauler:v1': '{{{ no és JSON' } });
expect(new RepositoriLocal({ magatzem }).carregar()).toBeNull();
expect(avisos).toHaveBeenCalledWith(expect.stringContaining('corrompudes'));
avisos.mockRestore();
});
test("sense espai retorna false, no llança: perdre persistència no tomba l'app", () => {
const avisos = jest.spyOn(console, 'warn').mockImplementation(() => {});
const repositori = new RepositoriLocal({ magatzem: magatzemFals({ ple: true }) });
expect(repositori.desar(unTauler())).toBe(false); // ← degrada, no explota
avisos.mockRestore();
});
test('netejar elimina la clau i deixa el magatzem buit', () => {
const magatzem = magatzemFals();
const repositori = new RepositoriLocal({ magatzem });
repositori.desar(unTauler());
repositori.netejar();
expect(repositori.carregar()).toBeNull();
expect(magatzem.length).toBe(0);
});
});Del 31 % a cobertura gairebé completa, incloses les dues branques defensives —dades corrompudes i quota esgotada— que en un navegador real són dificilíssimes de provocar a mà.
L'alternativa jest-environment-jsdom. Si configures testEnvironment: 'jsdom', obtens un localStorage global implementat per jsdom, i el codi funciona sense injectar res. És la via que faràs servir a 08-05 per a les proves de vista. Per a proves unitàries de la capa de dades, el doble injectat és preferible: és explícit, no arrossega un DOM sencer que no necessites, i sobretot permet provocar el QuotaExceededError, cosa que jsdom no ofereix.
- Injecció de dependències davant del mockeig agressiu
Portes tota la lliçó veient dos camins per al mateix problema, i mereixen comparar-se de cara.
// ── Camí A · Mockeig agressiu: la dependència està importada a dins ────
// js/dades/sincronitzador.js
import { llistarTasques } from './api-tasques.js';
export async function sincronitzar(tauler) {
const remotes = await llistarTasques();
return tauler.importar(remotes);
}
// La prova necessita interceptar el sistema de mòduls
jest.unstable_mockModule('../../js/dades/api-tasques.js', () => ({ llistarTasques: jest.fn() }));
const { llistarTasques } = await import('../../js/dades/api-tasques.js');
const { sincronitzar } = await import('../../js/dades/sincronitzador.js');
llistarTasques.mockResolvedValue(crearBacklog());// ── Camí B · Injecció: la dependència entra per paràmetre ──────────────
// js/dades/sincronitzador.js
import { llistarTasques } from './api-tasques.js';
export async function sincronitzar(tauler, { llistar = llistarTasques } = {}) {
const remotes = await llistar();
return tauler.importar(remotes);
}
// La prova, sense màgia
const llistar = jest.fn().mockResolvedValue(crearBacklog());
await sincronitzar(tauler, { llistar });Fixa't en el detall del camí B: el valor per defecte fa que el codi de producció no canviï ni una línia (sincronitzar(tauler) continua funcionant igual), i alhora obre la porta a la prova. És la mateixa tècnica de l'avui = AVUI d'estaVencuda i del magatzem de RepositoriLocal.
| Injecció de dependències | Mockeig de mòduls | |
|---|---|---|
| Canvis al codi de producció | Sí: un paràmetre més | Cap |
| Complexitat de la prova | Baixa: és JavaScript normal | Mitjana-alta: ordre i importacions dinàmiques |
| Acoblament a l'executor | Cap | Alt: depèn de l'API de Jest |
| Llegibilitat de la dependència | Explícita a la signatura | Oculta als import |
| Utilitat fora de les proves | Alta: permet variants reals | Nul·la |
Quan la dependència és global (fetch) |
No aplica directament | Necessari |
| Quan el mòdul és de tercers | Difícil | Necessari |
Recomanació pràctica, en ordre de preferència:
- Fes la funció pura si pots. Sense dependència, no hi ha res a simular.
- Injecta la dependència amb un valor per defecte. Cost: un paràmetre. Benefici: proves trivials i un disseny més flexible.
- Substitueix la global (
globalThis.fetch) quan la dependència sigui de l'entorn. - Simula el mòdul només quan no controlis el codi o la dependència sigui profunda.
I un senyal d'alarma que convé reconèixer: si una prova necessita quatre jest.mock per arrencar, el problema no és la prova, és el disseny del mòdul. Quatre dependències simulades signifiquen quatre responsabilitats acoblades. La dificultat de provar continua sent el detector de mal disseny del qual parlava 08-03.
- Proves acoblades a la implementació
El perill final, i el que arruïna més suites a mitjà termini.
// ❌ Aquesta prova no comprova RES útil
test('canviarEstat funciona', () => {
const tauler = unTauler();
const cercar = jest.spyOn(tauler, 'cercarPerId');
const canviar = jest.spyOn(Tasca.prototype, 'canviarEstat');
tauler.canviarEstat(2, 'en-curs');
expect(cercar).toHaveBeenCalledWith(2);
expect(canviar).toHaveBeenCalledWith('en-curs');
});Aquesta prova afirma que canviarEstat crida altres mètodes. No comprova en cap moment que la tasca 2 acabi en 'en-curs'. Conseqüències:
- Passa encara que el resultat estigui malament. Si
canviarEstatcridés els dos mètodes i després revertís el canvi, continuaria verda. - Falla quan res està trencat. Si demà
canviarEstatfa servir unMapintern en lloc decercarPerId, la prova es posa vermella amb el comportament intacte. - Impedeix refactoritzar, que era precisament el benefici número u de tenir proves. Una suite així converteix la xarxa de seguretat en una camisa de força.
// ✅ Comprova el comportament observable
test("canviarEstat deixa la tasca en l'estat indicat i recalcula les hores", () => {
const tauler = unTauler();
tauler.canviarEstat(1, 'feta'); // tasca 1: 12 h de l'Iván
expect(tauler.cercarPerId(1).estat).toBe('feta');
expect(tauler.horesObertes).toBe(33); // 45 − 12
});La regla: afirma sobre el que el mòdul promet, no sobre com ho compleix.
I l'excepció, que també és important: hi ha interaccions que són el comportament observable, i aquestes sí que es comproven amb mocks.
| Comprovar la interacció està justificat | Comprovar la interacció és un error |
|---|---|
Que un esborrat enviï DELETE i no PATCH |
Que resum() cridi internament filter |
| Que no es cridi l'API quan la validació falla | Que un getter faci servir reduce i no un for |
Que un AbortController es cancel·li en desmuntar |
Que es cridi un mètode privat |
Que s'emeti ESDEVENIMENTS.TASCA_CANVIADA amb el seu detail |
Quantes vegades es crida un ajudant intern |
| Que no es persisteixi més d'una vegada per canvi | L'ordre intern de dues operacions sense efecte observable |
El criteri per distingir-les: algú de fora del mòdul notaria la diferència? Si el servidor rep PATCH en lloc de DELETE, sí. Si resum() canvia un reduce per un bucle, no.
Errors Habituals i Consells
- Oblidar
import { jest } from '@jest/globals'amb mòduls ES. DónaReferenceError: jest is not definedi despista molt, perquè a tots els tutorials de CommonJS no cal. - No restaurar els dobles entre proves. Un
console.warnsilenciat o unfetchsubstituït que sobreviu contamina tota la suite. Fes servirrestoreMocks: trueiclearMocks: truea la configuració. - Oblidar
jest.useRealTimers()a l'afterEach. El rellotge fals es filtra al fitxer següent i una prova es penja sense explicació. - Fer servir
advanceTimersByTimequan hi ha promeses pel mig. Els temporitzadors s'executen però les microtasques no; la promesa continua pendent. Fes servir la variant...Async. - Fabricar respostes casolanes de l'estil
{ ok: true, json: () => dades }. Es comporten diferent d'unaResponsereal a les vores (status,okderivat, cos consumible una vegada) i produeixen falsos verds. Fes servirnew Response(...). - Simular el que no fa nosa. Un mòdul pur i ràpid com
util/format.jsno es simula: només hi afegeixes una capa que es pot desincronitzar amb la realitat. - Escriure un fake més permissiu que l'original. Un
localStoragefals que admeti objectes deixa passar codi que al navegador desaria"[object Object]". - Comprovar només que s'ha cridat un mock. Una prova que no afirma res sobre el resultat no protegeix el comportament: passa encara que el resultat sigui incorrecte, i falla tan bon punt refactoritzes.
- Deixar una promesa rebutjada sense capturar mentre s'avança el rellotge. Node avisa d'
unhandledRejectioni embruta (o trenca) la suite. Captura abans d'avançar. - Consell: si el doble és complicat, mira el disseny. Quatre
jest.mockper arrencar una prova assenyalen quatre responsabilitats acoblades. - Consell: posa els dobles a
proves/ajudes/.xarxa-falsa.js,magatzem-fals.jsibacklog-de-prova.jses comparteixen entre fitxers, es proven una vegada i eviten deu còpies divergents. - Consell: prefereix afirmar propietats a valors exactes quan hi ha aleatorietat o fórmules ajustables: «l'espera creix» sobreviu a un canvi de fórmula; «l'espera val 430 ms», no.
Exercicis
Exercici 1 — Provar demanarJson amb les set fallades de 07-03.
Escriu proves/dades/http.test.js amb la bateria completa de demanarJson, cobrint els set escenaris de la taula que obria 07-03: xarxa caiguda (TypeError), timeout (TimeoutError), cancel·lació (AbortError), 4xx, 5xx, resposta no-JSON i resposta 204 sense cos. Per a cadascun comprova el codi, el status i si és reintentable. Crea una ajuda proves/ajudes/xarxa-falsa.js reutilitzable i explica per què fas servir new Response(...) en lloc d'un objecte literal.
Exercici 2 — Un doble per a CanalTauler.
js/dades/temps-real.js exposa CanalTauler extends EventTarget, que obre un WebSocket, reconnecta amb retrocés i emet esdeveniments. Escriu un fake CanalFals extends EventTarget que permeti, des de la prova, provocar missatges entrants (simularMissatge(dades)), caigudes (simularCaiguda()) i reconnexions, sense obrir cap socket. Amb ell, prova que: (a) un missatge tasca:actualitzada amb la tasca 2 en 'en-curs' actualitza el tauler; (b) un missatge amb un id desconegut s'ignora sense llançar; (c) després d'una caiguda, els canvis locals s'encuen i s'envien en reconnectar. Fes servir temporitzadors falsos per a la reconnexió.
Exercici 3 — Reescriure una suite acoblada a la implementació. Aquesta suite passa en verd i no protegeix res. Identifica els quatre problemes, explica quina fallada real deixaria passar cadascun, i reescriu-la perquè comprovi comportament.
test('crearTasca funciona', async () => {
const xarxa = instalarFetchFals();
xarxa.mockResolvedValue(respostaJson({ id: 7 }, { status: 201 }));
const construir = jest.spyOn(Tasca, 'desDeJSON');
const serialitzar = jest.spyOn(JSON, 'stringify');
await crearTasca({ titol: 'Revisar els extintors', horesEstimades: 2 });
expect(xarxa).toHaveBeenCalled();
expect(serialitzar).toHaveBeenCalled();
expect(construir).toHaveBeenCalled();
});Solucions
Solució 1
// proves/dades/http.test.js
import { jest } from '@jest/globals';
import { demanarJson } from '../../js/dades/http.js';
import { ErrorDeApi } from '../../js/model/errors.js';
import { instalarFetchFals, respostaJson, respostaBuida, respostaHtml } from '../ajudes/xarxa-falsa.js';
import { capturarAsinc } from '../ajudes/backlog-de-prova.js';
// Fem servir `new Response(...)` i no un objecte literal perquè la Response real
// deriva `ok` de `status`, exposa `headers` amb l'API de Headers i permet
// llegir el cos UNA sola vegada. Un objecte casolà és més permissiu que
// l'original: deixaria passar codi que al navegador fallaria.
describe('demanarJson', () => {
let xarxa;
const fetchOriginal = globalThis.fetch;
beforeEach(() => { xarxa = instalarFetchFals(); });
afterEach(() => { globalThis.fetch = fetchOriginal; });
test('retorna el JSON en una resposta 200 correcta', async () => {
xarxa.mockResolvedValue(respostaJson({ tasques: 6 }));
await expect(demanarJson('/tasques')).resolves.toEqual({ tasques: 6 });
});
test.each`
cas | error | codi | reintentable
${'xarxa caiguda'} | ${new TypeError('Failed to fetch')} | ${'xarxa'} | ${true}
${'timeout'} | ${Object.assign(new Error('t'), { name: 'TimeoutError' })} | ${'timeout'} | ${true}
${'cancel·lació'} | ${Object.assign(new Error('a'), { name: 'AbortError' })} | ${'cancel·lat'} | ${false}
`('$cas → ErrorDeApi amb codi $codi', async ({ error, codi, reintentable }) => {
xarxa.mockRejectedValue(error);
const capturat = await capturarAsinc(() => demanarJson('/tasques'));
expect(capturat).toBeInstanceOf(ErrorDeApi);
expect(capturat.codi).toBe(codi);
expect(capturat.status).toBe(0);
expect(capturat.reintentable).toBe(reintentable);
expect(capturat.causa).toBe(error); // la causa original es conserva
});
test.each`
status | codi | reintentable | motiu
${400} | ${'client'} | ${false} | ${'dades mal enviades'}
${401} | ${'client'} | ${false} | ${'sessió caducada'}
${404} | ${'client'} | ${false} | ${'no existeix'}
${408} | ${'client'} | ${true} | ${'timeout del servidor: sí que es reintenta'}
${429} | ${'client'} | ${true} | ${'massa peticions: sí que es reintenta'}
${500} | ${'servidor'} | ${true} | ${'error intern'}
${503} | ${'servidor'} | ${true} | ${'servei no disponible'}
`('un $status dóna codi $codi, reintentable: $reintentable ($motiu)',
async ({ status, codi, reintentable }) => {
xarxa.mockResolvedValue(respostaJson({ missatge: `Error ${status}` }, { status }));
const error = await capturarAsinc(() => demanarJson('/tasques'));
expect(error.status).toBe(status);
expect(error.codi).toBe(codi);
expect(error.reintentable).toBe(reintentable);
expect(error.message).toBe(`Error ${status}`); // fa servir el missatge del servidor
});
test('una resposta HTML en lloc de JSON dóna codi de format', async () => {
xarxa.mockResolvedValue(respostaHtml(200));
const error = await capturarAsinc(() => demanarJson('/tasques'));
expect(error.codi).toBe('format');
expect(error.message).toContain('text/html');
expect(error.reintentable).toBe(false);
});
test('un 204 sense cos retorna null en lloc de llançar', async () => {
xarxa.mockResolvedValue(respostaBuida());
await expect(demanarJson('/tasques/3', { method: 'DELETE' })).resolves.toBeNull();
});
test('un cos JSON malformat dóna codi de format, no un SyntaxError solt', async () => {
xarxa.mockResolvedValue(new Response('{{{ trencat', {
status: 200, headers: { 'content-type': 'application/json' }
}));
const error = await capturarAsinc(() => demanarJson('/tasques'));
expect(error).toBeInstanceOf(ErrorDeApi); // mai escapa un SyntaxError cru
expect(error.codi).toBe('format');
});
});Solució 2
// proves/ajudes/canal-fals.js
/**
* FAKE de CanalTauler: mateixa interfície (EventTarget + enviar/tancar), zero sockets.
* Afegeix utilitats simular* que només existeixen per a les proves.
*/
export class CanalFals extends EventTarget {
enviats = [];
cua = [];
connectat = true;
intentsDeReconnexio = 0;
enviar(tipus, dades) {
if (!this.connectat) { this.cua.push({ tipus, dades }); return false; }
this.enviats.push({ tipus, dades });
return true;
}
tancar() { this.connectat = false; }
// ── Utilitats de prova ──────────────────────────────────────────────
simularMissatge(tipus, dades) {
this.dispatchEvent(new CustomEvent(tipus, { detail: dades }));
}
simularCaiguda() {
this.connectat = false;
this.dispatchEvent(new CustomEvent('canal:tancat'));
}
simularReconnexio() {
this.connectat = true;
this.intentsDeReconnexio += 1;
for (const pendent of this.cua.splice(0)) this.enviar(pendent.tipus, pendent.dades);
this.dispatchEvent(new CustomEvent('canal:obert'));
}
}// proves/dades/temps-real.test.js
import { jest } from '@jest/globals';
import { connectarTempsReal } from '../../js/dades/temps-real.js';
import { CanalFals } from '../ajudes/canal-fals.js';
import { unTauler } from '../ajudes/backlog-de-prova.js';
describe('sincronització en temps real', () => {
let canal, tauler;
beforeEach(() => {
jest.useFakeTimers();
canal = new CanalFals();
tauler = unTauler();
connectarTempsReal(tauler, { canal }); // ← injecció, no mockeig de mòdul
});
afterEach(() => { jest.useRealTimers(); });
test('(a) un missatge tasca:actualitzada aplica el canvi al tauler', () => {
canal.simularMissatge('tasca:actualitzada', { id: 2, estat: 'en-curs' });
expect(tauler.cercarPerId(2).estat).toBe('en-curs');
expect(tauler.horesObertes).toBe(45); // l'estat obert no canvia
});
test("(b) un missatge amb un id desconegut s'ignora sense llançar", () => {
expect(() => canal.simularMissatge('tasca:actualitzada', { id: 999, estat: 'feta' }))
.not.toThrow();
expect(tauler.total).toBe(6); // res no s'ha afegit ni trencat
});
test("(c) els canvis durant una caiguda s'encuen i s'envien en reconnectar", () => {
canal.simularCaiguda();
tauler.canviarEstat(2, 'en-curs'); // canvi local mentre no hi ha xarxa
expect(canal.enviats).toHaveLength(0);
expect(canal.cua).toHaveLength(1);
canal.simularReconnexio();
expect(canal.cua).toHaveLength(0);
expect(canal.enviats).toHaveLength(1);
expect(canal.enviats[0]).toMatchObject({ tipus: 'tasca:canviada', dades: { id: 2 } });
});
test('la reconnexió respecta el retrocés exponencial', async () => {
canal.simularCaiguda();
await jest.advanceTimersByTimeAsync(1000);
expect(canal.intentsDeReconnexio).toBe(0); // encara no toca
canal.simularReconnexio();
expect(canal.intentsDeReconnexio).toBe(1);
});
});Solució 3
Els quatre problemes:
| # | Problema | Quina fallada real deixaria passar |
|---|---|---|
| 1 | expect(xarxa).toHaveBeenCalled() sense comprovar amb què |
Que s'enviï GET en lloc de POST, o a la URL equivocada |
| 2 | Espiar JSON.stringify |
Detall intern absolut. Passaria encara que el cos enviat estigués buit o mal format |
| 3 | Espiar Tasca.desDeJSON |
Igual: comprova com es construeix, no que es retorni una Tasca amb l'id 7 |
| 4 | Cap asserció sobre el valor retornat | Que crearTasca retorni undefined, o la resposta crua en lloc d'una Tasca |
En conjunt: la prova passaria amb una implementació que cridés fetch, serialitzés alguna cosa i construís una tasca… i retornés null. I fallaria si una refactorització substituís JSON.stringify per una altra forma de serialitzar, sense que res estigués trencat.
// Reescrita: comprova comportament, i només la interacció que és observable
test('crearTasca envia POST amb el cos correcte i retorna la Tasca amb el seu id', async () => {
const xarxa = instalarFetchFals();
const nova = { titol: 'Revisar els extintors', responsable: 'Marta', prioritat: 'mitjana',
horesEstimades: 2, dataLimit: '2026-10-20', etiquetes: ['seguretat'] };
xarxa.mockResolvedValue(respostaJson({ ...nova, id: 7, estat: 'pendent' }, { status: 201 }));
const creada = await crearTasca(nova);
// 1 · El resultat: el que promet la funció
expect(creada).toBeInstanceOf(Tasca);
expect(creada.id).toBe(7); // R1: l'id l'assigna el servidor
expect(creada.estat).toBe('pendent'); // R5
expect(creada.titol).toBe('Revisar els extintors');
// 2 · La interacció, NOMÉS on és comportament observable pel servidor
const [url, opcions] = xarxa.mock.calls[0];
expect(String(url)).toContain('/tasques');
expect(opcions.method).toBe('POST');
expect(opcions.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(opcions.body)).toMatchObject({
titol: 'Revisar els extintors', horesEstimades: 2
});
});
test('crearTasca propaga un 422 de validació com a ErrorDeApi de client', async () => {
const xarxa = instalarFetchFals();
xarxa.mockResolvedValue(respostaJson({ missatge: 'Falten hores' }, { status: 422 }));
const error = await capturarAsinc(() => crearTasca({ titol: 'Sense hores' }));
expect(error).toBeInstanceOf(ErrorDeApi);
expect(error.status).toBe(422);
expect(error.reintentable).toBe(false);
expect(error.message).toBe('Falten hores');
});Conclusió
Les quatre dependències que espatllen una prova —xarxa, rellotge, emmagatzematge i aleatorietat— han deixat de ser un obstacle. Saps què és un doble de prova i manegues el vocabulari amb precisió: el dummy que només omple un buit, el stub que retorna respostes fixes, l'spy que observa deixant passar la crida real, el mock que a més imposa expectatives sobre com se'l crida, i el fake que és una implementació real simplificada. I tens clara la distinció operativa que importa: amb un stub afirmes sobre el resultat, amb un mock sobre la interacció, i per defecte es prefereix el primer.
Domines jest.fn() amb el seu registre complet (mock.calls, mock.lastCall, mock.results), els seus matchers (toHaveBeenCalledWith, toHaveBeenCalledTimes, toHaveBeenNthCalledWith) i les comparacions asimètriques —expect.objectContaining, expect.stringContaining, expect.any— que eviten lligar una prova a un objecte complet. Programes respostes amb mockReturnValue, mockResolvedValue, mockRejectedValue, mockImplementation i, sobretot, amb les variants ...Once que permeten simular seqüències: dos 503 seguits d'un èxit, una cosa impossible de reproduir contra un servidor real. Distingeixes mockClear, mockReset i mockRestore, i saps que restoreMocks i clearMocks a la configuració eliminen una família sencera de fallades entre proves. Fas servir jest.spyOn per observar sense substituir —comprovant que el desat real passa— i per silenciar un console.warn sense perdre l'asserció que es va emetre. I coneixes la mecànica de simular mòduls amb ESM, amb unstable_mockModule abans de qualsevol importació i await import després, a més de la simulació parcial que conserva la resta del mòdul intacta.
Nómada Tasques té ara cobert el que faltava. api-tasques.js es prova sense xarxa, amb Response reals i no objectes casolans, verificant que retorna instàncies de Tasca, que omet els filtres buits de la URL, que envia POST amb el cos correcte i que els cinc escenaris de fallada —404, 500, TypeError de transport, HTML inesperat i cancel·lació— produeixen ErrorDeApi amb el seu status, el seu codi i el seu reintentable exactes. El debounce es prova en microsegons amb temporitzadors falsos, inclòs el cas límit del mil·lisegon 299 i les vuit pulsacions de l'Iván teclejant «fusteria» que han de produir una crida amb l'últim valor. Els reintents amb retrocés exponencial es comproven amb advanceTimersByTimeAsync perquè les microtasques de l'await es processin, amb Math.random fixat a 0,5 per domar el jitter, afirmant la propietat («cada espera és més gran que l'anterior») en lloc de valors exactes que es trencarien en ajustar la fórmula. La data es congela quan cal, encara que el pla A continua sent el paràmetre avui = AVUI que 05-04 va tenir la precaució de deixar. I localStorage té el seu fake en memòria, fidel fins al String(valor) que imposa Web Storage, capaç de provocar el QuotaExceededError que en un navegador real és dificilíssim de reproduir: repositori-local.js passa del 31 % a cobertura gairebé completa, incloses les seves dues branques defensives.
I tens les dues advertències que separen una suite útil d'una que fa nosa. La primera: la injecció de dependències sol ser millor que el mockeig agressiu. Un paràmetre amb valor per defecte —{ llistar = llistarTasques } = {}, { magatzem }, avui = AVUI— deixa el codi de producció intacte, fa la dependència explícita a la signatura i converteix la prova en JavaScript normal, sense acoblar-la a l'API de l'executor; simular un mòdul es reserva per a les globals de l'entorn i el codi de tercers. La segona: les proves que només comproven que s'ha cridat un mock no protegeixen res. Passen encara que el resultat sigui incorrecte i fallen quan refactoritzes, convertint la xarxa de seguretat en una camisa de força. Afirma sobre el que el mòdul promet; comprova la interacció només quan algú de fora notaria la diferència —que un esborrat faci servir DELETE, que no es cridi l'API si la validació falla, que s'emeti TASCA_CANVIADA amb el seu detail—.
Amb això, les quatre capes de Nómada Tasques tenen proves unitàries: model, utilitats, dades i xarxa. Cadascuna aïllada de totes les altres, que és precisament la definició d'una prova unitària… i també el seu punt cec. Perquè totes aquestes proves donen per suposat un contracte: que RepositoriLocal desa exactament el que Tauler.importar sap llegir; que el JSON que produeix toJSON() és el que desDeJSON espera rebre; que el que TaulerVista renderitza és el que el controlador delegat de 06-04 sap interpretar pel seu data-id. Cada peça compleix la seva part del contracte a la seva pròpia prova, amb dobles que responen just el que la prova els ha dit que responguessin… i ningú no ha comprovat mai que les peces reals s'entenguin entre elles. Aquest buit —les fallades que només apareixen en ajuntar les coses: formats de data que no coincideixen, versions d'esquema mal migrades, errors que ningú captura a la costura— és el territori de Proves d'Integració, on muntaràs Tauler amb RepositoriLocal de debò, renderitzaràs la vista completa en un DOM sense navegador i provaràs el recorregut sencer de formulari a render.
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
