A la lliçó anterior va quedar provat que validarReserva rebutja 25 hores i que el reductor sliceReserves marca una reserva com a cancel·lada sense esborrar-la. Res d'això garanteix que un usuari de CicloUrbano vegi el missatge d'error al costat del camp de durada, ni que el botó «Reservar» estigui deshabilitat per a una bicicleta en manteniment, ni que prémer el filtre «Elèctrica» avisi el pare. Aquesta meitat —la visible, la que l'usuari toca— és la que cobreix aquesta lliçó, i és on el trofeu de proves posa el seu pes més gran.

L'eina és React Testing Library, i el seu valor no és al codi que estalvia sinó en la disciplina que imposa: obliga a cercar els elements com els cercaria una persona —pel seu rol, per la seva etiqueta, pel seu text— en lloc de per classes CSS o estructura del DOM. Això té una conseqüència que cal anunciar d'entrada: tota l'accessibilitat del mòdul 3 era, sense dir-ho, la preparació per a això. Els label htmlFor, els rols, els noms accessibles, l'aria-describedby que associa l'error al seu camp, el role="alert" dels missatges… tot això, que allà es va justificar pels lectors de pantalla, és exactament el mateix mecanisme que Testing Library usa per trobar coses. Un component accessible és un component fàcil de provar, i un d'inaccessible és gairebé impossible de provar bé. No és una coincidència: és el mateix principi dues vegades.

Contingut

  1. La filosofia de Testing Library
  2. jsdom: què et dona i què no
  3. render i screen, i la neteja entre proves
  4. Les tres famílies de consultes: getBy, queryBy, findBy
  5. La prioritat de consultes i per què getByRole va primer
  6. Depurar una consulta que falla
  7. Interacció: userEvent enfront de fireEvent
  8. Les assercions de jest-dom
  9. EtiquetaEstat: un component de presentació
  10. TargetaBicicleta: props, callbacks i estats
  11. SelectorTipus: provar un component controlat
  12. FormulariReserva: el cas complet
  13. La utilitat renderitzar amb els proveïdors reals
  14. Components que depenen de la ruta
  15. Què no cal provar mai

  1. La filosofia de Testing Library

Testing Library es resumeix en una frase del seu autor, Kent C. Dodds, que convé tenir present en escriure cada consulta:

Com més s'assemblin les teves proves a la manera en què s'usa el teu programari, més confiança et poden donar.

D'aquí surt tota la resta. Un usuari de CicloUrbano no sap que existeix una classe .targeta_a3f9x, ni que l'estat intern es diu tipusEscollit, ni que el segon fill del tercer div conté el preu. El que un usuari percep és: hi ha un botó que diu «Reservar», hi ha un camp etiquetat «Durada (hores)», hi ha un text que diu «En manteniment». I el que un usuari fa és: prémer, escriure, tabular.

Per això la biblioteca no ofereix cap manera d'accedir a l'estat, a les props o als mètodes interns d'un component. No és una mancança: és la característica principal. La versió anterior de l'eina estàndard, Enzyme, sí que ho permetia (wrapper.state(), wrapper.instance(), wrapper.find(MeuComponent)), i el resultat van ser milions de proves fràgils que es trencaven amb cada refactorització. Testing Library va eliminar la temptació traient-ne la possibilitat.

El que sí que ofereix:

Testing Library et dona Testing Library no et dona
Renderitzar un component en un DOM real de jsdom Accés a l'estat o a les props
Cercar elements com ho faria una persona o un lector de pantalla Accés a la instància del component
Simular interaccions reals (userEvent) Renderitzat superficial (shallow)
Esperar que alguna cosa aparegui o desaparegui Comptar renders
Assercions sobre el DOM (jest-dom) Cercar per nom de component

Aquest «no hi ha renderitzat superficial» mereix una nota. A Enzyme era habitual renderitzar un component sense els seus fills, substituint-los per marcadors. Testing Library renderitza sempre l'arbre complet, i és el correcte: si TargetaBicicleta pinta EtiquetaEstat a dins, el que importa és que l'usuari vegi «En manteniment», no que existeixi un element anomenat EtiquetaEstat. Això converteix gairebé totes les proves de components en proves d'integració, que és exactament el que vol el trofeu de 09-01.

  1. jsdom: què et dona i què no

jsdom és una implementació dels estàndards del DOM i de l'HTML escrita en JavaScript pur, que s'executa a Node. Quan vite.config.js declara environment: 'jsdom', cada fitxer de proves rep un window, un document, un localStorage, un history i tota l'API del DOM, sense obrir cap navegador.

És ràpid —muntar un component costa desenes de mil·lisegons— però és una simulació, i cal conèixer-ne els límits perquè expliquen força sorpreses:

Funciona a jsdom No funciona a jsdom
Estructura del DOM, atributs, esdeveniments Disseny real: no hi ha motor de maquetació
localStorage, sessionStorage getBoundingClientRect() retorna tot zeros
history.pushState i l'API de navegació window.location.assign i la navegació de veritat
fetch (Node 18+) IntersectionObserver, ResizeObserver, matchMedia (cal simular-los)
crypto.randomUUID Animacions i transicions CSS
Càlcul d'estils declarats en línia Cascada completa de CSS Modules o fulls externs
Esdeveniments de teclat, ratolí i focus Desplaçament (scrollTo no fa res)

Conseqüències pràctiques per a CicloUrbano:

  • toBeVisible() no comprova visibilitat real. Comprova display: none, visibility: hidden, hidden i opacity: 0 declarats en línia o en estils que jsdom hagi processat. Un element tapat per un altre amb position: absolute es considera visible. Aquest tipus de fallada només el detecta una prova d'extrem a extrem (09-05).
  • Les classes de CSS Modules existeixen com a cadenes, però no apliquen estil. Una altra raó, a més de la de l'apartat 15, per no afirmar mai sobre elles.
  • Si un component usa IntersectionObserver —típic en càrrega mandrosa d'imatges— cal proporcionar-lo a configuracio.js o la prova fallarà amb «is not defined».
  • La navegació real no existeix. Per això els components que enruten es proven amb createMemoryRouter (apartat 14), que manté la ruta en memòria.

  1. render i screen, i la neteja entre proves

import { render, screen } from '@testing-library/react';
import EtiquetaEstat from './EtiquetaEstat.jsx';

test('mostra el text de l\'estat', () => {
  render(<EtiquetaEstat estat="disponible" />);
  expect(screen.getByText('Disponible')).toBeInTheDocument();
});

Què fa cada peça:

  • render(element) crea un <div> contenidor, l'afegeix a document.body i hi munta l'arbre de React. Retorna un objecte amb utilitats, de les quals a la pràctica només s'usen tres: rerender (tornar a renderitzar amb altres props), unmount (desmuntar, útil per provar neteges d'efectes) i container (el node arrel, que gairebé mai no cal).
  • screen és un objecte amb totes les consultes ja lligades a document.body. És la forma recomanada: screen.getByRole(...) en lloc de desestructurar const { getByRole } = render(...). La raó és doble: no cal mantenir una llista de consultes desestructurades que creix amb cada prova, i screen troba també el que es renderitza fora del contenidor, com un modal muntat amb un portal —just el cas de Modal i DialegReserva a CicloUrbano.

Sobre la neteja: Testing Library desmunta l'arbre i buida el body després de cada prova de manera automàtica, sempre que l'entorn tingui els ganxos globals (globals: true a Vitest, que és el cas). Sense aquesta neteja, la segona prova trobaria dos botons «Reservar» —el seu i el de la prova anterior— i getByRole fallaria amb «s'han trobat diversos elements». A src/proves/configuracio.js ja es va deixar explícit a 09-01, juntament amb el localStorage.clear().

  1. Les tres famílies de consultes: getBy, queryBy, findBy

Aquesta és la primera decisió de cada línia de prova, i equivocar-se produeix missatges d'error confusos. Són tres famílies, i cadascuna respon a una pregunta diferent:

Família Si troba Si no troba Si troba diversos Asíncrona? Quan s'usa
getBy… Retorna l'element Llança error amb el DOM bolcat Llança error No «Això ha d'estar-hi ara»
queryBy… Retorna l'element Retorna null Llança error No «Això no ha d'estar-hi»
findBy… Retorna una promesa resolta Rebutja després de 1000 ms Rebutja «Això apareixerà» (asíncron)

I cadascuna té la seva variant en plural, que retorna un array i no falla per trobar-ne diversos:

