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

  1. Per què l'asincronia trenca una prova ingènua
  2. Les eines d'espera: findBy, waitFor i waitForElementToBeRemoved
  3. L'avís act(...), explicat de debò
  4. Simular la xarxa: per què a nivell de protocol i no de mòdul
  5. MSW v2: instal·lació i gestors de CicloUrbano
  6. servidor.js i l'enganxament a configuracio.js
  7. Provar components amb TanStack Query
  8. Els tres estats de la interfície: carregant, èxit i error
  9. server.use: sobreescriure un gestor en una prova concreta
  10. Provar una mutació completa
  11. renderHook: provar hooks personalitzats
  12. useDebounce: temporitzadors falsos dins de React
  13. useMagatzemLocal i l'emmagatzematge del navegador
  14. Provar un component que navega després d'una operació
  15. Provar LimitError i silenciar el soroll esperat
  16. Com no escriure proves inestables

  1. 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.

  1. Les eines d'espera: findBy, waitFor i waitForElementToBeRemoved

Testing 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.

  1. L'avís 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 desapareix

L'ú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í.

  1. 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 useBicicletes sencer, incloent-hi la construcció de http://localhost:3001/bicicletas?estacioId=est-01, la comprovació de resposta.ok i el resposta.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 useCrearReserva canvia el mètode de POST a PUT, el gestor no respon i la prova falla. Amb vi.mock hauria seguit en verd.
  • Provocar errors és trivial: retornar un 500 és una línia, i reproduir-ho amb l'API real exigiria apagar json-server a mitja prova.

  1. MSW v2: instal·lació i gestors de CicloUrbano

npm install -D msw

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 el 201 del POST. Com més s'assembli la simulació a l'API real, menys possibilitats que la prova passi i l'aplicació falli.

  1. 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"]

  1. 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.use no 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 }
    }
  });
}

  1. 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.

  1. server.use: sobreescriure un gestor en una prova concreta

Els 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ó.

  1. 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 comportament observable: si desapareix, l'usuari veu una llista desactualitzada.

  1. renderHook: provar hooks personalitzats

Un 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');

  1. useDebounce: temporitzadors falsos dins de React

A 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ó:

  1. act(() => vi.advanceTimersByTime(n)), sempre junts. Avançar el rellotge fa vèncer un setTimeout que crida setValorRetardat; sense act, aquesta actualització queda fora del control de React, result.current no es refresca i apareix l'avís de l'apartat 3.
  2. vi.useRealTimers() a l'afterEach, sense excepció. Els temporitzadors falsos que sobreviuen a la prova pengen les següents.
  3. Temporitzadors falsos i userEvent es porten malament per defecte. userEvent espera amb temporitzadors reals entre esdeveniments; si els has congelat, es bloqueja. Les dues solucions ja usades en aquesta lliçó: vi.useFakeTimers({ shouldAdvanceTime: true }), o passar userEvent.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.

  1. useMagatzemLocal i l'emmagatzematge del navegador

jsdom 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.

  1. 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.

  1. Provar LimitError i silenciar el soroll esperat

LimitError é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. Un console.error global 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.

  1. 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'await davant de findBy. L'asserció rep una promesa, que sempre és truthy, així que expect(promesa).toBeInTheDocument() dona un error críptic o passa per accident. Regla: findBy sempre amb await.
  • Usar getBy just després de render esperant 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'act embolicant-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 QueryClient entre proves. Produeix la fallada més desconcertant del mòdul: proves que passen soles i fallen juntes, o al revés. renderitzar ho evita, però no ho esquivis creant el client en l'àmbit del describe.
  • Oblidar resetHandlers(). Un server.use amb un 500 sobreviu a la prova i trenca altres que no hi tenen res a veure. És a configuracio.js; no el treguis.
  • Simular fetch en lloc d'usar MSW. Obliga a reimplementar Response, 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 POST omple i el GET llegeix converteix MSW en un servidor de veritat en miniatura, i permet provar el cicle complet d'invalidació.
  • Consell: exporta les dades de gestors.js i 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:

  1. 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í, sense retry: false, la mutació també reintenta.
  2. 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.
  3. fireEvent en lloc de userEvent. fireEvent.change assigna el valor sense disparar la seqüència real, i fireEvent.click premeria 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.
  4. Dues assercions dins d'un waitFor, i getByText('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.
  • act al voltant del dispatchEvent és necessari perquè l'escoltador crida un actualitzador d'estat fora de qualsevol interacció de userEvent. É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

Mòdul 2: Components de React

Mòdul 3: Treballar amb Esdeveniments

Mòdul 4: Conceptes Avançats de Components

Mòdul 5: Hooks de React

Mòdul 6: Enrutament a React

Mòdul 7: Gestió de l'Estat

Mòdul 8: Optimització del Rendiment

Mòdul 9: Proves a React

Mòdul 10: Temes Avançats

Mòdul 11: Projecte: Construir una Aplicació Completa

© Copyright 2026. Tots els drets reservats