Totes les proves de la lliçó anterior eren síncrones: es renderitzava un component amb les seves props, es premia un botó i es comprovava el resultat en la mateixa volta del bucle d'esdeveniments. L'aplicació real no funciona així. PaginaCataleg demana les bicicletes a http://localhost:3001/bicicletas i pinta un esquelet mentre espera; useCrearReserva envia un POST i després invalida dues consultes; useDebounce retarda 400 ms; LimitError captura una fallada que passa després del primer render. Tan bon punt el temps entra en l'equació, una prova escrita com les anteriors falla —o, pitjor, passa per casualitat.
Aquesta lliçó cobreix el terreny on es trenca la majoria de les proves de front-end. Primer, les eines d'espera de Testing Library i l'avís d'act(...), que gairebé tothom silencia sense entendre'l. Després, MSW, que intercepta la xarxa a nivell de protocol i permet provocar a voluntat un error 500, una llista buida o una resposta lenta, per comprovar els tres estats de qualsevol pantalla: carregant, èxit i error. I finalment els hooks personalitzats amb renderHook, inclòs el cas difícil de combinar temporitzadors falsos amb React.
Contingut
- Per què l'asincronia trenca una prova ingènua
- Les eines d'espera:
findBy,waitForiwaitForElementToBeRemoved - L'avís
act(...), explicat de debò - Simular la xarxa: per què a nivell de protocol i no de mòdul
- MSW v2: instal·lació i gestors de CicloUrbano
servidor.jsi l'enganxament aconfiguracio.js- Provar components amb TanStack Query
- Els tres estats de la interfície: carregant, èxit i error
server.use: sobreescriure un gestor en una prova concreta- Provar una mutació completa
renderHook: provar hooks personalitzatsuseDebounce: temporitzadors falsos dins de ReactuseMagatzemLocali l'emmagatzematge del navegador- Provar un component que navega després d'una operació
- Provar
LimitErrori silenciar el soroll esperat - Com no escriure proves inestables
- Per què l'asincronia trenca una prova ingènua
// ❌ Aquesta prova falla SEMPRE
test('mostra les bicicletes del catàleg', () => {
renderitzar(<PaginaCataleg />);
expect(screen.getByText('Urbana Clàssica')).toBeInTheDocument();
});TestingLibraryElementError: Unable to find an element with the text: Urbana Clàssica
<body>
<div>
<div data-testid="esqueleto-pagina" aria-hidden="true" />
</div>
</body>El bolcat del DOM ho explica tot: en l'instant en què s'executa l'asserció, el component ha renderitzat una sola vegada i està en el seu estat isPending, pintant l'esquelet. La petició s'ha llançat, però la resposta no ha arribat; arribarà en algun moment d'una microtasca futura, i per llavors la prova ja haurà acabat.
Aquesta és la seqüència real, i convé tenir-la clara:
sequenceDiagram
participant P as Prova
participant R as React
participant Q as TanStack Query
participant N as Xarxa (MSW)
P->>R: renderitzar(<PaginaCataleg />)
R->>Q: useBicicletes()
Q->>N: GET /bicicletas
R-->>P: primer render · isPending · esquelet
Note over P: ❌ getByText falla AQUÍ
N-->>Q: 200 · [ …5 bicicletes… ]
Q->>R: dades disponibles
R-->>P: segon render · llista pintada
Note over P: ✅ aquí sí que hi seria
Hi ha dues maneres de resoldre-ho, i només una és correcta:
// ❌ MALAMENT: esperar un temps fix
await new Promise((r) => setTimeout(r, 100));
expect(screen.getByText('Urbana Clàssica')).toBeInTheDocument();
// ✅ BÉ: esperar QUE APAREGUI
expect(await screen.findByText('Urbana Clàssica')).toBeInTheDocument();Per què el setTimeout manual està prohibit en aquest projecte, amb tres arguments independents: és lent (sempre espera els 100 ms, encara que la dada arribi en 3); és inestable (en una màquina carregada d'integració contínua, 100 ms poden no bastar, i la prova falla una de cada vint execucions); i no diu què espera (el dia que falli, ningú sabrà si el problema és el retard o l'aplicació). L'espera per resultat, en canvi, acaba tan bon punt apareix l'element i falla amb un missatge que anomena allò que buscava.
- Les eines d'espera:
findBy, waitFor i waitForElementToBeRemoved
findBy, waitFor i waitForElementToBeRemovedTesting Library n'ofereix tres, i triar bé simplifica molt el codi.
| Eina | Espera que… | Retorna | Quan usar-la |
|---|---|---|---|
findBy* / findAllBy* |
Un element aparegui | L'element (promesa) | L'opció per defecte. Cobreix el 80 % dels casos |
waitFor(fn) |
Una asserció qualsevol deixi de llançar | El valor retornat per fn |
Quan el que canvia no és la presència d'un element |
waitForElementToBeRemoved(el) |
Un element desaparegui | undefined |
Comprovar que l'indicador de càrrega se'n va |
Totes reintenten cada 50 ms fins a exhaurir 1.000 ms per defecte, i totes fallen amb un missatge útil que inclou el DOM.
// 1) findBy: el més habitual
const titol = await screen.findByRole('heading', { name: 'Elèctrica Pro' });
// 2) findAllBy: una llista que arriba del servidor
const targetes = await screen.findAllByRole('article');
expect(targetes).toHaveLength(5);
// 3) waitFor: quan l'asserció no és "existeix un element"
await waitFor(() => {
expect(alCrearReserva).toHaveBeenCalledTimes(1);
});
// 4) waitForElementToBeRemoved: l'esquelet se'n va
await waitForElementToBeRemoved(() => screen.queryByTestId('esqueleto-pagina'));
// 5) Ajustar el temps d'espera quan estigui justificat
const lent = await screen.findByText('Llest', {}, { timeout: 3000 });findBy és getBy + waitFor combinats, literalment: internament fa waitFor(() => getBy(...)). Per això, sempre que el que esperis sigui l'aparició d'un element, findBy és més curt i produeix millors missatges d'error.
Les dues regles de waitFor
Regla 1: dins de waitFor no hi pot haver efectes secundaris. La funció s'executa moltes vegades —fins que deixi de llançar—, així que qualsevol acció que provoqui un canvi es repetirà:
// ❌ MALAMENT: el clic s'executaria diverses vegades
await waitFor(async () => {
await usuari.click(screen.getByRole('button', { name: 'Reservar' }));
expect(alReservar).toHaveBeenCalled();
});
// ✅ BÉ: l'efecte fora, l'asserció dins
await usuari.click(screen.getByRole('button', { name: 'Reservar' }));
await waitFor(() => expect(alReservar).toHaveBeenCalled());Regla 2: una sola asserció per waitFor. Si en fiques tres, la funció es reintenta fins que les tres passin alhora, i quan falli no sabràs quina era. Pitjor: si la primera passa i la tercera no ho fa mai, el missatge d'error assenyalarà la tercera però hauràs perdut un segon sencer reintentant les tres.
// ❌ Innecessàriament fràgil i lent
await waitFor(() => {
expect(screen.getByText('Urbana Clàssica')).toBeInTheDocument();
expect(screen.getByText('Elèctrica Pro')).toBeInTheDocument();
expect(screen.getAllByRole('article')).toHaveLength(5);
});
// ✅ Esperar una vegada, afirmar la resta de manera síncrona
await screen.findByText('Urbana Clàssica');
expect(screen.getByText('Elèctrica Pro')).toBeInTheDocument();
expect(screen.getAllByRole('article')).toHaveLength(5);El patró de la segona versió és el que s'usarà en tota la lliçó: una espera al principi, assercions síncrones després. Quan el primer element ha arribat, el render ja ha passat i la resta és al DOM.
- L'avís
act(...), explicat de debò
act(...), explicat de debòTard o d'hora apareix aquest missatge, i la reacció habitual —embolicar coses en act fins que calli— és gairebé sempre l'equivocada:
Warning: An update to PaginaCataleg inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...)
Què és act
act és una funció de React que delimita un bloc de treball: «executa això i no em retornis el control fins que hagis processat totes les actualitzacions d'estat, executat tots els efectes i aplicat els canvis al DOM». Al navegador, React processa les actualitzacions per lots quan li convé; en una prova, això seria una cursa contra l'asserció. act sincronitza els dos mons.
Què provoca l'avís
Sempre el mateix: un canvi d'estat que passa fora del control de la prova, és a dir, després que la prova hagi acabat el seu bloc síncron. Els casos concrets:
| Situació | Per què apareix | Solució |
|---|---|---|
| Una petició es resol i actualitza l'estat després de l'última asserció | La prova va acabar abans que la resposta | await screen.findBy… o await waitFor(…) |
Un setTimeout dins d'un efecte venç sense que ningú l'esperi |
El temporitzador dispara fora de act |
Temporitzadors falsos i act(() => vi.advanceTimersByTime(n)) |
| Es crida l'actualitzador d'un hook des de la prova | No hi ha act al voltant |
act(() => resultat.current.alternar()) |
| Un efecte asíncron actualitza l'estat després del desmuntatge | Fuga real de l'aplicació | Arreglar el component: cancel·lar amb AbortSignal |
El que NO cal fer
// ❌ Embolicar-ho tot a mà
await act(async () => {
render(<PaginaCataleg />);
});
await act(async () => {
await usuari.click(boto);
});És innecessari i contraproduent. render, userEvent, findBy i waitFor ja embolcallen la seva feina en act; afegir-hi més capes no aporta res i emmascara el problema real, que gairebé sempre és «has oblidat esperar alguna cosa».
// ✅ La causa era una espera que faltava
render(<PaginaCataleg />);
await screen.findByText('Urbana Clàssica'); // l'avís desapareixL'únic ús legítim d'act a mà és el de l'apartat 11: cridar directament l'actualitzador d'un hook provat amb renderHook, on no hi ha ni component ni interacció que l'embolcalli.
La versió més difícil: l'avís després del final de la prova
De vegades l'avís apareix després que la prova hagi passat, a l'informe. Significa que l'aplicació ha continuat actualitzant l'estat quan ja no ho hauria de fer. Això no és un problema de la prova: és una fuga de l'aplicació —un efecte que no cancel·la la seva petició en desmuntar-se— i cal arreglar-ho al component. useBicicletes rep el signal de queryFn precisament per això (07-06), i aquesta decisió és la que evita l'avís aquí.
- Simular la xarxa: per què a nivell de protocol i no de mòdul
Hi ha quatre maneres d'evitar que una prova cridi l'API de veritat. No són equivalents.
| Estratègia | Com | Què es prova realment | Problema |
|---|---|---|---|
| Simular el mòdul de l'API | vi.mock('./consultes/api.js') |
El component, amb una API falsa | No es prova la construcció de la URL, ni la serialització, ni la gestió de codis HTTP. Si l'API real canvia, la prova segueix en verd |
Simular fetch global |
global.fetch = vi.fn() |
Una mica més: la URL sí que es veu | Cal reimplementar Response, ok, json(), capçaleres… a mà i per a cada cas |
| Interceptar la xarxa (MSW) | Un gestor per ruta | Tot el camí: URL, mètode, cos, capçaleres, codis d'estat | Requereix muntar el servidor una vegada |
API real (json-server) |
No simular res | Tot, inclosa la base de dades | Lent, requereix un procés aixecat, comparteix estat entre proves |
MSW (Mock Service Worker) és l'opció del projecte perquè intercepta a nivell de petició, no de mòdul. A Node ho fa interceptant les interfícies de xarxa; al navegador, amb un service worker. El codi de l'aplicació no se n'assabenta: fetch es crida de veritat, amb la seva URL de veritat, i rep una Response de veritat.
Els avantatges concrets per a CicloUrbano:
- La prova exercita
useBicicletessencer, incloent-hi la construcció dehttp://localhost:3001/bicicletas?estacioId=est-01, la comprovació deresposta.oki elresposta.json(). Una errada de tecleig a la URL trenca la prova, tal com ha de ser. - Els mateixos gestors serveixen en desenvolupament, si algun dia es vol treballar sense backend, i en les proves de Cypress.
- No hi ha res a sincronitzar. Si
useCrearReservacanvia el mètode dePOSTaPUT, el gestor no respon i la prova falla. Ambvi.mockhauria seguit en verd. - Provocar errors és trivial: retornar un 500 és una línia, i reproduir-ho amb l'API real exigiria apagar
json-servera mitja prova.
- MSW v2: instal·lació i gestors de CicloUrbano
Els gestors són la descripció de l'API falsa: què respon cada ruta. A MSW v2 l'API és http.get/http.post/… i HttpResponse.
// src/proves/gestors.js
import { http, HttpResponse } from 'msw';
const API = 'http://localhost:3001';
// El domini fictici de CicloUrbano, en un sol lloc i exportat
// perquè les proves puguin afirmar sobre les mateixes dades.
export const BICICLETES = [
{ id: 'bici-001', model: 'Urbana Clàssica', tipus: 'urbana', estat: 'disponible', estacioId: 'est-01', preuHora: 2.5 },
{ id: 'bici-002', model: 'Elèctrica Pro', tipus: 'electrica', estat: 'alquilada', estacioId: 'est-01', preuHora: 4.0 },
{ id: 'bici-003', model: 'Càrrega Max', tipus: 'carga', estat: 'mantenimiento', estacioId: 'est-02', preuHora: 5.5 },
{ id: 'bici-004', model: 'Urbana Clàssica', tipus: 'urbana', estat: 'disponible', estacioId: 'est-03', preuHora: 2.5 },
{ id: 'bici-005', model: 'Elèctrica Pro', tipus: 'electrica', estat: 'disponible', estacioId: 'est-02', preuHora: 4.0 }
];
export const ESTACIONS = [
{ id: 'est-01', nom: 'Plaça Major', barri: 'Centre', places: 20 },
{ id: 'est-02', nom: 'Parc Nord', barri: 'Nord', places: 15 },
{ id: 'est-03', nom: 'Estació Central', barri: 'Eixample', places: 30 }
];
export const RESERVES = [
{
id: 'res-01', bicicletaId: 'bici-002', usuari: 'usr-01',
dataInici: '2026-05-04T09:00', hores: 2, estat: 'activa'
}
];
export const gestors = [
// GET /bicicletas · admet ?estacionId= i ?tipo= com json-server
http.get(`${API}/bicicletas`, ({ request }) => {
const url = new URL(request.url);
const estacioId = url.searchParams.get('estacioId');
const tipus = url.searchParams.get('tipo');
let resultat = BICICLETES;
if (estacioId) resultat = resultat.filter((b) => b.estacioId === estacioId);
if (tipus && tipus !== 'todos') resultat = resultat.filter((b) => b.tipus === tipus);
return HttpResponse.json(resultat);
}),
// GET /bicicletas/:bicicletaId
http.get(`${API}/bicicletas/:bicicletaId`, ({ params }) => {
const bicicleta = BICICLETES.find((b) => b.id === params.bicicletaId);
if (!bicicleta) {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json(bicicleta);
}),
http.get(`${API}/estaciones`, () => HttpResponse.json(ESTACIONS)),
http.get(`${API}/estaciones/:estacionId`, ({ params }) => {
const estacio = ESTACIONS.find((e) => e.id === params.estacionId);
return estacio
? HttpResponse.json(estacio)
: new HttpResponse(null, { status: 404 });
}),
http.get(`${API}/reservas`, ({ request }) => {
const usuari = new URL(request.url).searchParams.get('usuari');
const resultat = usuari ? RESERVES.filter((r) => r.usuari === usuari) : RESERVES;
return HttpResponse.json(resultat);
}),
// POST /reservas · retorna el rebut amb el seu identificador, com json-server
http.post(`${API}/reservas`, async ({ request }) => {
const nova = await request.json();
return HttpResponse.json({ id: nova.id ?? 'res-nueva', ...nova }, { status: 201 });
}),
// PATCH /reservas/:id · confirmar i cancel·lar
http.patch(`${API}/reservas/:reservaId`, async ({ params, request }) => {
const canvis = await request.json();
const reserva = RESERVES.find((r) => r.id === params.reservaId);
if (!reserva) return new HttpResponse(null, { status: 404 });
return HttpResponse.json({ ...reserva, ...canvis });
})
];Les claus d'aquesta API:
| Element | Què fa |
|---|---|
http.get(url, resolutor) |
Declara què respondre a un GET d'aquesta URL |
:parametre a la ruta |
Segment variable; arriba a params |
({ request, params }) |
El context del resolutor: la petició i els paràmetres de ruta |
HttpResponse.json(dades) |
Resposta 200 amb cos JSON |
HttpResponse.json(d, { status }) |
Amb un altre codi d'estat |
new HttpResponse(null, { status: 500 }) |
Resposta sense cos |
await request.json() |
El cos de la petició, per verificar què s'ha enviat |
Dues decisions deliberades:
- Les dades s'exporten. Així una prova pot escriure
expect(await screen.findAllByRole('article')).toHaveLength(BICICLETES.length)sense duplicar el número 5 en vint llocs. - Els gestors repliquen el comportament de
json-server, inclosos els filtres per cadena de consulta i el201delPOST. Com més s'assembli la simulació a l'API real, menys possibilitats que la prova passi i l'aplicació falli.
servidor.js i l'enganxament a configuracio.js
servidor.js i l'enganxament a configuracio.js// src/proves/servidor.js
import { setupServer } from 'msw/node';
import { gestors } from './gestors.js';
// A Node (Vitest) s'usa setupServer. Al navegador seria setupWorker.
export const servidor = setupServer(...gestors);// src/proves/configuracio.js · versió completa del mòdul
import '@testing-library/jest-dom/vitest';
import { afterEach, afterAll, beforeAll } from 'vitest';
import { cleanup } from '@testing-library/react';
import { servidor } from './servidor.js';
beforeAll(() => {
// onUnhandledRequest: 'error' → qualsevol petició sense gestor TRENCA la prova.
// És el que es vol: una petició inesperada és una fallada, no una cosa a ignorar.
servidor.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
cleanup();
localStorage.clear();
// Desfà els server.use() de la prova que acaba d'acabar:
// sense això, un 500 forçat en una prova contaminaria totes les següents.
servidor.resetHandlers();
});
afterAll(() => {
servidor.close();
});Els tres ganxos són un trio inseparable, i cadascun previé una fallada concreta:
| Ganxo | Què previé si falta |
|---|---|
servidor.listen() a beforeAll |
Sense ell, les peticions surten de veritat: la prova depèn que json-server estigui aixecat |
servidor.resetHandlers() a afterEach |
Sense ell, un server.use amb un error 500 es queda actiu i trenca proves posteriors de manera aparentment aleatòria |
servidor.close() a afterAll |
Sense ell, el procés de Node es pot quedar penjat en acabar la suite |
I onUnhandledRequest: 'error' mereix defensa pròpia: converteix en fallada qualsevol petició per a la qual no hi hagi gestor. Pot semblar sever, però és l'única manera d'assabentar-se que un component està cridant un endpoint que ningú va preveure. Amb l'opció per defecte ('warn'), aquesta petició sortiria a la xarxa real, trigaria el que trigui i probablement fallaria amb un missatge incomprensible.
flowchart LR
A["Component"] -->|"fetch('http://localhost:3001/bicicletas')"| B["Capa de xarxa de Node"]
B --> C{"MSW té gestor?"}
C -- Sí --> D["Resolutor de gestors.js"]
D --> E["HttpResponse.json(BICICLETES)"]
E --> A
C -- No --> F["onUnhandledRequest: 'error'<br/>la prova falla"]
- Provar components amb TanStack Query
renderitzar (09-03) ja crea un QueryClient nou a cada crida amb retry: false. Les dues decisions són imprescindibles, i convé entendre per què.
Un client per prova. El QueryClient és una memòria cau. Si es comparteix entre proves, la segona troba les dades de la primera ja en la memòria cau i no fa la petició, així que:
- Una prova que vol comprovar l'estat de càrrega mai no el veu: la dada ja hi és.
- Una prova que força un 500 amb
server.useno ho observa: se serveix la resposta de la memòria cau. - El resultat depèn de l'ordre d'execució, que és la definició de prova inestable.
retry: false. Per defecte, clientConsultes reintenta dues vegades amb retrocés exponencial (07-06). En una prova d'error, això significa esperar la primera fallada, un segon, la segona fallada, dos segons… i exhaurir el temps d'espera d'1.000 ms molt abans que aparegui el missatge. És la causa número u de «la meva prova d'error es penja i no entenc per què».
// src/proves/utilitats.jsx (recordatori de l'apartat 13 de 09-03)
export function crearClientDeProva() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity, staleTime: 0 },
mutations: { retry: false }
}
});
}
- Els tres estats de la interfície: carregant, èxit i error
Tota pantalla que demani dades té tres estats, i els tres mereixen prova. El d'error és el que ningú comprova a mà i el que més falla.
// src/pagines/PaginaCataleg.test.jsx
import { renderitzar, screen, waitForElementToBeRemoved, userEvent } from '../proves/utilitats.jsx';
import { servidor } from '../proves/servidor.js';
import { BICICLETES } from '../proves/gestors.js';
import { http, HttpResponse, delay } from 'msw';
import PaginaCataleg from './PaginaCataleg.jsx';
const API = 'http://localhost:3001';
describe('PaginaCataleg', () => {
test('mostra l\'esquelet mentre carreguen les bicicletes', () => {
renderitzar(<PaginaCataleg />);
// Asserció SÍNCRONA, immediatament després de renderitzar: aquí encara no ha arribat res
expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();
expect(screen.queryByRole('article')).not.toBeInTheDocument();
});
test('substitueix l\'esquelet per la llista quan arriben les dades', async () => {
renderitzar(<PaginaCataleg />);
await waitForElementToBeRemoved(() => screen.queryByTestId('esqueleto-pagina'));
expect(screen.getAllByRole('article')).toHaveLength(BICICLETES.length);
expect(screen.getByRole('heading', { name: 'Càrrega Max' })).toBeInTheDocument();
});
test('mostra cada bicicleta amb el seu estat i el seu preu', async () => {
renderitzar(<PaginaCataleg />);
await screen.findByRole('heading', { name: 'Càrrega Max' });
// Una sola espera; la resta ja és al DOM i es comprova de manera síncrona
expect(screen.getByText('En manteniment')).toBeInTheDocument();
expect(screen.getAllByText('Disponible')).toHaveLength(3);
expect(screen.getByText(/5,50\s*€/)).toBeInTheDocument();
});
});La primera prova és l'única de la lliçó amb una asserció síncrona després de renderitzar, i és correcta precisament perquè comprova l'estat inicial: en aquest instant la petició està en vol i el component pinta l'esquelet. Si algú eliminés l'estat de càrrega i la pantalla es quedés en blanc, aquesta prova ho detectaria.
server.use: sobreescriure un gestor en una prova concreta
server.use: sobreescriure un gestor en una prova concretaEls gestors de gestors.js són el cas feliç. Per a la resta, servidor.use(...) afegeix un gestor que té prioritat sobre els de base i que dura només fins al resetHandlers() de l'afterEach.
Provocar un error del servidor
test('mostra un missatge d\'error i un botó de reintent si l\'API falla', async () => {
servidor.use(
http.get(`${API}/bicicletas`, () => new HttpResponse(null, { status: 500 }))
);
renderitzar(<PaginaCataleg />);
// El missatge és el que veu l'usuari; el "500" és un detall que no s'ha de filtrar
expect(await screen.findByRole('alert')).toHaveTextContent(/no s'han pogut carregar/i);
expect(screen.getByRole('button', { name: /Reintentar/ })).toBeInTheDocument();
expect(screen.queryByRole('article')).not.toBeInTheDocument();
});Recuperar-se després de l'error
Aquesta és la prova que de debò justifica el botó de reintent, i és impossible de fer a mà amb comoditat:
test('el botó de reintent torna a demanar les dades i pinta la llista', async () => {
// Primer intent: falla. Els gestors de `use` amb `{ once: true }` es
// consumeixen després de respondre un cop, així que el segon intent cau en el de base.
servidor.use(
http.get(`${API}/bicicletas`, () => new HttpResponse(null, { status: 500 }), { once: true })
);
const usuari = userEvent.setup();
renderitzar(<PaginaCataleg />);
await screen.findByRole('alert');
await usuari.click(screen.getByRole('button', { name: /Reintentar/ }));
expect(await screen.findByRole('heading', { name: 'Càrrega Max' })).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});El cas de la llista buida
Un estat que s'oblida sempre i que produeix pantalles en blanc en producció:
test('mostra un missatge propi quan no hi ha bicicletes', async () => {
servidor.use(
http.get(`${API}/bicicletas`, () => HttpResponse.json([]))
);
renderitzar(<PaginaCataleg />);
expect(await screen.findByText(/No hi ha bicicletes que coincideixin/)).toBeInTheDocument();
expect(screen.queryByRole('article')).not.toBeInTheDocument();
});Una resposta lenta
delay de MSW permet comprovar que l'estat de càrrega es manté mentre dura l'espera:
test('manté l\'esquelet mentre la resposta triga', async () => {
servidor.use(
http.get(`${API}/bicicletas`, async () => {
await delay(200);
return HttpResponse.json(BICICLETES);
})
);
renderitzar(<PaginaCataleg />);
expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();
// I acaba arribant: s'espera el resultat, no els 200 ms
expect(await screen.findByRole('heading', { name: 'Càrrega Max' })).toBeInTheDocument();
});Fixa't que ni tan sols aquí s'espera un temps fix. delay(200) és al gestor, al costat del servidor simulat; la prova segueix esperant el resultat. És la diferència entre controlar la latència i endevinar-la.
Verificar el filtre per cadena de consulta
test('demana només les bicicletes del tipus indicat a la URL', async () => {
renderitzar(<PaginaCataleg />, { ruta: '/?tipo=electrica' });
await screen.findByRole('heading', { name: 'Elèctrica Pro' });
// El gestor filtra per ?tipo=, així que només han d'arribar les dues elèctriques
expect(screen.getAllByRole('article')).toHaveLength(2);
expect(screen.queryByRole('heading', { name: 'Càrrega Max' })).not.toBeInTheDocument();
});Aquesta prova recorre la cadena completa: la URL → useSearchParams → la clau de consulta → la URL de la petició → el filtre del gestor → la llista pintada. Cap prova unitària pot cobrir això, i és exactament el tipus de cablejat que es trenca en una refactorització.
- Provar una mutació completa
Una mutació té tres coses a verificar, i la tercera és la que més s'oblida: què s'envia, què es mostra i què es refresca després.
// src/pagines/PaginaNovaReserva.test.jsx
import { renderitzar, screen, userEvent, waitFor } from '../proves/utilitats.jsx';
import { servidor } from '../proves/servidor.js';
import { http, HttpResponse, delay } from 'msw';
import PaginaNovaReserva from './PaginaNovaReserva.jsx';
const API = 'http://localhost:3001';
const SESSIO_ANA = {
sessio: {
usuari: { id: 'usr-01', nom: 'Ana Ribera', email: '[email protected]', rol: 'cliente' },
carregant: false,
error: null
}
};
describe('PaginaNovaReserva', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date('2026-05-04T08:00:00'));
});
afterEach(() => vi.useRealTimers());
test('envia la reserva amb el cos correcte i avisa de l\'èxit', async () => {
// Espia del cos enviat: el gestor el captura per poder afirmar-hi sobre
let cosRebut = null;
servidor.use(
http.post(`${API}/reservas`, async ({ request }) => {
cosRebut = await request.json();
return HttpResponse.json({ ...cosRebut }, { status: 201 });
})
);
const usuari = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
renderitzar(<PaginaNovaReserva />, { estatInicial: SESSIO_ANA });
// Les bicicletes del selector arriben de l'API: cal esperar-les
await screen.findByRole('option', { name: /Urbana Clàssica/ });
await usuari.selectOptions(screen.getByLabelText('Bicicleta'), 'bici-001');
await usuari.type(screen.getByLabelText('Inici de la reserva'), '2026-05-04T10:00');
await usuari.clear(screen.getByLabelText('Durada (hores)'));
await usuari.type(screen.getByLabelText('Durada (hores)'), '3');
await usuari.click(screen.getByRole('checkbox', { name: /Accepto les condicions/ }));
await usuari.click(screen.getByRole('button', { name: 'Crear reserva' }));
// 1) Què s'ha enviat
await waitFor(() => expect(cosRebut).not.toBeNull());
expect(cosRebut).toMatchObject({
bicicletaId: 'bici-001',
usuari: 'usr-01',
dataInici: '2026-05-04T10:00',
hores: 3,
estat: 'activa'
});
// 2) Què veu l'usuari
expect(await screen.findByRole('status')).toHaveTextContent(/Reserva creada/);
});
test('deshabilita el botó mentre s\'envia', async () => {
servidor.use(
http.post(`${API}/reservas`, async ({ request }) => {
await delay(100);
return HttpResponse.json(await request.json(), { status: 201 });
})
);
const usuari = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
renderitzar(<PaginaNovaReserva />, { estatInicial: SESSIO_ANA });
await omplirFormulariValid(usuari);
await usuari.click(screen.getByRole('button', { name: 'Crear reserva' }));
expect(screen.getByRole('button', { name: /Creant/ })).toBeDisabled();
expect(await screen.findByRole('status')).toHaveTextContent(/Reserva creada/);
});
test('mostra l\'error sense perdre les dades del formulari si l\'enviament falla', async () => {
servidor.use(
http.post(`${API}/reservas`, () => new HttpResponse(null, { status: 500 }))
);
const usuari = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
renderitzar(<PaginaNovaReserva />, { estatInicial: SESSIO_ANA });
await omplirFormulariValid(usuari);
await usuari.click(screen.getByRole('button', { name: 'Crear reserva' }));
expect(await screen.findByRole('alert')).toHaveTextContent(/No s'ha pogut crear la reserva/);
// I l'important: la feina de l'usuari no es perd
expect(screen.getByLabelText('Durada (hores)')).toHaveValue(3);
});
});Comprovar la invalidació
La part que gairebé ningú prova i que és la raó de ser de l'onSuccess de useCrearReserva: després de crear una reserva, la llista es recarrega.
test('la llista de reserves es refresca després de crear-ne una de nova', async () => {
let peticionsDeReserves = 0;
const reserves = [];
servidor.use(
http.get(`${API}/reservas`, () => {
peticionsDeReserves += 1;
return HttpResponse.json(reserves);
}),
http.post(`${API}/reservas`, async ({ request }) => {
const nova = await request.json();
reserves.push(nova); // el "servidor" guarda de veritat
return HttpResponse.json(nova, { status: 201 });
})
);
const usuari = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
renderitzar(<PaginaReserves />, { estatInicial: SESSIO_ANA });
expect(await screen.findByText(/No tens reserves/)).toBeInTheDocument();
expect(peticionsDeReserves).toBe(1);
await usuari.click(screen.getByRole('button', { name: /Nova reserva/ }));
await omplirFormulariValid(usuari);
await usuari.click(screen.getByRole('button', { name: 'Crear reserva' }));
// La invalidació de claus.reserves.totes() provoca una segona petició…
await waitFor(() => expect(peticionsDeReserves).toBe(2));
// …i la reserva apareix a la llista
expect(await screen.findByText('Urbana Clàssica')).toBeInTheDocument();
});El gestor amb estat (reserves.push) converteix MSW en un servidor de veritat, en miniatura. És el que permet verificar el cicle complet: crear, invalidar, recarregar, pintar. I comprovar el nombre de peticions és aquí legítim, perquè la recàrrega després de la mutació sí és comportament observable: si desapareix, l'usuari veu una llista desactualitzada.
renderHook: provar hooks personalitzats
renderHook: provar hooks personalitzatsUn hook no es pot cridar fora d'un component. renderHook munta un component mínim la única feina del qual és cridar el hook i exposar el seu valor a result.current.
// src/hooks/useAlternar.test.js
import { renderHook, act } from '@testing-library/react';
import { describe, test, expect } from 'vitest';
import { useAlternar } from './useAlternar.js';
describe('useAlternar', () => {
test('comença en false per defecte', () => {
const { result } = renderHook(() => useAlternar());
const [valor] = result.current;
expect(valor).toBe(false);
});
test('respecta el valor inicial rebut', () => {
const { result } = renderHook(() => useAlternar(true));
expect(result.current[0]).toBe(true);
});
test('alternar inverteix el valor', () => {
const { result } = renderHook(() => useAlternar(false));
// act: sense ell, React avisa que l'actualització no està embolcallada,
// i result.current no reflectiria el valor nou
act(() => result.current[1].alternar());
expect(result.current[0]).toBe(true);
act(() => result.current[1].alternar());
expect(result.current[0]).toBe(false);
});
test('activar i desactivar fixen el valor amb independència de l\'anterior', () => {
const { result } = renderHook(() => useAlternar(false));
act(() => result.current[1].activar());
act(() => result.current[1].activar());
expect(result.current[0]).toBe(true);
act(() => result.current[1].desactivar());
expect(result.current[0]).toBe(false);
});
test('les tres accions mantenen la seva identitat entre renders', () => {
const { result } = renderHook(() => useAlternar(false));
const accionsInicials = result.current[1];
act(() => result.current[1].alternar());
// useCallback amb [] les estabilitza: és la promesa del hook (05-06)
expect(result.current[1]).toBe(accionsInicials);
});
});Aquí hi ha l'únic ús legítim d'act a mà que s'anunciava a l'apartat 3: cridar directament un actualitzador d'estat sense que hi mitjanci ni interacció ni petició. Sense act, React no processa l'actualització abans de retornar el control i result.current es queda amb el valor antic.
L'última prova mereix atenció: verifica l'estabilitat d'identitat que useCallback amb dependències buides garanteix. És comportament observable, no implementació, perquè d'aquesta estabilitat depèn que un efecte que rebi alternar a les seves dependències no es reexecuti en bucle. Si algú treu el useCallback, aquesta prova falla i explica per què.
Opcions útils de renderHook
// Canviar les props del hook entre renders
const { result, rerender } = renderHook(({ valor }) => useValorPrevi(valor), {
initialProps: { valor: 'urbana' }
});
expect(result.current).toBeUndefined();
rerender({ valor: 'electrica' });
expect(result.current).toBe('urbana'); // el valor anterior
// Embolcallar el hook amb proveïdors: per a hooks que usen context o Redux
const { result } = renderHook(() => useTema(), { wrapper: ProveidorTema });
expect(result.current.tema).toBe('clar');
useDebounce: temporitzadors falsos dins de React
useDebounce: temporitzadors falsos dins de ReactA 09-02 es va provar el mecanisme del retard com a funció pura. Ara toca el hook, i apareix la dificultat de combinar els temporitzadors falsos amb les actualitzacions de React.
// src/hooks/useDebounce.test.js
import { renderHook, act } from '@testing-library/react';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { useDebounce } from './useDebounce.js';
describe('useDebounce', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
test('retorna el valor inicial immediatament', () => {
const { result } = renderHook(() => useDebounce('urbana', 400));
expect(result.current).toBe('urbana');
});
test('no propaga el valor nou abans que venci el retard', () => {
const { result, rerender } = renderHook(({ v }) => useDebounce(v, 400), {
initialProps: { v: 'urb' }
});
rerender({ v: 'urbana' });
// L'efecte ha programat el temporitzador, però encara no ha vençut
expect(result.current).toBe('urb');
act(() => vi.advanceTimersByTime(399));
expect(result.current).toBe('urb');
});
test('propaga el valor quan es compleix el retard', () => {
const { result, rerender } = renderHook(({ v }) => useDebounce(v, 400), {
initialProps: { v: 'urb' }
});
rerender({ v: 'urbana' });
// act embolcalla l'avanç del rellotge: el setTimeout venç DINS d'act,
// així que React processa el setState resultant abans de retornar el control
act(() => vi.advanceTimersByTime(400));
expect(result.current).toBe('urbana');
});
test('sis canvis ràpids produeixen un sol valor final', () => {
const { result, rerender } = renderHook(({ v }) => useDebounce(v, 400), {
initialProps: { v: 'u' }
});
for (const v of ['ur', 'urb', 'urba', 'urban', 'urbana']) {
rerender({ v });
act(() => vi.advanceTimersByTime(100)); // 100 ms entre tecles
}
// Han passat 500 ms en total, però mai 400 seguits sense canvis
expect(result.current).toBe('u');
act(() => vi.advanceTimersByTime(400));
expect(result.current).toBe('urbana');
});
test('el temporitzador pendent es cancel·la en desmuntar', () => {
const { rerender, unmount } = renderHook(({ v }) => useDebounce(v, 400), {
initialProps: { v: 'urb' }
});
rerender({ v: 'urbana' });
unmount();
// La neteja de l'efecte ha cridat clearTimeout: no queda res pendent
expect(vi.getTimerCount()).toBe(0);
});
});Les tres regles d'aquesta combinació:
act(() => vi.advanceTimersByTime(n)), sempre junts. Avançar el rellotge fa vèncer unsetTimeoutque cridasetValorRetardat; senseact, aquesta actualització queda fora del control de React,result.currentno es refresca i apareix l'avís de l'apartat 3.vi.useRealTimers()a l'afterEach, sense excepció. Els temporitzadors falsos que sobreviuen a la prova pengen les següents.- Temporitzadors falsos i
userEventes porten malament per defecte.userEventespera amb temporitzadors reals entre esdeveniments; si els has congelat, es bloqueja. Les dues solucions ja usades en aquesta lliçó:vi.useFakeTimers({ shouldAdvanceTime: true }), o passaruserEvent.setup({ advanceTimers: vi.advanceTimersByTime }).
L'última prova, la del desmuntatge, verifica la neteja de l'efecte —el return () => clearTimeout(identificador) de 05-02—. És una prova d'una fuga de memòria, i no hi ha cap altra manera raonable de comprovar-la.
useMagatzemLocal i l'emmagatzematge del navegador
useMagatzemLocal i l'emmagatzematge del navegadorjsdom proporciona un localStorage funcional, així que no cal simular res: n'hi ha prou de deixar-lo net entre proves, cosa que ja fa configuracio.js.
// src/hooks/useMagatzemLocal.test.js
import { renderHook, act } from '@testing-library/react';
import { describe, test, expect, vi, afterEach } from 'vitest';
import { useMagatzemLocal } from './useMagatzemLocal.js';
describe('useMagatzemLocal', () => {
afterEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
test('usa el valor inicial quan no hi ha res desat', () => {
const { result } = renderHook(() => useMagatzemLocal('ciclourbano:tema', 'clar'));
expect(result.current[0]).toBe('clar');
});
test('llegeix el valor desat en el primer render, sense parpelleig', () => {
localStorage.setItem('ciclourbano:tema', JSON.stringify('fosc'));
const { result } = renderHook(() => useMagatzemLocal('ciclourbano:tema', 'clar'));
// 'fosc' en el PRIMER valor: la lectura va a l'inicialitzador mandrós (05-06),
// no a un efecte. Si fos en un efecte, aquí veuríem 'clar'.
expect(result.current[0]).toBe('fosc');
});
test('desa el valor nou a l\'emmagatzematge', () => {
const { result } = renderHook(() => useMagatzemLocal('ciclourbano:tema', 'clar'));
act(() => result.current[1]('fosc'));
expect(result.current[0]).toBe('fosc');
expect(JSON.parse(localStorage.getItem('ciclourbano:tema'))).toBe('fosc');
});
test('cau al valor inicial si el que hi ha desat no és JSON vàlid', () => {
localStorage.setItem('ciclourbano:tema', '{{{això no és json');
const { result } = renderHook(() => useMagatzemLocal('ciclourbano:tema', 'clar'));
expect(result.current[0]).toBe('clar'); // el try/catch fa la seva feina
});
test('no es trenca si l\'emmagatzematge llança en escriure (quota o mode privat)', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('QuotaExceededError');
});
const { result } = renderHook(() => useMagatzemLocal('ciclourbano:tema', 'clar'));
// L'aplicació continua funcionant encara que no es pugui persistir
expect(() => act(() => result.current[1]('fosc'))).not.toThrow();
expect(result.current[0]).toBe('fosc');
});
});Les dues últimes proves són la raó de ser dels try/catch que 05-06 defensava com a «no paranoia». Provocar una quota exhaurida a mà requereix omplir l'emmagatzematge del navegador; amb vi.spyOn(Storage.prototype, 'setItem') són tres línies. I la del JSON corrupte reprodueix un cas real: una versió anterior de l'aplicació hi va desar una altra cosa sota la mateixa clau.
- Provar un component que navega després d'una operació
Combina la navegació de 09-03 amb l'asincronia d'aquesta lliçó: després de crear la reserva, l'aplicació porta l'usuari a /reservas.
test('porta a /reservas després de crear la reserva', async () => {
const usuari = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const { enrutador } = renderitzar(null, {
ruta: '/reservas/nueva',
estatInicial: SESSIO_ANA,
rutes: [
{ path: '/reservas/nueva', element: <PaginaNovaReserva /> },
{ path: '/reservas', element: <h1>Les teves reserves</h1> }
]
});
await omplirFormulariValid(usuari);
await usuari.click(screen.getByRole('button', { name: 'Crear reserva' }));
// S'espera la DESTINACIÓ, no un temps
expect(await screen.findByRole('heading', { name: 'Les teves reserves' })).toBeInTheDocument();
expect(enrutador.state.location.pathname).toBe('/reservas');
});La ruta de destinació se substitueix per un component mínim pel mateix motiu que a 09-03: la prova verifica que es navega, i muntar la pàgina real portaria les seves pròpies consultes i convertiria una fallada de navegació en una fallada de dades.
- Provar
LimitError i silenciar el soroll esperat
LimitError i silenciar el soroll esperatLimitError és l'única classe del projecte (04-05). Provar-lo té una particularitat: React sempre escriu l'error a la consola, fins i tot quan el límit el captura correctament. Si no se silencia, la sortida de la suite s'omple de traces vermelles que semblen fallades i no ho són.
// src/components/LimitError.test.jsx
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import LimitError from './LimitError.jsx';
// Component que explota sota demanda
function ComponentQueFalla({ fallar = true }) {
if (fallar) throw new Error('La flota no està disponible');
return <p>Contingut correcte</p>;
}
describe('LimitError', () => {
let espiaConsola;
beforeEach(() => {
// React escriu l'error capturat: és soroll ESPERAT, no una fallada
espiaConsola = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
test('deixa passar els fills quan no hi ha error', () => {
render(
<LimitError titol="Alguna cosa ha fallat">
<ComponentQueFalla fallar={false} />
</LimitError>
);
expect(screen.getByText('Contingut correcte')).toBeInTheDocument();
expect(espiaConsola).not.toHaveBeenCalled();
});
test('mostra la interfície de reserva quan un fill llança', () => {
render(
<LimitError titol="CicloUrbano no està disponible ara mateix">
<ComponentQueFalla />
</LimitError>
);
expect(screen.getByRole('heading', { name: /no està disponible/ })).toBeInTheDocument();
expect(screen.queryByText('Contingut correcte')).not.toBeInTheDocument();
});
test('notifica l\'error al servei de monitorització', () => {
const alRegistrar = vi.fn();
render(
<LimitError titol="Error" alRegistrar={alRegistrar}>
<ComponentQueFalla />
</LimitError>
);
expect(alRegistrar).toHaveBeenCalledTimes(1);
expect(alRegistrar).toHaveBeenCalledWith(
expect.objectContaining({ message: 'La flota no està disponible' }),
expect.anything() // la informació del component que React passa com a segon argument
);
});
test('el botó de reintent torna a muntar els fills', async () => {
const usuari = userEvent.setup();
function Contenidor() {
const [fallar, setFallar] = useState(true);
return (
<>
<button type="button" onClick={() => setFallar(false)}>Arreglar</button>
<LimitError titol="Error">
<ComponentQueFalla fallar={fallar} />
</LimitError>
</>
);
}
render(<Contenidor />);
expect(screen.getByRole('heading', { name: 'Error' })).toBeInTheDocument();
await usuari.click(screen.getByRole('button', { name: 'Arreglar' }));
await usuari.click(screen.getByRole('button', { name: /Reintentar/ }));
expect(screen.getByText('Contingut correcte')).toBeInTheDocument();
});
});Sobre el silenciament, dues precisions importants:
- Se silencia només en les proves que provoquen l'error a propòsit, mai de manera global a
configuracio.js. Unconsole.errorglobal desactivaria també els avisos legítims de React —claus duplicades, hooks mal usats, props no vàlides—, que són informació valuosa. vi.restoreAllMocks()a l'afterEachés obligatori. Sense ell, la consola queda muda per a la resta del fitxer.
I fixa't en la primera prova: expect(espiaConsola).not.toHaveBeenCalled() comprova que en el camí feliç no hi ha cap avís. És una comprovació barata que detecta claus duplicades i altres avisos de React que d'altra manera passarien desapercebuts.
- Com no escriure proves inestables
Recapitulació operativa de tot l'anterior, en forma de regles:
| Regla | Per què | Com s'aplica a CicloUrbano |
|---|---|---|
| Mai esperis un temps fix | Lent sempre, insuficient de vegades | findBy*, waitFor, waitForElementToBeRemoved |
| Espera al que es veu, no al que passa per dins | L'estat intern pot canviar sense que la interfície ho reflecteixi | await screen.findByRole('heading', …) |
| Un client i un magatzem nous per prova | La memòria cau compartida fa que el resultat depengui de l'ordre | renderitzar els crea sempre |
retry: false a les consultes |
Els reintents exhaureixen el temps d'espera a les proves d'error | crearClientDeProva |
resetHandlers() després de cada prova |
Un server.use amb un 500 contaminaria les següents |
afterEach de configuracio.js |
onUnhandledRequest: 'error' |
Una petició imprevista surt a la xarxa real i falla de manera incomprensible | servidor.listen |
| Neteja l'emmagatzematge entre proves | useMagatzemLocal i el tema persisteixen |
localStorage.clear() a l'afterEach |
| Congela la data, no el rellotge sencer | Les regles que depenen d'«ara» caduquen | setSystemTime + shouldAdvanceTime: true |
Una asserció per waitFor, sense efectes dins |
La funció es reintenta moltes vegades | Espera una vegada, afirma en síncron després |
| No comparteixis variables mutables entre proves | L'ordre d'execució passa a importar | Escenari nou a beforeEach |
I el diagnòstic de sempre quan aparegui una prova intermitent: executa-la en solitari i després dins de la suite. Si passa sola i falla acompanyada, el problema és d'aïllament (memòria cau, gestors, emmagatzematge). Si falla en ambdós casos de manera intermitent, el problema és d'espera.
Errors Comuns i Consells
- Oblidar l'
awaitdavant defindBy. L'asserció rep una promesa, que sempre és truthy, així queexpect(promesa).toBeInTheDocument()dona un error críptic o passa per accident. Regla:findBysempre ambawait. - Usar
getByjust després derenderesperant dades del servidor. Falla sempre, perquè el primer render és el de càrrega. És l'error de l'apartat 1, i el bolcat del DOM el delata: hi apareix l'esquelet. - Ficar interaccions dins de
waitFor. S'executen tantes vegades com reintents faci la funció. El clic fora, l'asserció dins. - Silenciar l'avís d'
actembolicant-ho tot a mà. L'avís és un símptoma; la causa sol ser una espera que falta. Afegeix l'espera i l'avís desapareix sol. - Compartir el
QueryCliententre proves. Produeix la fallada més desconcertant del mòdul: proves que passen soles i fallen juntes, o al revés.renderitzarho evita, però no ho esquivis creant el client en l'àmbit deldescribe. - Oblidar
resetHandlers(). Unserver.useamb un 500 sobreviu a la prova i trenca altres que no hi tenen res a veure. És aconfiguracio.js; no el treguis. - Simular
fetchen lloc d'usar MSW. Obliga a reimplementarResponse,ok,json()i les capçaleres, i deixa sense provar la construcció de la URL, que és justament on són les errates. - Provar només el camí feliç. L'estat d'error i la llista buida són els que produeixen pantalles en blanc en producció, i són els més fàcils de provar amb
server.use. Si només afegiràs una prova a una pàgina, que sigui la de l'error. - Consell: quan una prova asíncrona falli, mira el bolcat del DOM que acompanya l'error. Sol dir exactament en quin estat es va quedar: esquelet (no va arribar la dada), alerta (va arribar un error), buit (no es va muntar res).
- Consell: usa gestors amb estat per a les mutacions. Un array que el
POSTomple i elGETllegeix converteix MSW en un servidor de veritat en miniatura, i permet provar el cicle complet d'invalidació. - Consell: exporta les dades de
gestors.jsi afirma sobre elles.toHaveLength(BICICLETES.length)no es trenca el dia que algú afegeixi una sisena bicicleta a l'escenari.
Exercicis
Exercici 1. Escriu la suite de PaginaDetallEstacio per a la ruta /estaciones/est-01. Cobreix els quatre escenaris: l'estat de càrrega, l'èxit (nom «Plaça Major», barri «Centre», 20 places i les dues bicicletes d'aquesta estació), l'error 500 de l'endpoint d'estacions, i el 404 quan l'identificador no existeix. Indica quina eina d'espera uses en cada cas i per què.
Exercici 2. Aquesta prova passa a vegades i falla d'altres. Identifica quatre problemes i reescriu-la.
test('crea una reserva', async () => {
const client = new QueryClient();
render(
<QueryClientProvider client={client}>
<PaginaNovaReserva />
</QueryClientProvider>
);
await new Promise((r) => setTimeout(r, 300));
fireEvent.change(screen.getByLabelText('Bicicleta'), { target: { value: 'bici-001' } });
fireEvent.click(screen.getByText('Crear reserva'));
await waitFor(() => {
expect(screen.getByText('Reserva creada')).toBeInTheDocument();
expect(screen.getByLabelText('Bicicleta')).toHaveValue('');
});
});Exercici 3. Escriu les proves de useEstatConnexio, el hook que retorna true o false segons navigator.onLine i que se subscriu als esdeveniments online i offline del navegador. Cobreix: el valor inicial, la reacció a cada esdeveniment, i que els escoltadors es retiren en desmuntar. Pista: els esdeveniments del navegador es disparen amb window.dispatchEvent(new Event('offline')) i navigator.onLine es reemplaça amb vi.spyOn(navigator, 'onLine', 'get').
Solucions
Solució 1.
// src/pagines/PaginaDetallEstacio.test.jsx
import { renderitzar, screen, waitForElementToBeRemoved } from '../proves/utilitats.jsx';
import { servidor } from '../proves/servidor.js';
import { http, HttpResponse, delay } from 'msw';
import PaginaDetallEstacio from './PaginaDetallEstacio.jsx';
const API = 'http://localhost:3001';
const RUTES = [{ path: '/estaciones/:estacionId', element: <PaginaDetallEstacio /> }];
describe('PaginaDetallEstacio', () => {
test('mostra l\'esquelet mentre carrega', () => {
servidor.use(
http.get(`${API}/estaciones/:estacionId`, async () => {
await delay(100);
return HttpResponse.json({ id: 'est-01', nom: 'Plaça Major', barri: 'Centre', places: 20 });
})
);
renderitzar(null, { ruta: '/estaciones/est-01', rutes: RUTES });
// Asserció SÍNCRONA: és l'estat inicial, no hi ha res a esperar
expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();
});
test('mostra les dades de l\'estació i la seva flota', async () => {
renderitzar(null, { ruta: '/estaciones/est-01', rutes: RUTES });
// waitForElementToBeRemoved: es comprova la TRANSICIÓ càrrega → contingut
await waitForElementToBeRemoved(() => screen.queryByTestId('esqueleto-pagina'));
expect(screen.getByRole('heading', { name: 'Plaça Major' })).toBeInTheDocument();
expect(screen.getByText('Centre')).toBeInTheDocument();
expect(screen.getByText(/20 places/)).toBeInTheDocument();
// est-01 té bici-001 i bici-002 a l'escenari de gestors.js
expect(screen.getAllByRole('article')).toHaveLength(2);
expect(screen.getByRole('heading', { name: 'Elèctrica Pro' })).toBeInTheDocument();
});
test('mostra un missatge d\'error si l\'API falla', async () => {
servidor.use(
http.get(`${API}/estaciones/:estacionId`, () => new HttpResponse(null, { status: 500 }))
);
renderitzar(null, { ruta: '/estaciones/est-01', rutes: RUTES });
// findByRole: s'espera que APAREGUI l'alerta
expect(await screen.findByRole('alert')).toHaveTextContent(/no s'ha pogut carregar l'estació/i);
expect(screen.queryByRole('article')).not.toBeInTheDocument();
});
test('mostra «estació no trobada» davant d\'un 404', async () => {
renderitzar(null, { ruta: '/estaciones/est-99', rutes: RUTES });
// El gestor de base ja retorna 404 per a un id inexistent: no cal server.use
expect(await screen.findByText(/Aquesta estació no existeix/)).toBeInTheDocument();
});
});Les eines d'espera i el seu motiu:
| Escenari | Eina | Per què |
|---|---|---|
| Càrrega | Cap (síncrona) | És l'estat immediatament posterior al render |
| Èxit | waitForElementToBeRemoved |
Comprova la transició completa: l'esquelet se'n va |
| Error | findByRole('alert') |
S'espera que aparegui un element concret |
| 404 | findByText |
Igual, i sense server.use perquè el gestor de base ja ho cobreix |
Solució 2. Els quatre problemes:
new QueryClient()amb les opcions per defecte. Reintenta les consultes fallides, així que qualsevol prova d'error exhauriria el temps d'espera. I en crear-se aquí, senseretry: false, la mutació també reintenta.await new Promise(r => setTimeout(r, 300)). Espera fixa: lenta sempre, insuficient a vegades, i no diu què espera. És la causa directa de la intermitència.fireEventen lloc deuserEvent.fireEvent.changeassigna el valor sense disparar la seqüència real, ifireEvent.clickpremeria el botó encara que estigués deshabilitat durant l'enviament. A més, el formulari no s'omple sencer, així que la validació el rebutjaria.- Dues assercions dins d'un
waitFor, igetByText('Crear reserva')en lloc d'una consulta per rol. El primer fa la fallada indiagnosticable; el segon trobaria qualsevol text solt que coincideixi, no necessàriament el botó.
Reescrita:
test('crea la reserva i neteja el formulari', async () => {
const usuari = userEvent.setup();
renderitzar(<PaginaNovaReserva />, { estatInicial: SESSIO_ANA });
// S'espera A LES DADES, no a un temps: les opcions arriben de l'API
await screen.findByRole('option', { name: /Urbana Clàssica/ });
await usuari.selectOptions(screen.getByLabelText('Bicicleta'), 'bici-001');
await usuari.type(screen.getByLabelText('Inici de la reserva'), '2026-05-04T10:00');
await usuari.clear(screen.getByLabelText('Durada (hores)'));
await usuari.type(screen.getByLabelText('Durada (hores)'), '2');
await usuari.click(screen.getByRole('checkbox', { name: /Accepto les condicions/ }));
await usuari.click(screen.getByRole('button', { name: 'Crear reserva' }));
// Una sola espera; la resta en síncron
expect(await screen.findByRole('status')).toHaveTextContent(/Reserva creada/);
expect(screen.getByLabelText('Bicicleta')).toHaveValue('');
});renderitzar aporta el QueryClient amb retry: false, el magatzem amb la sessió d'Ana i l'enrutador, resolent el primer problema sense escriure res.
Solució 3.
// src/hooks/useEstatConnexio.test.js
import { renderHook, act } from '@testing-library/react';
import { describe, test, expect, vi, afterEach } from 'vitest';
import { useEstatConnexio } from './useEstatConnexio.js';
describe('useEstatConnexio', () => {
afterEach(() => vi.restoreAllMocks());
test('retorna true quan el navegador està connectat', () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
const { result } = renderHook(() => useEstatConnexio());
expect(result.current).toBe(true);
});
test('retorna false quan el navegador arrenca sense connexió', () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
const { result } = renderHook(() => useEstatConnexio());
expect(result.current).toBe(false);
});
test('passa a false en rebre l\'esdeveniment offline', () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
const { result } = renderHook(() => useEstatConnexio());
// act: l'esdeveniment provoca un setState fora de qualsevol interacció
act(() => {
window.dispatchEvent(new Event('offline'));
});
expect(result.current).toBe(false);
});
test('torna a true en rebre l\'esdeveniment online', () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
const { result } = renderHook(() => useEstatConnexio());
act(() => {
window.dispatchEvent(new Event('online'));
});
expect(result.current).toBe(true);
});
test('retira els escoltadors en desmuntar', () => {
const espiaTreure = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useEstatConnexio());
unmount();
expect(espiaTreure).toHaveBeenCalledWith('online', expect.any(Function));
expect(espiaTreure).toHaveBeenCalledWith('offline', expect.any(Function));
});
});Notes sobre les decisions:
vi.spyOn(navigator, 'onLine', 'get')amb el tercer argument'get'intercepta el captador de la propietat, que és el que permet controlar-la:navigator.onLineés de només lectura i una assignació directa no funciona.actal voltant deldispatchEventés necessari perquè l'escoltador crida un actualitzador d'estat fora de qualsevol interacció deuserEvent. És el mateix cas de l'apartat 11.- L'última prova és l'excepció que confirma la regla. Espiar
removeEventListenerés afirmar sobre la implementació, i en general s'evita. Es justifica aquí perquè la fuga d'un escoltador no té cap manifestació observable des de fora del component, i és una fallada real que s'acumula en muntar i desmuntar la pantalla moltes vegades. Quan no hi ha comportament observable a verificar i el risc és real, és acceptable baixar un esglaó; però convé saber que s'està baixant.
Conclusió
Aquesta lliçó ha cobert el terreny on es trenca la majoria de les proves de front-end, i ha deixat muntada tota la infraestructura de xarxa simulada del projecte.
L'essencial. Una prova ingènua sobre codi asíncron falla sempre, perquè l'asserció s'executa en el primer render, quan el component encara pinta l'esquelet. La solució mai no és un setTimeout manual —lent, inestable i mut sobre el que espera— sinó esperar al resultat amb les tres eines de Testing Library: findBy* per defecte, waitFor quan el que canvia no és la presència d'un element, i waitForElementToBeRemoved per verificar que l'indicador de càrrega se'n va. Amb les dues regles de waitFor: sense efectes secundaris dins, i una sola asserció per crida. El patró que es repeteix en tota la lliçó és una espera al principi, assercions síncrones després.
L'avís act(...) ja no és un misteri: significa que hi va haver un canvi d'estat fora del control de la prova, gairebé sempre perquè faltava una espera. render, userEvent, findBy i waitFor ja embolcallen la seva feina en act, així que embolicar-ne més a mà emmascara la causa en lloc d'arreglar-la. L'únic ús legítim d'act explícit és cridar directament l'actualitzador d'un hook a renderHook, o fer vèncer un temporitzador fals.
Per a la xarxa, la decisió és interceptar a nivell de protocol i no de mòdul: vi.mock de la capa d'API deixa sense provar la URL, la serialització i els codis d'estat, i segueix en verd quan l'API real canvia. MSW v2 fa que fetch es cridi de veritat i rebi una Response de veritat. Queden fixats src/proves/gestors.js —amb http.get/http.post/http.patch per a /bicicletas, /bicicletas/:id, /estaciones, /estaciones/:id i /reservas, replicant els filtres per cadena de consulta de json-server i exportant BICICLETES, ESTACIONS i RESERVES— i src/proves/servidor.js amb setupServer. L'enganxament a configuracio.js és un trio inseparable: listen({ onUnhandledRequest: 'error' }) a beforeAll, resetHandlers() a afterEach i close() a afterAll; sense el segon, un 500 forçat contamina les proves següents de manera aparentment aleatòria.
Amb servidor.use es provoquen a voluntat els escenaris que ningú comprova a mà: un error 500 amb el seu missatge i el seu botó de reintent, un gestor { once: true } per verificar que el reintent es recupera, una llista buida amb el seu missatge propi, i una resposta lenta amb delay —controlant la latència al servidor simulat i esperant sempre el resultat, mai el rellotge—. Per a TanStack Query, les dues regles són un QueryClient nou per prova (una memòria cau compartida fa que el resultat depengui de l'ordre d'execució) i retry: false (amb els reintents per defecte, tota prova d'error exhaureix el temps d'espera). I una mutació es prova sencera: quin cos s'envia —capturant-lo al gestor—, què veu l'usuari mentre s'envia i en acabar, què passa si falla, i sobretot que la llista es refresca després de la invalidació, comptant peticions sobre un gestor amb estat que actua com un servidor en miniatura.
Els hooks personalitzats es proven amb renderHook, que exposa el valor a result.current i admet rerender amb noves props i un wrapper amb proveïdors. useAlternar ha mostrat l'ús d'act i una prova que sí que val la pena sobre identitats: que les accions són estables entre renders, perquè d'això depèn que un efecte no es reexecuti en bucle. useDebounce ha combinat temporitzadors falsos amb React —act(() => vi.advanceTimersByTime(n)) sempre junts, useRealTimers a l'afterEach, i shouldAdvanceTime: true o advanceTimers perquè userEvent no es bloquegi—, inclosa la prova que el temporitzador pendent es cancel·la en desmuntar. useMagatzemLocal ha justificat els seus try/catch provocant un JSON corrupte i una quota exhaurida amb vi.spyOn(Storage.prototype, 'setItem'). I LimitError ha ensenyat a silenciar el soroll esperat de la consola només en les proves que provoquen l'error, mai de manera global, amb restoreAllMocks obligatori.
Tot això es resumeix a la taula anti-inestabilitat: mai un temps fix, esperar el que es veu, client i magatzem nous per prova, retry: false, resetHandlers, onUnhandledRequest: 'error', emmagatzematge net, data congelada amb el rellotge avançant, una asserció per waitFor i cap variable mutable compartida. I el diagnòstic d'una prova intermitent: executa-la sola i després acompanyada; si passa sola i falla a la suite, el problema és d'aïllament; si falla sempre a estones, és d'espera.
Tot i amb això, hi ha coses que jsdom no pot veure. Un botó «Reservar» tapat per un banner de cookies amb position: fixed continua sent clicable en aquestes proves. Una redirecció després de l'accés que porta a la ruta equivocada passaria desapercebuda si cada pàgina es prova per separat. Una sessió que no persisteix en recarregar no es manifesta en un enrutador en memòria. Per a això cal un navegador de veritat, l'aplicació sencera arrencada i json-server responent. La propera lliçó munta Cypress, decideix quins tres fluxos de CicloUrbano mereixen aquest cost —identificar-se, reservar i cancel·lar—, explica per què cy.get(...) no retorna un element i mai no s'usa await, controla la xarxa amb cy.intercept, aïlla les proves amb cy.session i una base de dades reiniciable, i tanca amb un flux de GitHub Actions que llança tot el mòdul a cada canvi. La propera lliçó és Proves d'Extrem a Extrem amb Cypress.
Curs de React
Mòdul 1: Introducció a React
- Què és React?
- Configuració de l'Entorn de Desenvolupament
- Hola Món amb React
- JSX: Extensió de Sintaxi de JavaScript
- Com Renderitza React: Virtual DOM i Reconciliació
Mòdul 2: Components de React
- Entendre els Components
- Components Funcionals vs de Classe
- Props: Passar Dades als Components
- State: Gestió de l'Estat del Component
- Estils en els Components: CSS, Mòduls i Utilitats
Mòdul 3: Treballar amb Esdeveniments
- Gestió d'Esdeveniments a React
- Renderitzat Condicional
- Llistes i Claus
- Formularis i Components Controlats
- Validació de Formularis i Components No Controlats
- Accessibilitat en Components Interactius
Mòdul 4: Conceptes Avançats de Components
- Elevar l'Estat
- Composició vs Herència
- Mètodes del Cicle de Vida de React
- Hooks: Introducció i Ús Bàsic
- Límits d'Error: Capturar Fallades a la Interfície
Mòdul 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef i Accés al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalitzats
Mòdul 6: Enrutament a React
- Introducció a React Router
- Configuració de React Router
- Rutes Imbricades
- Navegació Programàtica
- Rutes Protegides i Control d'Accés
Mòdul 7: Gestió de l'Estat
- Introducció a la Gestió de l'Estat
- API de Context
- Redux: Introducció i Configuració
- Redux: Accions i Reductors
- Redux: Connectar-lo a React
- Estat del Servidor: Peticions, Memòria Cau i Sincronització
Mòdul 8: Optimització del Rendiment
- Tècniques d'Optimització del Rendiment a React
- Memoïtzació amb React.memo
- Hooks useMemo i useCallback
- Divisió de Codi i Càrrega Mandrosa
- Mesurar el Rendiment amb React DevTools Profiler
Mòdul 9: Proves a React
- Introducció a les Proves
- Proves Unitàries amb Jest
- Proves de Components amb React Testing Library
- Proves de Codi Asíncron i Simulació d'APIs
- Proves d'Extrem a Extrem amb Cypress
Mòdul 10: Temes Avançats
- Renderitzat al Servidor (SSR) amb Next.js
- Generació de Llocs Estàtics (SSG) amb Next.js
- Suspense i React Server Components
- TypeScript amb React
- React Native: Creació d'Aplicacions Mòbils