Plural Si no en troba cap
getAllBy… Llança error
queryAllBy… Retorna []
findAllBy… Rebutja després del temps d'espera

Les regles d'ús, amb els errors típics:

// ✅ Existeix: getBy. Si no hi és, l'error inclou el DOM complet i es depura sol
expect(screen.getByRole('button', { name: 'Reservar' })).toBeInTheDocument();

// ✅ No existeix: queryBy. És l'ÚNICA família que pot retornar null sense fallar
expect(screen.queryByRole('button', { name: 'Cancel·lar reserva' })).not.toBeInTheDocument();

// ❌ MALAMENT: getBy llança abans d'arribar a l'asserció; l'error dirà "no s'ha trobat",
//              cosa que és confusa quan el que volies era justament comprovar que no hi és
expect(screen.getByRole('button', { name: 'Cancel·lar reserva' })).not.toBeInTheDocument();

// ✅ Apareixerà després d'una càrrega: findBy (i s'espera amb await)
expect(await screen.findByText('Urbana Clàssica')).toBeInTheDocument();

// ❌ MALAMENT: sense await, l'asserció rep una promesa, que sempre és "truthy"
expect(screen.findByText('Urbana Clàssica')).toBeInTheDocument();

Aquesta lliçó usarà gairebé sempre getBy i queryBy, perquè encara no hi ha asincronia de xarxa: findBy és el protagonista de 09-04.

Un matís sobre getAllBy, que apareix tan bon punt es prova una llista:

test('pinta una targeta per bicicleta', () => {
  render(<LlistaBicicletes bicicletes={BICICLETES} />);

  // Els models són encapçalaments <h3> dins de cada <article>
  expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(5);
});

  1. La prioritat de consultes i per què getByRole va primer

Testing Library defineix un ordre de preferència explícit, i seguir-lo no és purisme: cada esglaó que baixes allunya la prova del que percep l'usuari i l'acosta als detalls de la implementació.

# Consulta Què cerca Quan usar-la
1 getByRole El rol d'accessibilitat, normalment amb { name } Sempre que es pugui. És el que veu un lector de pantalla
2 getByLabelText L'element associat a una <label> Camps de formulari. El cas natural del label htmlFor de 03-06
3 getByPlaceholderText L'atribut placeholder Només si el camp no té etiqueta —cosa que ja és una fallada d'accessibilitat
4 getByText El contingut textual Elements no interactius: paràgrafs, missatges, encapçalaments
5 getByDisplayValue El valor actual d'un camp Comprovar un formulari ja emplenat
6 getByAltText L'alt d'una imatge Imatges
7 getByTitle L'atribut title Poc fiable: no s'anuncia de manera consistent
8 getByTestId data-testid Últim recurs, quan no hi ha res semàntic a què agafar-se

Per què getByRole és la primera opció

Perquè una consulta per rol comprova dues coses alhora:

screen.getByRole('button', { name: 'Reservar' })
  1. Que existeix un element amb rol de botó —un <button>, o alguna cosa amb role="button"—, és a dir, que es pot prémer i enfocar de debò.
  2. Que el seu nom accessible és «Reservar», el text que anunciaria un lector de pantalla.

Si algú substitueix el <button> per un <div onClick>, aquesta consulta falla. I ha de fallar, perquè aquest canvi trenca el teclat i els lectors de pantalla, exactament la fallada que es va corregir a 03-06 amb la targeta polsable. Una prova escrita amb rols és també una prova d'accessibilitat, gratis.

El nom accessible es calcula, en aquest ordre: aria-labelledby, aria-label, el contingut textual de l'element, el <label> associat, title. Per això funciona igual amb aquests tres:

<button>Reservar</button>
<button aria-label="Reservar">🚲</button>
<button aria-labelledby="titol-accio">🚲</button>

Els rols més habituals a CicloUrbano:

Element HTML Rol implícit Exemple del projecte
<button> button «Reservar», «Veure fitxa», BotoTema
<a href> link Enllaços de MollesDePa i Capcalera
<h1><h6> heading (amb level) El model a TargetaBicicleta és heading nivell 3
<input type="text"> textbox El cercador
<input type="checkbox"> checkbox «Accepto les condicions»
<input type="number"> spinbutton «Durada (hores)»
<select> combobox «Bicicleta» del formulari
<option> option Cada bicicleta disponible
<ul> / <li> list / listitem LlistaBicicletes
<form> amb nom accessible form FormulariReserva
<nav> navigation La navegació principal
<article> article Cada TargetaBicicleta
Element amb role="alert" alert Els missatges d'error del formulari
Element amb role="status" status La regió aria-live d'avisos

Fixa't que <input type="number"> té rol spinbutton, no textbox: és dels que més despisten.

Opcions útils de getByRole

screen.getByRole('button', { name: 'Reservar' })              // nom exacte
screen.getByRole('button', { name: /reservar/i })             // expressió regular: robusta davant majúscules
screen.getByRole('heading', { level: 3 })                     // només els h3
screen.getByRole('button', { name: 'Reservar', hidden: true })// inclou els ocults a l'accessibilitat
screen.getAllByRole('listitem')                               // tots els <li>
screen.getByRole('checkbox', { checked: true })               // pel seu estat

La variant amb expressió regular mereix un comentari: { name: /reservar/i } sobreviu a un canvi de majúscules o al fet que el disseny afegeixi una icona al costat. És la manera de no lligar-se al text literal quan el text pot canviar sense que canviï el comportament.

Quan és legítim baixar a data-testid

getByTestId és l'últim esglaó, i usar-lo massa aviat anul·la l'avantatge de la biblioteca: un data-testid no comprova accessibilitat, no comprova semàntica i no es trenca quan hauria de fer-ho. Però hi ha tres casos en què és la resposta correcta:

  1. Elements sense rol ni text estable: un contenidor de disseny, un gràfic, un llenç. A CicloUrbano, l'EsqueletPagina és exactament això: no té text perquè és una silueta grisa.
  2. Textos dinàmics que canvien sovint per motius editorials i que farien fràgil la prova.
  3. Proves d'extrem a extrem, on data-testid és el contracte explícit entre la interfície i les proves. S'argumenta a fons a 09-05.
// Legítim: l'esquelet no té text ni rol
<div className={estils.esquelet} data-testid="esqueleto-pagina" aria-hidden="true" />
expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();

Abans d'escriure un data-testid, fes-te aquesta pregunta: com trobaria aquest element una persona cega? Si no hi ha resposta, el problema no és la prova: és el component.

  1. Depurar una consulta que falla

Quan getByRole no troba res, Testing Library bolca el DOM complet al missatge d'error. Aquest bolcat és l'eina de depuració principal, i cal aprendre a llegir-lo:

TestingLibraryElementError: Unable to find an accessible element with the role "button"
and name "Reservar"

Here are the accessible roles:
  article:
    Name "":
    <article class="targeta_a3f9x" />
  heading:
    Name "Urbana Clàssica":
    <h3 />
  button:
    Name "Veure fitxa":
    <button type="button" />
    Name "Reservar bicicleta":        ← el nom real és un altre!
    <button type="button" />

Aquí hi ha el diagnòstic: el botó existeix i el seu rol és correcte, però el seu nom accessible és «Reservar bicicleta», no «Reservar». La solució és { name: /reservar/i } o el nom complet.

Les tres eines de depuració, per ordre d'utilitat:

import { render, screen, logRoles } from '@testing-library/react';

test('depuració', () => {
  const { container } = render(<TargetaBicicleta bicicleta={BICI} />);

  // 1) Bolcar el DOM complet, amb format i colors
  screen.debug();

  // 2) Bolcar només un tros
  screen.debug(screen.getByRole('article'));

  // 3) Llistar TOTS els rols disponibles i els seus noms accessibles: el més útil
  logRoles(container);
});
Eina Què dona Quan
screen.debug() L'HTML renderitzat, amb format «S'ha arribat a pintar?»
screen.debug(element) Només aquest subarbre Quan el DOM és gran i el bolcat és il·legible
logRoles(container) L'arbre de rols amb els seus noms accessibles «Per què no troba el meu botó?». Resol la majoria dels casos
Testing Playground Interfície web que suggereix la millor consulta per a cada element En escriure la primera prova d'un component nou

El Testing Playground s'usa de dues maneres: com a extensió del navegador sobre l'aplicació en marxa, o des de la prova amb screen.logTestingPlaygroundURL(), que imprimeix un enllaç amb el DOM actual ja carregat. Assenyales un element i et diu la consulta recomanada, ordenada per prioritat. És la manera més ràpida d'interioritzar la taula de l'apartat 5.

Un detall de configuració: per defecte screen.debug() talla el bolcat a 7.000 caràcters. Per a pàgines grans:

// vite.config.js, dins de test
test: { environment: 'jsdom', globals: true, setupFiles: '…' }
// o puntualment a la prova
screen.debug(undefined, 30000);

  1. Interacció: userEvent enfront de fireEvent

Hi ha dues maneres de simular una interacció, i la diferència importa més del que sembla.

fireEvent dispara un esdeveniment del DOM, exactament el que li demanes:

fireEvent.click(boto);        // dispara un únic esdeveniment 'click'
fireEvent.change(camp, { target: { value: 'urbana' } });   // un únic 'change'

userEvent simula la seqüència completa d'esdeveniments que produeix aquesta acció en un navegador real:

await usuari.click(boto);
// dispara, en ordre: pointerover, pointerenter, pointermove, pointerdown,
// mousedown, focus, pointerup, mouseup, click
fireEvent userEvent
Què dispara Un esdeveniment aïllat La seqüència real completa
Focus No el mou El mou, com un clic de veritat
Elements deshabilitats Dispara igualment No fa res, com en un navegador
Escriure text Assigna el valor de cop Tecla a tecla, amb keydown/keypress/input/keyup
Detecta fallades d'accessibilitat No Sí (pointer-events: none, elements ocults)
API Síncrona Asíncrona: cal esperar-la
Quan usar-lo Casos rars que userEvent no cobreix Per defecte, sempre

La fila que decideix és la dels elements deshabilitats. Amb fireEvent.click(botoDeshabilitat) el gestor s'executa i la prova passa, encara que a l'aplicació real aquest clic no faci res: la prova menteix. userEvent reprodueix el comportament del navegador i no crida el gestor, que és el que s'ha de verificar a TargetaBicicleta amb una bicicleta en manteniment.

El mateix amb l'escriptura: fireEvent.change assigna el valor d'una vegada, així que un component amb useDebounce o amb validació per tecla no es comporta com en producció. userEvent.type tecleja de debò.

L'API de userEvent

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

test('interacció completa', async () => {
  // setup() s'ha de cridar ABANS del render, i una vegada per prova
  const usuari = userEvent.setup();
  render(<FormulariReserva bicicletes={BICICLETES} />);

  await usuari.click(screen.getByRole('button', { name: 'Reservar' }));
  await usuari.type(screen.getByLabelText('Durada (hores)'), '3');
  await usuari.clear(screen.getByLabelText('Durada (hores)'));
  await usuari.selectOptions(screen.getByLabelText('Bicicleta'), 'bici-001');
  await usuari.tab();                                   // mou el focus al següent
  await usuari.keyboard('{Escape}');                    // prem Escape
  await usuari.keyboard('urbana{Enter}');               // escriu i prem Retorn
  await usuari.hover(screen.getByRole('article'));
  await usuari.dblClick(screen.getByRole('button', { name: 'Veure fitxa' }));
});
Mètode Què simula
click(el) Un clic complet, amb focus inclòs
dblClick(el) Doble clic
type(el, text) Escriure tecla a tecla en un camp enfocat
clear(el) Seleccionar-ho tot i esborrar
selectOptions(select, valor) Escollir una o diverses opcions
tab() Avançar el focus. tab({ shift: true }) retrocedeix
keyboard('{Enter}') Polsacions de teclat soltes
hover(el) / unhover(el) Entrar i sortir amb el punter
upload(input, fitxer) Pujar un fitxer

Per què tot és asíncron i cal esperar-ho. Des de la versió 14, userEvent retorna promeses per dues raons: internament introdueix pauses entre els esdeveniments de la seqüència per semblar-se a una persona, i embolcalla les actualitzacions en act perquè React processi el canvi d'estat abans de retornar el control. Si t'oblides de l'await, l'asserció s'executa abans que React hagi repintat i veus l'estat anterior. Amb usuari.type l'efecte és encara més visible: es perden lletres.

// ❌ Sense await: l'asserció corre abans que React actualitzi
usuari.click(boto);
expect(alReservar).toHaveBeenCalled();     // falla de manera intermitent

// ✅ Amb await
await usuari.click(boto);
expect(alReservar).toHaveBeenCalled();

Regla mecànica: si la línia comença per usuari., porta await.

  1. Les assercions de jest-dom

@testing-library/jest-dom, registrat a src/proves/configuracio.js des de 09-01, afegeix matchers que parlen de DOM. Sense ells caldria escriure expect(el.disabled).toBe(true); amb ells, expect(el).toBeDisabled(), que a més produeix un missatge d'error molt més clar.

Matcher Comprova Exemple a CicloUrbano
toBeInTheDocument() L'element és al document expect(screen.getByText('Disponible')).toBeInTheDocument()
toBeVisible() Hi és i és visible (amb els límits de l'apartat 2) El panell després d'obrir l'acordió
toBeDisabled() / toBeEnabled() Atribut disabled o aria-disabled El botó «Reservar» d'una bicicleta en manteniment
toHaveValue(v) El valor d'un camp expect(campHores).toHaveValue(2)
toHaveDisplayValue(v) El valor mostrat d'un select L'opció escollida
toBeChecked() Casella o ràdio marcats «Accepto les condicions»
toHaveTextContent(t) Conté aquest text expect(targeta).toHaveTextContent('2,50 €')
toHaveAttribute(a, v) Té l'atribut expect(camp).toHaveAttribute('aria-invalid', 'true')
toHaveAccessibleName(n) El seu nom accessible és aquest expect(boto).toHaveAccessibleName('Reservar')
toHaveAccessibleDescription(d) La seva descripció accessible (via aria-describedby) El missatge d'error associat al camp
toHaveClass(c) Té aquesta classe Evitar amb CSS Modules (apartat 15)
toHaveFocus() Té el focus Després de usuari.tab()
toBeRequired() És obligatori Els camps del formulari
toBeInvalid() / toBeValid() aria-invalid o validació nativa El camp amb error
toBeEmptyDOMElement() No té fills La regió aria-live abans del primer avís

Els dos que més partit donen i menys s'usen són toHaveAccessibleName i toHaveAccessibleDescription, perquè verifiquen directament el que anunciaria un lector de pantalla. La segona és la manera correcta de comprovar l'aria-describedby de FormulariReserva, i s'usarà a l'apartat 12.

  1. EtiquetaEstat: un component de presentació

Comencem pel més senzill. EtiquetaEstat rep un estat i el pinta per tres canals: color (classe), símbol (aria-hidden) i text.

// src/components/EtiquetaEstat.test.jsx
import { render, screen } from '@testing-library/react';
import EtiquetaEstat from './EtiquetaEstat.jsx';

describe('EtiquetaEstat', () => {
  test.each([
    ['disponible',    'Disponible'],
    ['alquilada',     'Llogada'],
    ['mantenimiento', 'En manteniment']
  ])('amb estat "%s" mostra el text "%s"', (estat, text) => {
    render(<EtiquetaEstat estat={estat} />);
    expect(screen.getByText(text)).toBeInTheDocument();
  });

  test('mostra un text de reserva davant d\'un estat desconegut', () => {
    render(<EtiquetaEstat estat="teletransportada" />);
    expect(screen.getByText('Desconegut')).toBeInTheDocument();
  });

  test('usa "disponible" quan no rep la prop', () => {
    render(<EtiquetaEstat />);
    expect(screen.getByText('Disponible')).toBeInTheDocument();
  });

  test('el símbol decoratiu es oculta a les tecnologies d\'assistència', () => {
    render(<EtiquetaEstat estat="mantenimiento" />);

    // El nom accessible de l'etiqueta NO ha d'incloure el símbol:
    // per això porta aria-hidden a 03-06
    expect(screen.getByText('En manteniment')).toBeInTheDocument();
    expect(screen.queryByText('🔧')).not.toBeInTheDocument();
  });
});

Comentaris sobre les decisions:

  • test.each per als tres estats evita tres proves idèntiques i fa que afegir un quart estat sigui afegir una fila.
  • El cas de l'estat desconegut prova el ?? DESCONEGUT del component. És una branca que l'usuari no hauria de veure mai, i precisament per això ningú la comprovaria a mà.
  • L'última prova verifica l'accessibilitat, no l'aparença. queryByText('🔧') retorna null perquè el <span aria-hidden="true"> està exclòs de l'arbre d'accessibilitat i les consultes per text ho respecten. Si algú tragués l'aria-hidden, aquesta prova fallaria i avisaria que un lector de pantalla començaria a llegir «clau anglesa En manteniment».
  • No es comprova cap classe CSS. Que el color sigui verd o vermell no és verificable a jsdom ni és responsabilitat d'aquesta prova.

  1. TargetaBicicleta: props, callbacks i estats

Aquí apareixen les dues coses noves: verificar que s'avisa el pare i verificar un comportament condicional.

// src/components/TargetaBicicleta.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import TargetaBicicleta from './TargetaBicicleta.jsx';

const DISPONIBLE = {
  id: 'bici-001', model: 'Urbana Clàssica', tipus: 'urbana',
  estat: 'disponible', estacioId: 'est-01', preuHora: 2.5
};

const EN_MANTENIMENT = {
  id: 'bici-003', model: 'Càrrega Max', tipus: 'carga',
  estat: 'mantenimiento', estacioId: 'est-02', preuHora: 5.5
};

// Fàbrica de props: els callbacks són simulats i es retornen per poder afirmar-hi
function pintar(bicicleta, extres = {}) {
  const props = {
    bicicleta,
    nomEstacio: 'Plaça Major',
    alSeleccionar: vi.fn(),
    alReservar: vi.fn(),
    ...extres
  };
  render(<TargetaBicicleta {...props} />);
  return props;
}

describe('TargetaBicicleta', () => {
  test('mostra el model, l\'estació, l\'estat i el preu per hora', () => {
    pintar(DISPONIBLE);

    expect(screen.getByRole('heading', { level: 3, name: 'Urbana Clàssica' })).toBeInTheDocument();
    expect(screen.getByText('Plaça Major')).toBeInTheDocument();
    expect(screen.getByText('Disponible')).toBeInTheDocument();
    // El formatador d'Intl produeix el format català: "2,50 €"
    expect(screen.getByText(/2,50\s*€\s*\/\s*hora/)).toBeInTheDocument();
  });

  test('avisa amb l\'identificador en prémer «Veure fitxa»', async () => {
    const usuari = userEvent.setup();
    const { alSeleccionar, alReservar } = pintar(DISPONIBLE);

    await usuari.click(screen.getByRole('button', { name: 'Veure fitxa' }));

    expect(alSeleccionar).toHaveBeenCalledTimes(1);
    expect(alSeleccionar).toHaveBeenCalledWith('bici-001');
    expect(alReservar).not.toHaveBeenCalled();      // no es dispara l'altre per error
  });

  test('avisa amb l\'identificador en reservar una bicicleta disponible', async () => {
    const usuari = userEvent.setup();
    const { alReservar } = pintar(DISPONIBLE);

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

    expect(alReservar).toHaveBeenCalledWith('bici-001');
  });

  describe('quan la bicicleta no està disponible', () => {
    test('deshabilita el botó «Reservar»', () => {
      pintar(EN_MANTENIMENT);
      expect(screen.getByRole('button', { name: 'Reservar' })).toBeDisabled();
    });

    test('prémer «Reservar» no avisa el pare', async () => {
      const usuari = userEvent.setup();
      const { alReservar } = pintar(EN_MANTENIMENT);

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

      expect(alReservar).not.toHaveBeenCalled();
    });

    test('«Veure fitxa» continua funcionant', async () => {
      const usuari = userEvent.setup();
      const { alSeleccionar } = pintar(EN_MANTENIMENT);

      await usuari.click(screen.getByRole('button', { name: 'Veure fitxa' }));

      expect(alSeleccionar).toHaveBeenCalledWith('bici-003');
    });
  });
});

El que cal retenir d'aquest bloc:

  • La fàbrica pintar compleix aquí la mateixa funció que dadesValides a 09-02: cada prova diu només el que la distingeix, i els callbacks simulats queden disponibles per afirmar-hi.
  • La prova de «prémer no avisa» és la que justifica userEvent. Amb fireEvent.click sobre un botó deshabilitat, el gestor s'executaria i alReservar hauria estat cridat: la prova fallaria encara que el codi sigui correcte. És el cas concret de la taula de l'apartat 7.
  • L'asserció negativa expect(alReservar).not.toHaveBeenCalled() a la prova de «Veure fitxa» sembla redundant, però atrapa una fallada real i freqüent: dos gestors intercanviats. Compleix la regla de 09-02 d'acompanyar el positiu amb el negatiu.
  • S'afirma sobre el preu amb una expressió regular, no amb la cadena exacta '2,50 € / hora'. Intl.NumberFormat insereix un espai irrompible abans del símbol de l'euro, i comparar la cadena literal produeix una fallada desconcertant. L'expressió regular amb \s* n'és immune.
  • En cap moment es comprova que el component estigui memoïtzat amb memo. El memo de 08-02 és una optimització, no un comportament: provar-ho seria provar la implementació.

  1. SelectorTipus: provar un component controlat

SelectorTipus es va tornar controlat al mòdul 4: ja no guarda el tipus, el rep per props i avisa dels canvis. Aquesta distinció canvia completament el que cal provar.

// src/components/SelectorTipus.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import SelectorTipus from './SelectorTipus.jsx';

describe('SelectorTipus', () => {
  test('mostra un botó per cada tipus disponible', () => {
    render(<SelectorTipus tipus="todos" alCanviarTipus={vi.fn()} />);

    expect(screen.getByRole('button', { name: 'Totes' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Urbana' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Elèctrica' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'De càrrega' })).toBeInTheDocument();
  });

  test('avisa el pare amb el tipus escollit en prémer un filtre', async () => {
    const usuari = userEvent.setup();
    const alCanviarTipus = vi.fn();
    render(<SelectorTipus tipus="todos" alCanviarTipus={alCanviarTipus} />);

    await usuari.click(screen.getByRole('button', { name: 'Elèctrica' }));

    expect(alCanviarTipus).toHaveBeenCalledTimes(1);
    expect(alCanviarTipus).toHaveBeenCalledWith('electrica');
  });

  test('reflecteix el tipus actiu que rep per props', () => {
    render(<SelectorTipus tipus="carga" alCanviarTipus={vi.fn()} />);

    // L'estat actiu s'expressa amb aria-pressed, no amb una classe CSS
    expect(screen.getByRole('button', { name: 'De càrrega', pressed: true })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Urbana', pressed: false })).toBeInTheDocument();
  });

  test('NO canvia el filtre actiu pel seu compte: és un component controlat', async () => {
    const usuari = userEvent.setup();
    render(<SelectorTipus tipus="todos" alCanviarTipus={vi.fn()} />);

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

    // El pare no ha canviat la prop, així que "Totes" continua sent l'actiu.
    // Això és correcte i és LA característica d'un component controlat.
    expect(screen.getByRole('button', { name: 'Totes', pressed: true })).toBeInTheDocument();
  });

  test('torna a «todos» en prémer Escape', async () => {
    const usuari = userEvent.setup();
    const alCanviarTipus = vi.fn();
    render(<SelectorTipus tipus="electrica" alCanviarTipus={alCanviarTipus} />);

    await usuari.click(screen.getByRole('button', { name: 'Elèctrica' }));
    await usuari.keyboard('{Escape}');

    expect(alCanviarTipus).toHaveBeenLastCalledWith('todos');
  });
});

La quarta prova és la que més ensenya. Podria semblar una fallada —«premo Urbana i no es marca»— però és exactament el contracte d'un component controlat: la font de la veritat és al pare. Documentar-ho amb una prova impedeix que algú «arregli» el component reintroduint estat intern i trencant la sincronització amb Redux i amb la URL.

I fixa't com es comprova el filtre actiu: amb { pressed: true }, que consulta l'atribut aria-pressed. Ni una paraula sobre estils.actiu. Si el disseny canvia el color del botó actiu, la prova continua verda; si el botó deixa d'anunciar el seu estat, falla.

Sobre useTransition: SelectorTipus embolcalla el canvi en una transició per no bloquejar el teclejat (08-03). A jsdom les transicions es resolen de manera síncrona dins de l'act que userEvent ja introdueix, així que no requereix cap tractament especial. És un bon recordatori del principi: l'optimització és invisible per a la prova perquè és invisible per a l'usuari.

  1. FormulariReserva: el cas complet

Aquest és el component que més es beneficia d'una prova d'integració, perquè combina estat, valors derivats, accessibilitat i un callback cap a fora. I perquè, tal com està, cap prova unitària pot garantir que l'error es vegi.

// src/components/FormulariReserva.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import FormulariReserva from './FormulariReserva.jsx';

const BICICLETES = [
  { id: 'bici-001', model: 'Urbana Clàssica', tipus: 'urbana',    estat: 'disponible',    estacioId: 'est-01', preuHora: 2.5 },
  { id: 'bici-003', model: 'Càrrega Max',     tipus: 'carga',     estat: 'mantenimiento', estacioId: 'est-02', preuHora: 5.5 },
  { id: 'bici-005', model: 'Elèctrica Pro',   tipus: 'electrica', estat: 'disponible',    estacioId: 'est-02', preuHora: 4.0 }
];

describe('FormulariReserva', () => {
  beforeEach(() => {
    vi.useFakeTimers({ shouldAdvanceTime: true });
    vi.setSystemTime(new Date('2026-05-04T08:00:00'));
  });

  afterEach(() => vi.useRealTimers());

  test('només ofereix les bicicletes disponibles', () => {
    render(<FormulariReserva bicicletes={BICICLETES} />);

    const selector = screen.getByLabelText('Bicicleta');
    // 2 disponibles + l'opció buida inicial
    expect(screen.getAllByRole('option')).toHaveLength(3);
    expect(selector).toHaveDisplayValue('— Tria una bicicleta —');
    expect(screen.queryByRole('option', { name: /Càrrega Max/ })).not.toBeInTheDocument();
  });

  test('no mostra errors abans de tocar els camps', () => {
    render(<FormulariReserva bicicletes={BICICLETES} />);
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  });
});

El useFakeTimers({ shouldAdvanceTime: true }) mereix una explicació: es congela la data del sistema perquè la validació de «no en el passat» sigui determinista, però es deixa que el rellotge avanci sol, perquè userEvent introdueix esperes internes entre esdeveniments i amb temporitzadors completament aturats es quedaria bloquejat. És una combinació que cal conèixer: data fixa + rellotge que avança.

Enviar buit i veure tots els errors

test('en enviar buit mostra els quatre errors i no crida el pare', async () => {
  const usuari = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  const alCrearReserva = vi.fn();
  render(<FormulariReserva bicicletes={BICICLETES} alCrearReserva={alCrearReserva} />);

  await usuari.click(screen.getByRole('button', { name: 'Crear reserva' }));

  // role="alert" a cada missatge: així el busca l'usuari de lector de pantalla
  const errors = screen.getAllByRole('alert');
  expect(errors).toHaveLength(4);

  expect(screen.getByText(/Tria una bicicleta/)).toBeInTheDocument();
  expect(screen.getByText(/Indica quan comença la reserva/)).toBeInTheDocument();
  expect(screen.getByText(/Has d'acceptar les condicions/)).toBeInTheDocument();

  expect(alCrearReserva).not.toHaveBeenCalled();
});

Consultar els errors amb getAllByRole('alert') en lloc de per text és una decisió deliberada: comprova alhora que els missatges existeixen i que estan marcats com a alertes, que és el que fa que un lector de pantalla els anunciï en aparèixer. Torna a ser accessibilitat i prova alhora.

L'error associat al seu camp mitjançant aria-describedby

Aquesta és la prova més valuosa del component, i la que enllaça directament amb 03-06:

test('associa l\'error de durada al camp mitjançant la seva descripció accessible', async () => {
  const usuari = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  render(<FormulariReserva bicicletes={BICICLETES} />);

  const campHores = screen.getByLabelText('Durada (hores)');

  await usuari.clear(campHores);
  await usuari.type(campHores, '25');
  await usuari.tab();                       // sortir del camp el marca com a "tocat"

  // 1) El camp s'anuncia com a no vàlid
  expect(campHores).toBeInvalid();
  expect(campHores).toHaveAttribute('aria-invalid', 'true');

  // 2) I la seva descripció accessible inclou el missatge: això és el que llegeix
  //    un lector de pantalla en enfocar el camp. Verifica l'aria-describedby complet.
  expect(campHores).toHaveAccessibleDescription(/La reserva màxima és de 24 hores/);
});

toHaveAccessibleDescription resol l'aria-describedby, segueix els identificadors —inclosa la cadena 'hores-ajuda hores-error'— i comprova el text resultant. Amb una sola asserció es verifica que l'error existeix, que és al DOM, que té l'identificador correcte i que aquest identificador està referenciat des del camp correcte. Comprovar-ho amb getByText només hauria verificat el primer, i la fallada real més freqüent —l'error visible però associat al camp equivocat— hauria passat desapercebuda.

El camí feliç complet

test('crea la reserva amb les dades introduïdes i neteja el formulari', async () => {
  const usuari = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  const alCrearReserva = vi.fn();
  render(
    <FormulariReserva bicicletes={BICICLETES} usuariId="usr-01" alCrearReserva={alCrearReserva} />
  );

  await usuari.selectOptions(screen.getByLabelText('Bicicleta'), 'bici-005');

  const campData = screen.getByLabelText('Inici de la reserva');
  await usuari.type(campData, '2026-05-04T10:00');

  const campHores = screen.getByLabelText('Durada (hores)');
  await usuari.clear(campHores);
  await usuari.type(campHores, '3');

  await usuari.click(screen.getByRole('checkbox', { name: /Accepto les condicions/ }));

  // El total derivat apareix quan les dades són vàlides: 4,00 € × 3 h = 12,00 €
  expect(screen.getByText(/12,00\s*€/)).toBeInTheDocument();

  await usuari.click(screen.getByRole('button', { name: 'Crear reserva' }));

  expect(alCrearReserva).toHaveBeenCalledTimes(1);
  expect(alCrearReserva).toHaveBeenCalledWith(
    expect.objectContaining({
      id: expect.stringMatching(/^res-[a-z0-9]{8}$/),
      bicicletaId: 'bici-005',
      usuari: 'usr-01',
      dataInici: '2026-05-04T10:00',
      hores: 3,
      estat: 'activa'
    })
  );

  // Després de crear, el formulari torna al seu estat inicial
  expect(screen.getByLabelText('Bicicleta')).toHaveValue('');
  expect(screen.getByRole('checkbox', { name: /Accepto les condicions/ })).not.toBeChecked();
  expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

Detalls importants:

  • expect.objectContaining amb stringMatching per a l'identificador. L'identificador es genera amb crypto.randomUUID(), així que no es pot comparar amb un valor fix. Es comprova el format, que és el que forma part del contracte. És el comparador asimètric de 09-02 aplicat aquí.
  • L'asserció sobre el total (12,00 €) verifica un valor derivat: preuHora × hores. És la comprovació que el formulari calcula bé sense necessitat d'exposar el càlcul.
  • La comprovació que el formulari es neteja és un comportament que un usuari percep i que es trenca amb facilitat en refactoritzar el gestor d'enviament.
  • getByRole('checkbox', { name: /Accepto les condicions/ }) funciona perquè la casella està dins de la seva <label>, així que el text de l'etiqueta és el seu nom accessible. Sense aquest embolcall —la fallada d'accessibilitat que es va corregir a 03-06— caldria recórrer a un data-testid.

  1. La utilitat renderitzar amb els proveïdors reals

Tots els exemples anteriors proven components autònoms. Tan bon punt un consumeix useTema, useSelector o useQuery, un render nu esclata:

Error: useTema s'ha d'usar dins de <ProveidorTema>
Error: could not find react-redux context value
Error: No QueryClient set, use QueryClientProvider

Hi ha dues sortides. La dolenta: simular els hooks amb vi.mock('react-redux'). La bona: embolcallar el component amb els proveïdors de veritat. I hi ha tres raons sòlides per preferir la segona:

Simular els proveïdors Usar els proveïdors reals
La prova deixa de verificar la integració amb Redux/Query, que és on són les fallades Verifica el cablejat complet
Cal mantenir sincronitzada la simulació amb l'API real No hi ha res a sincronitzar
Un selector mal escrit passa la prova Un selector mal escrit la trenca
El component pot exigir props que la simulació amaga Es prova com s'usa

Per això el projecte defineix el seu propi renderitzar:

// src/proves/utilitats.jsx
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createMemoryRouter, RouterProvider } from 'react-router';

import reductorReserves from '../funcionalitats/reserves/sliceReserves.js';
import reductorCataleg from '../funcionalitats/cataleg/sliceCataleg.js';
import reductorSessio from '../funcionalitats/sessio/sliceSessio.js';
import { ProveidorTema } from '../contextos/ContextTema.jsx';
import { ProveidorAvisos } from '../contextos/ContextAvisos.jsx';

/**
 * Crea un magatzem NOU per prova, amb els reductors reals.
 * `estatInicial` permet col·locar l'aplicació en l'escenari que es vulgui provar.
 */
export function crearMagatzemDeProva(estatInicial = {}) {
  return configureStore({
    reducer: {
      reserves: reductorReserves,
      cataleg: reductorCataleg,
      sessio: reductorSessio
    },
    preloadedState: estatInicial
  });
}

/**
 * Crea un QueryClient NOU per prova, sense reintents ni memòria cau persistent.
 */
export function crearClientDeProva() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,          // sense això, un error triga 3 reintents a manifestar-se
        gcTime: Infinity,      // la memòria cau no es recull durant la prova
        staleTime: 0
      },
      mutations: { retry: false }
    }
  });
}

/**
 * Renderitza un component amb TOTS els proveïdors reals de CicloUrbano.
 *
 * Opcions:
 *  - estatInicial: estat precarregat del magatzem de Redux
 *  - magatzem / client: instàncies pròpies, si la prova necessita inspeccionar-les
 *  - ruta: ruta inicial de l'enrutador en memòria (per defecte '/')
 *  - rutes: definició de rutes pròpia, per provar navegació
 *
 * Retorna el mateix que `render` MÉS `magatzem` i `client`, per poder
 * despatxar accions o inspeccionar l'estat des de la prova.
 */
export function renderitzar(element, opcions = {}) {
  const {
    estatInicial,
    magatzem = crearMagatzemDeProva(estatInicial),
    client = crearClientDeProva(),
    ruta = '/',
    rutes,
    ...opcionsDeRender
  } = opcions;

  const definicioDeRutes = rutes ?? [{ path: '*', element }];
  const enrutador = createMemoryRouter(definicioDeRutes, { initialEntries: [ruta] });

  function Embolcall() {
    return (
      <QueryClientProvider client={client}>
        <Provider store={magatzem}>
          <ProveidorTema>
            <ProveidorAvisos>
              <RouterProvider router={enrutador} />
            </ProveidorAvisos>
          </ProveidorTema>
        </Provider>
      </QueryClientProvider>
    );
  }

  return {
    ...render(<Embolcall />, opcionsDeRender),
    magatzem,
    client,
    enrutador
  };
}

// Es reexporta tot el de Testing Library perquè les proves importin d'un sol lloc
export * from '@testing-library/react';
export { default as userEvent } from '@testing-library/user-event';

Decisions que convé entendre:

  • L'ordre dels proveïdors replica el de main.jsx: QueryClientProvider > Provider > Proveidors > RouterProvider. Si la prova usés un altre ordre, podria passar amb un cablejat que en producció falla.
  • Magatzem i client nous a cada crida. És la prevenció directa contra les proves inestables de 09-01: un QueryClient compartit filtra dades en memòria cau d'una prova a la següent, i un magatzem compartit arrossega reserves creades en proves anteriors.
  • retry: false és obligatori. Amb els reintents per defecte, una prova d'error esperaria tres intents amb retrocés exponencial abans de mostrar el missatge, i exhauriria el temps d'espera. És la causa número u de «la meva prova d'error es penja».
  • preloadedState permet col·locar l'escenari. Provar la pantalla d'un operari és renderitzar(<PaginaTaller />, { estatInicial: { sessio: { usuari: MARC, carregant: false } } }), sense simular res.
  • Es retornen magatzem, client i enrutador per poder afirmar sobre l'estat resultant quan calgui. Amb moderació: afirmar sobre el magatzem és afirmar sobre la implementació, i només es justifica quan l'efecte no és visible en pantalla.
  • La reexportació al final permet que cada fitxer de prova importi tot de '../proves/utilitats.jsx' en lloc de repartir importacions entre tres paquets.

Ús:

import { renderitzar, screen, userEvent } from '../proves/utilitats.jsx';
import PanellReserves from './PanellReserves.jsx';

test('mostra les reserves de l\'usuari identificat', () => {
  renderitzar(<PanellReserves />, {
    estatInicial: {
      sessio: { usuari: { id: 'usr-01', nom: 'Ana Ribera', rol: 'cliente' }, carregant: false },
      reserves: {
        entitats: { 'res-01': { id: 'res-01', bicicletaId: 'bici-002', usuari: 'usr-01', hores: 2, estat: 'activa' } },
        ids: ['res-01'], estatCarrega: 'correcte', error: null, estatEnviament: 'inactiu'
      }
    }
  });

  expect(screen.getByRole('heading', { name: /Les teves reserves/ })).toBeInTheDocument();
  expect(screen.getAllByRole('listitem')).toHaveLength(1);
});
flowchart TB
    P["renderitzar(element, opcions)"] --> Q["QueryClientProvider · client nou, retry: false"]
    Q --> R["Provider · magatzem nou amb preloadedState"]
    R --> S["ProveidorTema"]
    S --> T["ProveidorAvisos"]
    T --> U["RouterProvider · createMemoryRouter(initialEntries)"]
    U --> V["El component sota prova"]

  1. Components que depenen de la ruta

Tot component que usi useParams, useNavigate, useSearchParams, <Link> o <Outlet> necessita un enrutador. createMemoryRouter manté l'historial en memòria, sense tocar la URL del navegador —que a jsdom no existeix de debò.

Un component que llegeix un paràmetre de ruta

import { renderitzar, screen } from '../proves/utilitats.jsx';
import PaginaFitxaBicicleta from './PaginaFitxaBicicleta.jsx';

test('mostra la bicicleta indicada a la URL', () => {
  renderitzar(null, {
    ruta: '/bicicletas/bici-002',
    rutes: [{ path: '/bicicletas/:bicicletaId', element: <PaginaFitxaBicicleta /> }]
  });

  expect(screen.getByRole('heading', { name: 'Elèctrica Pro' })).toBeInTheDocument();
});

Un component que llegeix la cadena de consulta

El filtre ?tipo= de PaginaCataleg viu a la URL des del mòdul 6:

test('aplica el filtre de tipus que arriba a la URL', () => {
  renderitzar(<PaginaCataleg />, { ruta: '/?tipo=electrica' });

  expect(screen.getByRole('button', { name: 'Elèctrica', pressed: true })).toBeInTheDocument();
});

Un component que navega

Quan l'acció produeix una navegació, el que es comprova és la destinació, no que s'hagi cridat useNavigate:

test('«Veure fitxa» porta a la fitxa de la bicicleta', async () => {
  const usuari = userEvent.setup();

  const { enrutador } = renderitzar(null, {
    ruta: '/',
    rutes: [
      { path: '/', element: <PaginaCataleg /> },
      { path: '/bicicletas/:bicicletaId', element: <h1>Fitxa de bicicleta</h1> }
    ]
  });

  await usuari.click(screen.getAllByRole('button', { name: 'Veure fitxa' })[0]);

  // Dues comprovacions equivalents; la primera és la que veu l'usuari
  expect(screen.getByRole('heading', { name: 'Fitxa de bicicleta' })).toBeInTheDocument();
  expect(enrutador.state.location.pathname).toBe('/bicicletas/bici-001');
});

La ruta de destinació se substitueix per un component mínim (<h1>Fitxa de bicicleta</h1>) a propòsit: la prova verifica la navegació, i muntar la pàgina real portaria les seves consultes, les seves dependències i les seves fallades, convertint una fallada de navegació en una fallada de càrrega de dades i arruïnant el diagnòstic.

Comparat amb l'alternativa que es veu sovint —simular useNavigate amb vi.mock('react-router') i comprovar que s'ha cridat amb '/bicicletas/bici-001'—, aquesta manera és millor pel de sempre: la simulació comprova que s'ha demanat navegar; l'enrutador en memòria comprova que s'ha navegat, inclosa la construcció correcta de la URL i l'existència d'una ruta que l'atengui.

  1. Què no cal provar mai

Recapitulació operativa del principi de 09-01, ara amb nom i cognoms:

❌ No provis Per què ✅ Prova en el seu lloc
L'estat intern (tipusEscollit, tocats) És implementació; RTL ni tan sols ho permet El que l'estat produeix en pantalla
Noms de classes CSS (estils.actiu) Són hashes generats per CSS Modules; canvien sols i no apliquen estil a jsdom L'atribut semàntic: aria-pressed, aria-invalid, disabled
El nombre de renders És rendiment, no comportament Mesura amb el Profiler (08-05)
Que s'hagi cridat useSelector o useNavigate Detall de la biblioteca El resultat: el pintat, la ruta assolida
Que un component estigui embolcallat en memo Optimització invisible a l'usuari Res: no és comportament
L'estructura exacta del DOM (container.querySelector('div > p')) Es trenca amb qualsevol canvi de maquetació Consultes per rol i per text
Funcions no exportades Si mereixen prova, exporta-les El seu efecte a través de l'API pública
Que React Router redirigeixi, que Query faci memòria cau Codi de tercers, ja provat Com el teu codi els usa

I el senyal d'alarma més fiable: si has de llegir el codi del component per saber què consultar a la prova, la prova està acoblada. Una bona prova de component s'escriu mirant la pantalla, no el fitxer .jsx.

Errors Comuns i Consells

  • Usar getBy per comprovar que alguna cosa no existeix. getBy llança en no trobar, així que la prova falla amb un missatge enganyós abans d'arribar al .not. Per a absències, sempre queryBy.
  • Oblidar l'await a userEvent. Produeix fallades intermitents, lletres perdudes en escriure i avisos d'act. Regla: tota línia que comenci per usuari. porta await.
  • Cridar userEvent.setup() després de render. Ha d'anar abans, perquè setup instal·la la configuració dels esdeveniments sobre el document. I una vegada per prova, mai a l'àmbit del describe.
  • Usar fireEvent per costum. Deixa passar clics sobre botons deshabilitats i no mou el focus, així que valida comportaments que en producció no passen. Només per al que userEvent no cobreix.
  • Comparar textos formatats amb cadenes literals. Intl.NumberFormat usa espai irrompible abans del símbol de l'euro i coma decimal en català. Usa expressions regulars amb \s* o toHaveTextContent.
  • Afirmar sobre classes de CSS Modules. El nom real és targeta_a3f9x, canvia a cada compilació i no representa res visible a jsdom. Si l'estat importa, expressa'l amb un atribut ARIA i comprova'l per rol.
  • Començar per getByTestId. És còmode i desactiva la meitat del valor de la biblioteca. Baixa l'escala de prioritats només quan els esglaons superiors no existeixin, i pregunta't abans si el problema és que el component no és accessible.
  • Embolcallar tot en act a mà. render i userEvent ja ho fan. Si apareix l'avís d'act, la causa sol ser una actualització asíncrona sense esperar, i s'explica a fons a 09-04.
  • Consell: escriu la primera asserció abans que la interacció. Comprovar l'estat inicial —«no hi ha errors», «el botó està deshabilitat»— documenta el punt de partida i fa evident què canvia la interacció.
  • Consell: quan una consulta falli, comença per logRoles(container). Resol la majoria dels casos en deu segons, i de passada t'ensenya quins rols té realment la teva maquetació, que sovint no són els que et pensaves.
  • Consell: si provar un component exigeix deu proveïdors i quinze props, el component fa massa. La dificultat de la prova és un indicador de disseny, igual que a 09-02.

Exercicis

Exercici 1. Escriu la suite de CercadorBicicletes, que rep la prop alCercar i conté un camp de text etiquetat «Cercar bicicletes». Cobreix: que el camp apareix amb la seva etiqueta, que comença buit, que en escriure «urbana» el camp mostra aquest valor, i que en prémer el botó «Netejar» el camp es buida i s'avisa amb la cadena buida. No provis el retard del useDebounce: explica en un comentari per què aquesta part correspon a 09-04 i què caldria per provar-la aquí.

Exercici 2. Aquesta prova de TargetaEstacio està escrita amb l'estil equivocat. Identifica cinc problemes, reescriu-la seguint la prioritat de consultes i explica quina fallada real detectaria la teva versió que l'original deixa passar.

test('TargetaEstacio', () => {
  const { container } = render(
    <TargetaEstacio estacio={{ id: 'est-01', nom: 'Plaça Major', barri: 'Centre', places: 20 }} />
  );
  expect(container.querySelector('.targeta-estacio')).toBeTruthy();
  expect(container.querySelectorAll('p')[0].textContent).toBe('Centre');
  expect(container.querySelectorAll('p')[1].textContent).toBe('20 places');
  fireEvent.click(container.querySelector('button'));
  expect(container.querySelector('.detall')).toBeTruthy();
});

Exercici 3. RequereixRol protegeix PaginaTaller: si l'usuari de la sessió no té rol operario, redirigeix a /sin-permisos. Escriu dues proves fent servir renderitzar —una per a Ana Ribera (cliente) i una altra per a Marc Solé (operario)— i explica per què usar els proveïdors reals és superior a simular useSelector en aquest cas concret.

Solucions

Solució 1.

// src/components/CercadorBicicletes.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect, vi } from 'vitest';
import CercadorBicicletes from './CercadorBicicletes.jsx';

describe('CercadorBicicletes', () => {
  test('mostra un camp de cerca etiquetat i buit', () => {
    render(<CercadorBicicletes alCercar={vi.fn()} />);

    const camp = screen.getByRole('textbox', { name: 'Cercar bicicletes' });
    expect(camp).toBeInTheDocument();
    expect(camp).toHaveValue('');
  });

  test('reflecteix el que l\'usuari escriu', async () => {
    const usuari = userEvent.setup();
    render(<CercadorBicicletes alCercar={vi.fn()} />);

    const camp = screen.getByRole('textbox', { name: 'Cercar bicicletes' });
    await usuari.type(camp, 'urbana');

    expect(camp).toHaveValue('urbana');
  });

  test('«Netejar» buida el camp i avisa amb la cadena buida', async () => {
    const usuari = userEvent.setup();
    const alCercar = vi.fn();
    render(<CercadorBicicletes alCercar={alCercar} />);

    const camp = screen.getByRole('textbox', { name: 'Cercar bicicletes' });
    await usuari.type(camp, 'urbana');
    await usuari.click(screen.getByRole('button', { name: 'Netejar' }));

    expect(camp).toHaveValue('');
    expect(alCercar).toHaveBeenLastCalledWith('');
  });

  // NO es prova aquí que `alCercar` es cridi 400 ms després de deixar d'escriure.
  // Aquest comportament depèn d'un temporitzador dins d'un efecte de React, així que
  // exigeix combinar `vi.useFakeTimers()` amb `advanceTimersByTime` DINS d'un `act`,
  // i coordinar-ho amb les esperes internes de userEvent. És matèria de 09-04, on es
  // prova `useDebounce` amb `renderHook`. El mecanisme pur ja va quedar provat a 09-02
  // amb la funció `retardar`, sense React pel mig.
});

Solució 2. Els cinc problemes:

  1. El nom no descriu cap comportament. test('TargetaEstacio') hauria de ser un describe, amb proves que diguin què es verifica.
  2. container.querySelector('.targeta-estacio') afirma sobre una classe CSS. Amb CSS Modules aquest nom ni tan sols existeix tal qual, i encara que existís no és una cosa que l'usuari percebi.
  3. querySelectorAll('p')[0] i [1] lliguen la prova a l'ordre dels paràgrafs a la maquetació. Intercanviar barri i places per motius de disseny trencaria la prova sense trencar res real.
  4. fireEvent.click sobre querySelector('button'). Dues fallades en una línia: se selecciona «el primer botó que hi hagi», sigui quin sigui, i s'usa fireEvent, que dispararia el gestor fins i tot si el botó estigués deshabilitat.
  5. Cinc comprovacions inconnexes en una sola prova. En fallar, l'informe només diu «TargetaEstacio», sense indicar si ha fallat la informació, la interacció o el detall.

Reescrita:

describe('TargetaEstacio', () => {
  const ESTACIO = { id: 'est-01', nom: 'Plaça Major', barri: 'Centre', places: 20 };

  test('mostra el nom, el barri i el nombre de places', () => {
    render(<TargetaEstacio estacio={ESTACIO} />);

    expect(screen.getByRole('heading', { name: 'Plaça Major' })).toBeInTheDocument();
    expect(screen.getByText('Centre')).toBeInTheDocument();
    expect(screen.getByText(/20 places/)).toBeInTheDocument();
  });

  test('el detall està ocult fins que es demana', () => {
    render(<TargetaEstacio estacio={ESTACIO} />);

    expect(screen.getByRole('button', { name: /Veure detall/ })).toHaveAttribute('aria-expanded', 'false');
    expect(screen.queryByRole('region', { name: /Detall de Plaça Major/ })).not.toBeInTheDocument();
  });

  test('mostra el detall en prémer «Veure detall»', async () => {
    const usuari = userEvent.setup();
    render(<TargetaEstacio estacio={ESTACIO} />);

    await usuari.click(screen.getByRole('button', { name: /Veure detall/ }));

    expect(screen.getByRole('region', { name: /Detall de Plaça Major/ })).toBeVisible();
    expect(screen.getByRole('button', { name: /Veure detall/ })).toHaveAttribute('aria-expanded', 'true');
  });
});

Quina fallada detecta la versió nova i l'original no: que el botó deixi d'anunciar el seu estat amb aria-expanded. L'original comprova que apareix un element amb classe .detall; si algú elimina l'aria-expanded o converteix el botó en un <div onClick>, l'original continua verda i l'accessibilitat queda trencada. La nova falla, perquè getByRole('button', …) exigeix un rol de botó real i l'asserció exigeix l'atribut. A més, la nova detectaria que el detall ja era visible des del principi, cosa que l'original no comprova en cap moment.

Solució 3.

import { renderitzar, screen } from '../proves/utilitats.jsx';
import RequereixRol from './RequereixRol.jsx';
import PaginaTaller from '../pagines/PaginaTaller.jsx';

const ANA  = { id: 'usr-01', nom: 'Ana Ribera', email: '[email protected]', rol: 'cliente' };
const MARC = { id: 'usr-02', nom: 'Marc Solé', email: '[email protected]', rol: 'operario' };

const RUTES = [
  {
    path: '/taller',
    element: <RequereixRol rol="operario"><PaginaTaller /></RequereixRol>
  },
  { path: '/sin-permisos', element: <h1>Sense permisos</h1> }
];

describe('RequereixRol sobre PaginaTaller', () => {
  test('deixa passar un operari', () => {
    renderitzar(null, {
      ruta: '/taller',
      rutes: RUTES,
      estatInicial: { sessio: { usuari: MARC, carregant: false, error: null } }
    });

    expect(screen.getByRole('heading', { name: /Taller/ })).toBeInTheDocument();
    expect(screen.queryByRole('heading', { name: 'Sense permisos' })).not.toBeInTheDocument();
  });

  test('redirigeix a /sin-permisos a un client', () => {
    const { enrutador } = renderitzar(null, {
      ruta: '/taller',
      rutes: RUTES,
      estatInicial: { sessio: { usuari: ANA, carregant: false, error: null } }
    });

    expect(screen.getByRole('heading', { name: 'Sense permisos' })).toBeInTheDocument();
    expect(enrutador.state.location.pathname).toBe('/sin-permisos');
  });
});

Per què els proveïdors reals són superiors aquí: RequereixRol no llegeix el rol directament, sinó a través del selector seleccionarEsOperari, que a 07-04 es va decidir derivar en lloc de guardar. Si se simulés useSelector perquè retornés true, la prova passaria per alt exactament la peça que pot fallar: que el selector derivi bé el rol a partir de l'usuari. Amb el magatzem real i preloadedState, s'exercita la cadena completa —usuari a l'estat → selector → decisió del guardià → redirecció de l'enrutador—, que és on viu la fallada de veritat. I hi ha un segon motiu: amb la simulació no es comprovaria a on redirigeix, només que ha decidit redirigir; amb createMemoryRouter es verifica la destinació real, que és el que l'usuari experimenta.

Conclusió

Amb aquesta lliçó, CicloUrbano té per fi proves del que l'usuari veu i toca, i el pes del trofeu de 09-01 queda on ha d'estar.

L'essencial. La filosofia de Testing Library és una restricció deliberada: no dona accés a l'estat ni a les props, perquè com més s'assembli la prova a l'ús real, més confiança aporta. Renderitza sempre l'arbre complet, sense renderitzat superficial, així que gairebé tota prova de component és en realitat una prova d'integració. Corre sobre jsdom, que dona DOM, esdeveniments, localStorage i historial en mil·lisegons, però no dona maquetació real, ni getBoundingClientRect, ni cascada de CSS, ni navegació de veritat: per això toBeVisible no detecta un botó tapat i per això cal Cypress (09-05).

Saps triar entre les tres famílies de consultes: getBy quan alguna cosa ha d'estar-hi, queryBy —i només queryBy— quan alguna cosa no ha d'estar-hi, i findBy quan apareixerà. I coneixes l'escala de prioritats, amb getByRole al primer esglaó perquè comprova dues coses alhora: que l'element té el rol correcte i que el seu nom accessible és l'esperat. Aquí hi ha la connexió que anunciava la introducció: els label htmlFor, els rols, els noms accessibles i l'aria-describedby de 03-06 no eren una tasca a part, eren els agafadors que ara usen les proves. Un <div onClick> en lloc d'un <button> trenca la prova, i l'ha de trencar. data-testid queda com a últim recurs legítim —elements sense semàntica, com EsqueletPagina— i com a contracte explícit a les proves d'extrem a extrem. I quan una consulta falli, ja tens l'ordre de depuració: llegir el bolcat del DOM, screen.debug(), logRoles(container) i el Testing Playground.

Per interactuar, userEvent sempre, perquè simula la seqüència completa d'esdeveniments, mou el focus i —el decisiu— no dispara res sobre un element deshabilitat, a diferència de fireEvent, que validaria comportaments impossibles en producció. Totes les seves crides són asíncrones: si la línia comença per usuari., porta await. I les assercions de jest-dom donen un vocabulari que parla d'interfície, amb dues joies infrautilitzades: toHaveAccessibleName i toHaveAccessibleDescription.

Els quatre casos de CicloUrbano han anat pujant de dificultat. EtiquetaEstat ha provat presentació pura i, de passada, que el símbol decoratiu continua ocult al lector de pantalla. TargetaBicicleta ha introduït els callbacks amb vi.fn() i el cas que justifica userEvent: prémer «Reservar» en una bicicleta en manteniment no ha d'avisar el pare. SelectorTipus ha ensenyat a provar un component controlat: es verifica que avisa amb 'electrica' i que no canvia el filtre pel seu compte, i l'estat actiu es consulta amb { pressed: true }, no amb una classe. I FormulariReserva ha tancat el cercle del mòdul 3: quatre role="alert" en enviar buit, i sobretot toHaveAccessibleDescription, que en una sola asserció comprova que el missatge existeix, que té l'identificador correcte i que el camp el referencia —la fallada de l'error visible però mal associat, que cap altra comprovació detecta.

Queda fixada la infraestructura: src/proves/utilitats.jsx amb crearMagatzemDeProva, crearClientDeProva i el renderitzar propi, que embolcalla el component amb els proveïdors reals en el mateix ordre que main.jsx i retorna magatzem, client i enrutador. Usar els proveïdors de veritat, i no simular-los, és el que fa que un selector mal escrit trenqui la prova en lloc de colar-se. I retry: false al QueryClient no és un detall: sense ell, qualsevol prova d'error es penja esperant tres reintents. Per a les rutes, createMemoryRouter amb initialEntries permet provar paràmetres (/bicicletas/bici-002), cadena de consulta (?tipo=electrica) i navegació real, comprovant la destinació assolida en lloc que s'hagi cridat useNavigate.

I la llista del que no es prova mai ja té noms concrets: estat intern, classes de CSS Modules, nombre de renders, crides a hooks de biblioteques, memo, estructura del DOM i funcions no exportades. Amb el senyal d'alarma que les resumeix: si necessites llegir el .jsx per saber què consultar, la prova està acoblada.

Falta el terreny on fallen la majoria de les proves del món real: l'asincronia. Tot el d'aquesta lliçó era síncron; tan bon punt PaginaCataleg demani les bicicletes a l'API, l'asserció s'executarà abans que arribi la resposta i apareixerà el temut avís d'act(...). La propera lliçó explica aquest avís de debò, presenta les eines d'espera (findBy, waitFor, waitForElementToBeRemoved) i munta MSW per interceptar la xarxa a nivell de protocol, amb src/proves/gestors.js i src/proves/servidor.js, de manera que es puguin provocar a voluntat els tres estats de la interfície: carregant, èxit i error. A més es proven les mutacions de TanStack Query i els hooks personalitzats amb renderHook. La propera lliçó és Proves de Codi Asíncron i Simulació d'APIs.

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