A la lliçó anterior CicloUrbano va quedar funcionant de cap a cap, i l'única comprovació va ser un recorregut manual de vint passos amb la pestanya de xarxa oberta. Aquest recorregut va demostrar que l'aplicació funciona avui. No demostra res sobre demà: tan bon punt algú toqui una línia de l'onSettled d'una mutació, ningú tornarà a repetir els vint passos, i la fallada arribarà a l'usuari abans que a nosaltres. Aquesta lliçó converteix aquesta comprovació manual en una xarxa de seguretat automàtica, aplicant al codi real del projecte l'estratègia que es va estudiar al Mòdul 9. No es torna a explicar com funciona Testing Library ni MSW: aquí es decideix què es prova, en quin nivell i per què, i s'escriu la suite completa del projecte.

Contingut

  1. El pla de proves: cada història al seu nivell
  2. El que es decideix no provar, i per què
  3. Configuració a punt per al projecte
  4. Els gestors de MSW sobre el domini de CicloUrbano
  5. Proves unitàries: validarReserva
  6. Proves unitàries: el reductor i els selectors de reserves
  7. Proves unitàries: els hooks propis
  8. Proves de components: TargetaBicicleta, SelectorTipus i FormulariReserva
  9. Proves d'integració: pàgines senceres amb MSW
  10. Proves d'extrem a extrem amb Cypress
  11. Cobertura: llegir l'informe amb criteri
  12. Integració contínua
  13. La regressió guiada: què caça cada nivell

  1. El pla de proves: cada història al seu nivell

El pla no s'improvisa fitxer a fitxer: es parteix de les vuit històries d'usuari que es van escriure a la planificació del projecte i es decideix, per a cadascuna, quin nivell la cobreix. Una història ben coberta no cal que estigui provada als cinc nivells; cal que estigui provada al nivell més barat que detecti la seva fallada característica.

Història Estàtica Unitària Component Integració E2E
H1 Veure el catàleg Linter i exhaustive-deps TargetaBicicleta pinta model, tipus, estat i preu : esquelet → llista → error 500 amb reintent Inclosa al flux de reserva
H2 Filtrar i cercar useDebounce SelectorTipus avisa del canvi : ?tipo=electrica filtra la llista pintada
H3 Fitxa de bicicleta : id inexistent → 404 propi
H4 Identificar-se useMagatzemLocal FormulariAcces valida Torna a la pantalla d'origen : acces.cy.js
H5 Reservar : validarReserva complet FormulariReserva mostra errors accessibles : cos de la petició i redirecció : reservar.cy.js
H6 Veure i cancel·lar reserves Reductor i selectors de sliceReserves PanellReserves demana confirmació : optimista + invalidació : cancellar.cy.js
H7 L'operari canvia l'estat : client a /taller → sense permisos Exercici
H8 Estacions i flota TargetaEstacio : pestanya activa a la URL

Fixa't en el patró: la columna d'integració està gairebé plena i la d'e2e gairebé buida. És exactament el repartiment que recomana el trofeu de proves. Les d'integració proven una pantalla sencera amb la xarxa simulada, que és on viu la major part de les fallades reals d'aquesta aplicació (estats de càrrega, invalidació de memòria cau, errors de xarxa, permisos), i costen una fracció del que costa una prova e2e.

  1. El que es decideix no provar, i per què

Escriure què no es prova és tan important com escriure què sí, perquè evita la discussió eterna i el cas de consciència.

No es prova Per què
D1 Tema clar/fosc La seva fallada és visual i evident a primer cop d'ull; una prova només comprovaria que un atribut canvia de valor
D2 Avisos globals Es comproven de passada a les proves d'integració de reserva, on l'avís d'èxit és part de l'asserció
D3 Avís de pèrdua de connexió Depèn d'un esdeveniment del navegador difícil de simular amb fidelitat; el cost supera el risc
D4 Resum de flota És un càlcul derivat trivial; si falla, es veu a la pantalla
Noms de classes CSS i maquetació Canvien amb cada retoc de disseny; provar-los genera proves fràgils que no detecten cap fallada funcional
Nombre de renders És implementació, no comportament. Per a això hi ha el Profiler
La biblioteca de tercers TanStack Query ja té les seves proves; nosaltres provem el nostre ús que en fem

  1. Configuració a punt per al projecte

La infraestructura és la que es va muntar al Mòdul 9, ara consolidada al projecte. Primer, la secció test de vite.config.js:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    // jsdom dona un DOM a Node: hi ha document, però no disseny real ni navegació de veritat
    environment: 'jsdom',
    // globals: true permet fer servir describe/test/expect sense importar-los a cada fitxer
    globals: true,
    // Aquest fitxer s'executa abans de cada fitxer de proves
    setupFiles: './src/proves/configuracio.js',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      // No té sentit mesurar cobertura del que no és lògica
      exclude: ['src/proves/**', 'src/main.jsx', '**/*.module.css'],
    },
  },
});

El fitxer de preparació fa quatre coses, i cadascuna evita una classe sencera de proves contaminades:

// src/proves/configuracio.js
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { servidor } from './servidor.js';

// 1. Arrenca la xarxa simulada. onUnhandledRequest: 'error' és la decisió clau:
//    si una prova demana una URL que no té gestor, falla en comptes de penjar-se.
beforeAll(() => servidor.listen({ onUnhandledRequest: 'error' }));

afterEach(() => {
  cleanup();                    // 2. Desmunta el que s'ha renderitzat
  servidor.resetHandlers();     // 3. Desfà els servidor.use() de la prova anterior
  localStorage.clear();         // 4. La sessió d'una prova no es filtra a la següent
});

afterAll(() => servidor.close());

I la utilitat renderitzar, que és la peça que fa llegibles totes les proves del projecte: munta el component amb els proveïdors reals, en el mateix ordre que main.jsx.

// src/proves/utilitats.jsx
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createMemoryRouter, RouterProvider } from 'react-router';
import { sliceSessio } from '../funcionalitats/sessio/sliceSessio.js';
import { sliceCataleg } from '../funcionalitats/cataleg/sliceCataleg.js';
import { sliceReserves } from '../funcionalitats/reserves/sliceReserves.js';
import ProveidorTema from '../contextos/ProveidorTema.jsx';
import ProveidorAvisos from '../contextos/ProveidorAvisos.jsx';

export function crearMagatzemDeProva(estatInicial) {
  return configureStore({
    reducer: {
      sessio: sliceSessio.reducer,
      cataleg: sliceCataleg.reducer,
      reserves: sliceReserves.reducer,
    },
    preloadedState: estatInicial,
  });
}

export function crearClientDeProva() {
  return new QueryClient({
    defaultOptions: {
      // Sense reintents: una prova d'error no ha d'esperar tres intents fallits
      queries: { retry: false, gcTime: Infinity },
      mutations: { retry: false },
    },
  });
}

export function renderitzar(element, opcions = {}) {
  const {
    estatInicial,
    magatzem = crearMagatzemDeProva(estatInicial),
    client = crearClientDeProva(),
    ruta = '/',
    rutes = [{ path: '*', element }],
  } = opcions;

  const enrutador = createMemoryRouter(rutes, { initialEntries: [ruta] });

  const resultat = render(
    <QueryClientProvider client={client}>
      <Provider store={magatzem}>
        <ProveidorTema>
          <ProveidorAvisos>
            <RouterProvider router={enrutador} />
          </ProveidorAvisos>
        </ProveidorTema>
      </Provider>
    </QueryClientProvider>
  );

  return { ...resultat, magatzem, client, enrutador, usuari: userEvent.setup() };
}

export * from '@testing-library/react';
export { userEvent };

Cada prova rep un magatzem i un client nous, i per tant una memòria cau buida. Compartir-los entre proves és la causa número u de proves que passen soltes i fallen en conjunt.

  1. Els gestors de MSW sobre el domini de CicloUrbano

Els gestors repliquen el comportament de json-server, inclòs el filtratge per paràmetres de consulta, perquè el codi de producció en depèn.

// src/proves/gestors.js
import { http, HttpResponse } from 'msw';

const API = 'http://localhost:3001';

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 USUARIS = [
  { id: 'usr-01', nom: 'Ana Ribera', email: '[email protected]', rol: 'cliente' },
  { id: 'usr-02', nom: 'Marc Solé', email: '[email protected]', rol: 'operario' },
];

export const RESERVES = [
  { id: 'res-01', bicicletaId: 'bici-002', usuari: 'usr-01', dataInici: '2026-05-04T09:00', hores: 2, estat: 'activa' },
];

export const gestors = [
  http.get(`${API}/bicicletas`, ({ request }) => {
    const parametres = new URL(request.url).searchParams;
    const tipus = parametres.get('tipo');
    const estacioId = parametres.get('estacioId');
    let resultat = BICICLETES;
    if (tipus) resultat = resultat.filter((b) => b.tipus === tipus);
    if (estacioId) resultat = resultat.filter((b) => b.estacioId === estacioId);
    return HttpResponse.json(resultat);
  }),

  http.get(`${API}/bicicletas/:bicicletaId`, ({ params }) => {
    const bicicleta = BICICLETES.find((b) => b.id === params.bicicletaId);
    // Reproduïm el 404 real de json-server: la prova del 404 propi en depèn
    if (!bicicleta) return new HttpResponse(null, { status: 404 });
    return HttpResponse.json(bicicleta);
  }),

  http.patch(`${API}/bicicletas/:bicicletaId`, async ({ params, request }) => {
    const canvis = await request.json();
    const bicicleta = BICICLETES.find((b) => b.id === params.bicicletaId);
    return HttpResponse.json({ ...bicicleta, ...canvis });
  }),

  http.get(`${API}/estaciones`, () => HttpResponse.json(ESTACIONS)),

  http.get(`${API}/estaciones/:estacionId`, ({ params }) => {
    const estacio = ESTACIONS.find((e) => e.id === params.estacionId);
    if (!estacio) return new HttpResponse(null, { status: 404 });
    return HttpResponse.json(estacio);
  }),

  http.get(`${API}/usuarios`, () => HttpResponse.json(USUARIS)),

  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);
  }),

  http.post(`${API}/reservas`, async ({ request }) => {
    const nova = await request.json();
    return HttpResponse.json({ ...nova, id: 'res-nueva' }, { status: 201 });
  }),

  http.patch(`${API}/reservas/:reservaId`, async ({ params, request }) => {
    const canvis = await request.json();
    const reserva = RESERVES.find((r) => r.id === params.reservaId);
    return HttpResponse.json({ ...reserva, ...canvis });
  }),
];
// src/proves/servidor.js
import { setupServer } from 'msw/node';
import { gestors } from './gestors.js';

export const servidor = setupServer(...gestors);

  1. Proves unitàries: validarReserva

validarReserva és una funció pura amb cinc regles, i per tant el lloc on una taula de casos rendeix més per línia escrita. Aquí no cal React, ni DOM, ni xarxa.

// src/utilitats/validarReserva.test.js
import { describe, test, expect } from 'vitest';
import { validarReserva } from './validarReserva.js';
import { BICICLETES } from '../proves/gestors.js';

// Una data clarament futura i una altra clarament passada, fixes perquè la prova
// no depengui del rellotge del dia en què s'executi.
const FUTUR = '2027-01-15T10:00';
const PASSAT = '2020-01-15T10:00';

const VALIDS = {
  bicicletaId: 'bici-001',
  dataInici: FUTUR,
  hores: 2,
  condicions: true,
};

describe('validarReserva', () => {
  test('no retorna errors amb dades vàlides', () => {
    expect(validarReserva(VALIDS, BICICLETES)).toEqual({});
  });

  test.each([
    ['bicicleta sense triar',       { bicicletaId: '' },                 'bicicletaId'],
    ['bicicleta inexistent',        { bicicletaId: 'bici-999' },         'bicicletaId'],
    ['bicicleta ja llogada',        { bicicletaId: 'bici-002' },         'bicicletaId'],
    ['bicicleta en manteniment',    { bicicletaId: 'bici-003' },         'bicicletaId'],
    ['data buida',                  { dataInici: '' },                   'dataInici'],
    ['data en el passat',           { dataInici: PASSAT },               'dataInici'],
    ['zero hores',                  { hores: 0 },                        'hores'],
    ['hores per sobre del dia',     { hores: 25 },                       'hores'],
    ['hores no numèriques',         { hores: 'dos' },                    'hores'],
    ['condicions sense acceptar',   { condicions: false },               'condicions'],
  ])('marca error a %s', (_descripcio, canvis, campEsperat) => {
    const errors = validarReserva({ ...VALIDS, ...canvis }, BICICLETES);
    // Es comprova QUIN camp falla, no el text exacte del missatge:
    // el text és redacció i canviarà; el camp és contracte.
    expect(errors).toHaveProperty(campEsperat);
  });

  test('accepta els extrems vàlids del rang d\'hores', () => {
    expect(validarReserva({ ...VALIDS, hores: 1 }, BICICLETES)).toEqual({});
    expect(validarReserva({ ...VALIDS, hores: 24 }, BICICLETES)).toEqual({});
  });

  test('acumula tots els errors, no només el primer', () => {
    const errors = validarReserva(
      { bicicletaId: '', dataInici: PASSAT, hores: 0, condicions: false },
      BICICLETES
    );
    expect(Object.keys(errors)).toHaveLength(4);
  });
});

Les dues últimes proves són les que més valor aporten i les que més s'obliden. Els extrems del rang (1 i 24) documenten que l'interval és tancat: si algú canvia el <= per un <, la prova ho caça. I l'acumulació d'errors protegeix una decisió de disseny de la interfície: el formulari mostra tots els errors alhora, així que una implementació que retornés al primer error trencaria la pantalla sense trencar cap altra prova.

  1. Proves unitàries: el reductor i els selectors de reserves

Un reductor és una funció pura (estat, accio) => estat, així que es prova despatxant accions sobre un estat conegut, sense muntar res.

// src/funcionalitats/reserves/sliceReserves.test.js
import { describe, test, expect } from 'vitest';
import {
  sliceReserves,
  reservaCreada,
  reservaCancellada,
  seleccionarReservesDeUsuari,
  seleccionarResumReserves,
} from './sliceReserves.js';

const { reducer } = sliceReserves;

const ESTAT_AMB_UNA = {
  entitats: {
    'res-01': { id: 'res-01', bicicletaId: 'bici-002', usuari: 'usr-01', hores: 2, estat: 'activa' },
  },
  ids: ['res-01'],
  estatEnviament: 'inactiu',
  error: null,
};

describe('reductor de reserves', () => {
  test('retorna l\'estat inicial davant d\'una acció desconeguda', () => {
    const estat = reducer(undefined, { type: 'accion/inexistente' });
    expect(estat.ids).toEqual([]);
  });

  test('afegeix la reserva creada al final i no muta l\'estat anterior', () => {
    const nova = { id: 'res-02', bicicletaId: 'bici-001', usuari: 'usr-01', hores: 3, estat: 'activa' };
    const seguent = reducer(ESTAT_AMB_UNA, reservaCreada(nova));

    expect(seguent.ids).toEqual(['res-01', 'res-02']);
    expect(seguent.entitats['res-02'].hores).toBe(3);
    // Immer permet escriure com si mutéssim, però l'estat previ ha de quedar intacte
    expect(ESTAT_AMB_UNA.ids).toEqual(['res-01']);
  });

  test('cancel·lar canvia l\'estat de la reserva sense eliminar-la', () => {
    const seguent = reducer(ESTAT_AMB_UNA, reservaCancellada({ id: 'res-01' }));
    expect(seguent.entitats['res-01'].estat).toBe('cancelada');
    expect(seguent.ids).toHaveLength(1);
  });
});

describe('selectors de reserves', () => {
  const estatArrel = { reserves: ESTAT_AMB_UNA };

  test('filtra per usuari', () => {
    expect(seleccionarReservesDeUsuari(estatArrel, 'usr-01')).toHaveLength(1);
    expect(seleccionarReservesDeUsuari(estatArrel, 'usr-02')).toHaveLength(0);
  });

  test('el resum compta per estat', () => {
    expect(seleccionarResumReserves(estatArrel)).toEqual({
      actives: 1, confirmades: 0, cancellades: 0,
    });
  });
});

L'asserció expect(ESTAT_AMB_UNA.ids).toEqual(['res-01']) mereix un comentari: comprova que Immer fa la seva feina. És la prova que caça l'error d'escriure un reductor que muta de veritat perquè algú el va treure del createSlice cap a una funció auxiliar.

  1. Proves unitàries: els hooks propis

Els hooks necessiten un component que els executi, i per a això hi ha renderHook. useDebounce, a més, necessita controlar el temps.

// src/hooks/useDebounce.test.js
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce.js';

describe('useDebounce', () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());

  test('retorna el valor inicial immediatament', () => {
    const { result } = renderHook(() => useDebounce('urbana', 300));
    expect(result.current).toBe('urbana');
  });

  test('no actualitza abans del retard i sí després', () => {
    const { result, rerender } = renderHook(({ valor }) => useDebounce(valor, 300), {
      initialProps: { valor: 'urb' },
    });

    rerender({ valor: 'urbana' });
    // Just abans del límit el valor antic segueix vigent
    act(() => vi.advanceTimersByTime(299));
    expect(result.current).toBe('urb');

    act(() => vi.advanceTimersByTime(1));
    expect(result.current).toBe('urbana');
  });

  test('només aplica l\'últim valor d\'una ràfega de canvis', () => {
    const { result, rerender } = renderHook(({ valor }) => useDebounce(valor, 300), {
      initialProps: { valor: 'u' },
    });

    rerender({ valor: 'ur' });
    act(() => vi.advanceTimersByTime(100));
    rerender({ valor: 'urb' });
    act(() => vi.advanceTimersByTime(100));
    rerender({ valor: 'urbana' });
    act(() => vi.advanceTimersByTime(300));

    expect(result.current).toBe('urbana');
  });
});
// src/hooks/useMagatzemLocal.test.js
import { describe, test, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMagatzemLocal } from './useMagatzemLocal.js';

describe('useMagatzemLocal', () => {
  test('fa servir el valor per defecte quan la clau no existeix', () => {
    const { result } = renderHook(() => useMagatzemLocal('ciclourbano:tema', 'clar'));
    expect(result.current[0]).toBe('clar');
  });

  test('persisteix el valor i el recupera en un muntatge posterior', () => {
    const primer = renderHook(() => useMagatzemLocal('ciclourbano:tema', 'clar'));
    act(() => primer.result.current[1]('fosc'));
    primer.unmount();

    const segon = renderHook(() => useMagatzemLocal('ciclourbano:tema', 'clar'));
    expect(segon.result.current[0]).toBe('fosc');
  });

  test('no rebenta si el contingut emmagatzemat no és JSON vàlid', () => {
    localStorage.setItem('ciclourbano:tema', '{trencat');
    const { result } = renderHook(() => useMagatzemLocal('ciclourbano:tema', 'clar'));
    expect(result.current[0]).toBe('clar');
  });
});

Aquesta última prova és la que justifica el try/catch del hook. Sense ella, ningú recordaria per què hi és i algú l'esborraria a la primera neteja.

  1. Proves de components: comportament, mai implementació

Amb els components canvia la pregunta: ja no és «què retorna aquesta funció» sinó «què veu i què pot fer la persona que fa servir això». Les consultes per rol, explicades a React Testing Library, són la traducció directa de la feina d'accessibilitat de la lliçó 03-06.

// src/components/TargetaBicicleta.test.jsx
import { describe, test, expect, vi } from 'vitest';
import { renderitzar, screen } from '../proves/utilitats.jsx';
import TargetaBicicleta from './TargetaBicicleta.jsx';

function bicicleta(id, model, tipus, estat, preuHora) {
  return { id, model, tipus, estat, estacioId: 'est-01', preuHora };
}

const DISPONIBLE = bicicleta('bici-001', 'Urbana Clàssica', 'urbana', 'disponible', 2.5);
const AL_TALLER = bicicleta('bici-003', 'Càrrega Max', 'carga', 'mantenimiento', 5.5);

describe('TargetaBicicleta', () => {
  test('mostra model, estat i preu per hora', () => {
    renderitzar(<TargetaBicicleta bicicleta={DISPONIBLE} nomEstacio="Plaça Major" />);

    expect(screen.getByRole('heading', { name: 'Urbana Clàssica' })).toBeInTheDocument();
    expect(screen.getByText('Disponible')).toBeInTheDocument();
    expect(screen.getByText(/2,50/)).toBeInTheDocument();
    expect(screen.getByText('Plaça Major')).toBeInTheDocument();
  });

  test('ofereix el botó de reservar només si la bicicleta està disponible', () => {
    const { unmount } = renderitzar(<TargetaBicicleta bicicleta={DISPONIBLE} nomEstacio="Plaça Major" />);
    expect(screen.getByRole('button', { name: /reservar/i })).toBeInTheDocument();
    unmount();

    renderitzar(<TargetaBicicleta bicicleta={AL_TALLER} nomEstacio="Parc Nord" />);
    expect(screen.queryByRole('button', { name: /reservar/i })).not.toBeInTheDocument();
  });

  test('avisa el pare amb la bicicleta polsada', async () => {
    const alReservar = vi.fn();
    const { usuari } = renderitzar(
      <TargetaBicicleta bicicleta={DISPONIBLE} nomEstacio="Plaça Major" alReservar={alReservar} />
    );

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

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

  test('no transmet l\'estat només amb color', () => {
    renderitzar(<TargetaBicicleta bicicleta={AL_TALLER} nomEstacio="Parc Nord" />);
    // El text de l'estat ha d'existir, no només la classe CSS que el pinta
    expect(screen.getByText('Al taller')).toBeInTheDocument();
  });
});

SelectorTipus és un component controlat, i això determina què es prova: que avisi, no que canviï per dins. El seu estat ja no existeix.

// src/components/SelectorTipus.test.jsx
import { describe, test, expect, vi } from 'vitest';
import { renderitzar, screen } from '../proves/utilitats.jsx';
import SelectorTipus from './SelectorTipus.jsx';

describe('SelectorTipus', () => {
  test('marca com a actiu el tipus rebut per props', () => {
    renderitzar(<SelectorTipus tipusTriat="electrica" alCanviarTipus={vi.fn()} />);

    expect(screen.getByRole('button', { name: 'Elèctriques' })).toHaveAttribute('aria-pressed', 'true');
    expect(screen.getByRole('button', { name: 'Totes' })).toHaveAttribute('aria-pressed', 'false');
  });

  test('avisa del tipus polsat i no decideix res pel seu compte', async () => {
    const alCanviarTipus = vi.fn();
    const { usuari } = renderitzar(<SelectorTipus tipusTriat="todos" alCanviarTipus={alCanviarTipus} />);

    await usuari.click(screen.getByRole('button', { name: 'De càrrega' }));

    expect(alCanviarTipus).toHaveBeenCalledWith('carga');
    // El botó polsat NO queda actiu: qui decideix és el pare.
    // Aquesta asserció és la que documenta que el component és controlat.
    expect(screen.getByRole('button', { name: 'De càrrega' })).toHaveAttribute('aria-pressed', 'false');
  });
});

I FormulariReserva, on es comprova alhora la validació i la seva accessibilitat:

// src/components/FormulariReserva.test.jsx
import { describe, test, expect, vi } from 'vitest';
import { renderitzar, screen } from '../proves/utilitats.jsx';
import { BICICLETES } from '../proves/gestors.js';
import FormulariReserva from './FormulariReserva.jsx';

describe('FormulariReserva', () => {
  test('no envia i mostra els errors associats a cada camp', async () => {
    const alCrearReserva = vi.fn();
    const { usuari } = renderitzar(
      <FormulariReserva bicicletes={BICICLETES} alCrearReserva={alCrearReserva} />
    );

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

    expect(alCrearReserva).not.toHaveBeenCalled();

    // L'error no està solt a la pàgina: està associat al camp per aria-describedby,
    // que és el que fa que un lector de pantalla l'anunciï en enfocar-lo.
    const campBicicleta = screen.getByLabelText(/bicicleta/i);
    expect(campBicicleta).toHaveAttribute('aria-invalid', 'true');
    expect(campBicicleta).toHaveAccessibleDescription(/tria una bicicleta/i);
  });

  test('impedeix reservar una bicicleta que no està disponible', async () => {
    const alCrearReserva = vi.fn();
    const { usuari } = renderitzar(
      <FormulariReserva bicicletes={BICICLETES} alCrearReserva={alCrearReserva} />
    );

    await usuari.selectOptions(screen.getByLabelText(/bicicleta/i), 'bici-003');
    await usuari.type(screen.getByLabelText(/inici/i), '2027-01-15T10:00');
    await usuari.click(screen.getByLabelText(/condicions/i));
    await usuari.click(screen.getByRole('button', { name: /confirmar reserva/i }));

    expect(alCrearReserva).not.toHaveBeenCalled();
    expect(screen.getByRole('alert')).toHaveTextContent(/no està disponible/i);
  });

  test('envia les dades completes quan tot és vàlid', async () => {
    const alCrearReserva = vi.fn();
    const { usuari } = renderitzar(
      <FormulariReserva bicicletes={BICICLETES} alCrearReserva={alCrearReserva} />
    );

    await usuari.selectOptions(screen.getByLabelText(/bicicleta/i), 'bici-001');
    await usuari.type(screen.getByLabelText(/inici/i), '2027-01-15T10:00');
    await usuari.clear(screen.getByLabelText(/hores/i));
    await usuari.type(screen.getByLabelText(/hores/i), '3');
    await usuari.click(screen.getByLabelText(/condicions/i));
    await usuari.click(screen.getByRole('button', { name: /confirmar reserva/i }));

    expect(alCrearReserva).toHaveBeenCalledWith(
      expect.objectContaining({ bicicletaId: 'bici-001', hores: 3, estat: 'activa' })
    );
  });
});

  1. Proves d'integració: pàgines senceres amb MSW

Aquí és on el pla concentra l'esforç. Una prova d'integració munta la pàgina real, amb els seus hooks de consulta reals, contra la xarxa simulada; comprova els tres estats que l'usuari pot arribar a veure.

// src/pagines/PaginaCataleg.test.jsx
import { describe, test, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { renderitzar, screen, waitFor, waitForElementToBeRemoved } from '../proves/utilitats.jsx';
import { servidor } from '../proves/servidor.js';
import PaginaCataleg from './PaginaCataleg.jsx';

const API = 'http://localhost:3001';

describe('PaginaCataleg', () => {
  test('mostra l\'esquelet i després les cinc bicicletes', async () => {
    renderitzar(<PaginaCataleg />);

    // Primer estat: esquelet, no un text «Carregant…»
    expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();
    await waitForElementToBeRemoved(() => screen.queryByTestId('esqueleto-pagina'));

    // Segon estat: la llista pintada
    expect(await screen.findAllByTestId('tarjeta-bicicleta')).toHaveLength(5);
    expect(screen.getByRole('heading', { name: 'Elèctrica Pro' })).toBeInTheDocument();
  });

  test('filtra pel tipus que arriba a la URL', async () => {
    renderitzar(<PaginaCataleg />, { ruta: '/?tipo=electrica' });

    const targetes = await screen.findAllByTestId('tarjeta-bicicleta');
    expect(targetes).toHaveLength(2);
    expect(screen.getByRole('button', { name: 'Elèctriques' })).toHaveAttribute('aria-pressed', 'true');
  });

  test('davant d\'un error del servidor ofereix reintentar, i el reintent funciona', async () => {
    let intents = 0;
    servidor.use(
      http.get(`${API}/bicicletas`, () => {
        intents += 1;
        // El primer intent falla; el segon, ja amb el gestor per defecte, funciona
        if (intents === 1) return new HttpResponse(null, { status: 500 });
        return HttpResponse.json([
          { id: 'bici-001', model: 'Urbana Clàssica', tipus: 'urbana', estat: 'disponible', estacioId: 'est-01', preuHora: 2.5 },
        ]);
      })
    );

    const { usuari } = renderitzar(<PaginaCataleg />);

    // Tercer estat: error visible i accionable
    expect(await screen.findByRole('alert')).toHaveTextContent(/no s'ha pogut carregar/i);
    await usuari.click(screen.getByRole('button', { name: /reintentar/i }));

    expect(await screen.findByRole('heading', { name: 'Urbana Clàssica' })).toBeInTheDocument();
    await waitFor(() => expect(intents).toBe(2));
  });

  test('la cerca es combina amb el filtre sense tornar a demanar al servidor', async () => {
    const { usuari } = renderitzar(<PaginaCataleg />, { ruta: '/?tipo=urbana' });
    await screen.findAllByTestId('tarjeta-bicicleta');

    await usuari.type(screen.getByRole('searchbox', { name: /cercar/i }), 'Clàssica');

    await waitFor(() => expect(screen.getAllByTestId('tarjeta-bicicleta')).toHaveLength(2));
  });
});

La prova del formulari complet és la que substitueix els vint passos manuals: comprova el cos de la petició i la navegació posterior.

// src/pagines/PaginaNovaReserva.test.jsx
import { describe, test, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { renderitzar, screen, waitFor } from '../proves/utilitats.jsx';
import { servidor } from '../proves/servidor.js';
import PaginaNovaReserva from './PaginaNovaReserva.jsx';
import PaginaReserves from './PaginaReserves.jsx';

const API = 'http://localhost:3001';
const SESSIO_CLIENT = { sessio: { usuari: { id: 'usr-01', nom: 'Ana Ribera', rol: 'cliente' } } };

describe('PaginaNovaReserva', () => {
  test('envia la reserva i redirigeix a les meves reserves', async () => {
    let cosRebut = null;
    servidor.use(
      http.post(`${API}/reservas`, async ({ request }) => {
        cosRebut = await request.json();
        return HttpResponse.json({ ...cosRebut, id: 'res-nueva' }, { status: 201 });
      })
    );

    const { usuari, enrutador } = renderitzar(null, {
      estatInicial: SESSIO_CLIENT,
      ruta: '/reservas/nueva',
      rutes: [
        { path: '/reservas', element: <PaginaReserves /> },
        { path: '/reservas/nueva', element: <PaginaNovaReserva /> },
      ],
    });

    await usuari.selectOptions(await screen.findByLabelText(/bicicleta/i), 'bici-001');
    await usuari.type(screen.getByLabelText(/inici/i), '2027-01-15T10:00');
    await usuari.click(screen.getByLabelText(/condicions/i));
    await usuari.click(screen.getByRole('button', { name: /confirmar reserva/i }));

    // 1. El que s'ha enviat al servidor
    await waitFor(() => expect(cosRebut).toMatchObject({
      bicicletaId: 'bici-001', usuari: 'usr-01', estat: 'activa',
    }));

    // 2. La redirecció, comprovada sobre l'enrutador real
    await waitFor(() => expect(enrutador.state.location.pathname).toBe('/reservas'));

    // 3. L'avís d'èxit
    expect(screen.getByTestId('aviso')).toHaveTextContent(/reserva creada/i);
  });

  test('una fallada del servidor deixa el formulari ple i mostra l\'error', async () => {
    servidor.use(http.post(`${API}/reservas`, () => new HttpResponse(null, { status: 500 })));

    const { usuari, enrutador } = renderitzar(<PaginaNovaReserva />, {
      estatInicial: SESSIO_CLIENT, ruta: '/reservas/nueva',
    });

    await usuari.selectOptions(await screen.findByLabelText(/bicicleta/i), 'bici-001');
    await usuari.type(screen.getByLabelText(/inici/i), '2027-01-15T10:00');
    await usuari.click(screen.getByLabelText(/condicions/i));
    await usuari.click(screen.getByRole('button', { name: /confirmar reserva/i }));

    expect(await screen.findByRole('alert')).toHaveTextContent(/no s'ha pogut crear/i);
    // El que s'ha escrit no es perd: és la diferència entre un error tolerable i un insofrible
    expect(screen.getByLabelText(/bicicleta/i)).toHaveValue('bici-001');
    expect(enrutador.state.location.pathname).toBe('/reservas/nueva');
  });
});

I el control d'accés, que és una regla de negoci i no un detall visual:

// src/pagines/PaginaTaller.test.jsx
import { describe, test, expect } from 'vitest';
import { renderitzar, screen } from '../proves/utilitats.jsx';
import PaginaTaller from './PaginaTaller.jsx';
import PaginaSensePermisos from './PaginaSensePermisos.jsx';
import RequereixRol from '../components/RequereixRol.jsx';

const rutes = [
  { path: '/sin-permisos', element: <PaginaSensePermisos /> },
  {
    path: '/taller',
    element: (
      <RequereixRol rolsPermesos={['operario']}>
        <PaginaTaller />
      </RequereixRol>
    ),
  },
];

describe('accés a /taller', () => {
  test('l\'operari veu el panell', async () => {
    renderitzar(null, {
      estatInicial: { sessio: { usuari: { id: 'usr-02', nom: 'Marc Solé', rol: 'operario' } } },
      ruta: '/taller', rutes,
    });

    expect(await screen.findByRole('heading', { name: /taller/i })).toBeInTheDocument();
  });

  test('un client que hi entra per URL rep «sense permisos», no un 404 ni una pantalla en blanc', async () => {
    renderitzar(null, {
      estatInicial: { sessio: { usuari: { id: 'usr-01', nom: 'Ana Ribera', rol: 'cliente' } } },
      ruta: '/taller', rutes,
    });

    expect(await screen.findByRole('heading', { name: /no tens permís/i })).toBeInTheDocument();
    expect(screen.queryByRole('heading', { name: /taller/i })).not.toBeInTheDocument();
  });
});

  1. Proves d'extrem a extrem amb Cypress

Només tres fluxos, els que el negoci no es pot permetre que es trenquin. Cadascun arrenca d'un estat sembrat i conegut.

// cypress/e2e/reservar.cy.js
describe('reservar una bicicleta', () => {
  beforeEach(() => {
    cy.sembrarDades();              // Reinicia db.json des de la llavor
    cy.accedirCom('usr-01');        // Embolcallat en cy.session: no repeteix el formulari
  });

  it('crea la reserva i la mostra a les meves reserves', () => {
    cy.intercept('GET', '**/bicicletas*').as('cataleg');
    cy.intercept('POST', '**/reservas').as('crearReserva');

    cy.visit('/');
    cy.wait('@cataleg');

    // S'espera l'element, mai un temps fix
    cy.targetaDe('bici-001').findByRole('button', { name: /reservar/i }).click();

    cy.location('pathname').should('eq', '/reservas/nueva');
    cy.findByLabelText(/inici/i).type('2027-01-15T10:00');
    cy.findByLabelText(/hores/i).clear().type('3');
    cy.findByLabelText(/condicions/i).check();
    cy.findByRole('button', { name: /confirmar reserva/i }).click();

    cy.wait('@crearReserva').its('response.statusCode').should('eq', 201);

    cy.location('pathname').should('eq', '/reservas');
    cy.perTestId('aviso').should('contain.text', 'Reserva creada');
    cy.perTestId('fila-reserva').should('have.length', 2);
    cy.perTestId('total-reserva').first().should('contain.text', '7,50');
  });
});
// cypress/e2e/cancellar.cy.js
describe('cancel·lar una reserva', () => {
  beforeEach(() => {
    cy.sembrarDades();
    cy.accedirCom('usr-01');
  });

  it('demana confirmació, reflecteix el canvi i el persisteix després de recarregar', () => {
    cy.intercept('PATCH', '**/reservas/res-01').as('cancellar');

    cy.visit('/reservas');
    cy.perTestId('fila-reserva').should('have.length', 1);
    cy.findByRole('button', { name: /cancel·lar/i }).click();

    // Sense confirmar no passa res: la confirmació és part del contracte d'H6
    cy.findByRole('button', { name: /tornar/i }).click();
    cy.contains('Activa').should('exist');

    cy.findByRole('button', { name: /cancel·lar/i }).click();
    cy.findByRole('button', { name: /sí, cancel·lar/i }).click();

    cy.wait('@cancellar');
    cy.contains('Cancel·lada').should('exist');

    // La recàrrega és el que distingeix un canvi real d'una il·lusió optimista
    cy.reload();
    cy.contains('Cancel·lada').should('exist');
  });
});

Aquest cy.reload() final és el cor de la prova. Una actualització optimista pinta el canvi abans que el servidor respongui, així que sense recarregar no es distingeix una escriptura correcta d'una que s'ha revertit en silenci.

  1. Cobertura: llegir l'informe amb criteri

npm run cobertura
# → informe a la consola i a coverage/index.html

Un informe típic del projecte en aquest punt:

File                        | % Stmts | % Branch | Uncovered lines
----------------------------|---------|----------|----------------
src/utilidades/             |   98.1  |   96.4   |
src/funcionalidades/        |   91.7  |   84.2   |
src/componentes/            |   78.3  |   71.0   |
src/paginas/                |   72.6  |   64.8   |
src/hooks/                  |   88.9  |   80.0   |
src/api/cliente.js          |   61.2  |   45.0   | 48-53, 67-71
----------------------------|---------|----------|----------------
All files                   |   80.4  |   73.1   |

El número global del 80 % no diu res per si sol. El que importa és on són els forats:

Forat Importa? Per què
cliente.js línies 48-53: temps d'espera exhaurit És una branca d'error que l'usuari veurà amb una xarxa dolenta i que ningú ha exercitat mai
cliente.js línies 67-71: capçalera d'autorització absent Toca seguretat; una branca no provada aquí és una porta oberta
Branques de ProveidorTema No Desitjable D1, descartat conscientment al pla
Branques d'estil condicional a TargetaEstacio No Canvien amb el disseny; provar-les produeix proves fràgils
PaginaErrorRuta Parcialment Mereix una prova que comprovi que no mostra la traça tècnica a l'usuari

La regla: la cobertura no és un objectiu, és un detector de punts cecs. Es llegeix per descobrir branques que ningú va pensar a provar, i s'ignora quan assenyala codi que es va decidir no provar.

  1. Integració contínua

Tot l'anterior només val si s'executa sense que ningú s'hagi de recordar d'executar-ho.

# .github/workflows/pruebas.yml
name: Proves

on:
  push:
    branches: [main]
  pull_request:

jobs:
  estatica-i-unitaries:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm            # Accelera molt; la memòria cau d'npm és la meitat del temps del treball
      # ci en lloc d'install: instal·la exactament el del package-lock.json
      - run: npm ci
      - run: npm run lint
      - run: npm run formato:comprobar
      - run: npm run cobertura
      - uses: actions/upload-artifact@v4
        if: always()           # Puja l'informe encara que les proves fallin
        with:
          name: cobertura
          path: coverage/

  extrem-a-extrem:
    runs-on: ubuntu-latest
    needs: estatica-i-unitaries   # No gastis sis minuts d'e2e si el linter ja ha fallat
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      # Es prova l'aplicació CONSTRUÏDA, no el servidor de desenvolupament:
      # és el més semblant a producció que es pot executar aquí.
      - run: npm run build
      - uses: cypress-io/github-action@v6
        with:
          install: false
          start: npm run api, npm run preview
          wait-on: 'http://localhost:4173, http://localhost:3001/bicicletas'
          wait-on-timeout: 90
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: cypress-captures
          path: |
            cypress/screenshots
            cypress/videos

Quan falla a CI i no en local, la causa és gairebé sempre en aquesta llista:

Símptoma Causa habitual Solució
Falla només a CI, passa en local Ordre de proves diferent i estat global compartit Aïllar a afterEach; mai dependre de l'ordre
Falla de manera intermitent Espera per temps en lloc de per element Substituir wait(500) per findBy* o cy.wait('@alias')
Falla amb dates Fus horari del contenidor diferent Dates fixes a les proves; TZ=Europe/Madrid a l'entorn
Falla en construir Dependència instal·lada en local però no al lock npm ci en local per reproduir-ho
Va lent i expira Sense memòria cau d'npm o wait-on massa curt Memòria cau i wait-on-timeout folgat

I el consell que estalvia hores: abans de depurar a cegues, descarrega els artefactes. Les captures i el vídeo de Cypress mostren la pantalla exacta en el moment de la fallada.

  1. La regressió guiada: què caça cada nivell

Toca demostrar que la suite serveix. Anem a trencar el projecte a propòsit. A la mutació de cancel·lar reserva escrita a la lliçó anterior, esborrem una sola línia:

// src/funcionalitats/reserves/useCancelarReserva.js
onSettled: (_dades, _error, variables) => {
  client.invalidateQueries({ queryKey: claus.reserves.deUsuari(variables.usuariId) });
- client.invalidateQueries({ queryKey: claus.bicicletes.totes() });
}

És una fallada realista i traïdora: la reserva es cancel·la bé, la llista de reserves s'actualitza bé, i la bicicleta continua apareixent com a llogada al catàleg fins que l'usuari recarrega. Ningú se n'adona mirant la pantalla de reserves. Això és el que passa en executar npm run pruebas:todas:

flowchart TD
    F["S'esborra invalidateQueries<br/>de bicicletes"] --> E["Anàlisi estàtica"]
    F --> U["Unitàries<br/>validarReserva, reductor, hooks"]
    F --> C["De component<br/>TargetaBicicleta, PanellReserves"]
    F --> I["D'integració<br/>pàgina sencera + MSW"]
    F --> X["Extrem a extrem<br/>Cypress"]
    E --> EP["PASSA<br/>la sintaxi és vàlida"]
    U --> UP["PASSA<br/>no hi ha memòria cau en joc"]
    C --> CP["PASSA<br/>el component rep props, no memòria cau"]
    I --> IF["FALLA<br/>el catàleg no reflecteix el canvi"]
    X --> XF["FALLA<br/>després de cancel·lar, la bici continua llogada"]

La prova que ho caça és aquesta, que convé afegir al conjunt d'integració precisament per aquest motiu:

test('en cancel·lar, el catàleg reflecteix que la bicicleta torna a estar disponible', async () => {
  const { usuari } = renderitzar(null, {
    estatInicial: SESSIO_CLIENT,
    ruta: '/reservas',
    rutes: [
      { path: '/reservas', element: <PaginaReserves /> },
      { path: '/', element: <PaginaCataleg /> },
    ],
  });

  await usuari.click(await screen.findByRole('button', { name: /cancel·lar/i }));
  await usuari.click(screen.getByRole('button', { name: /sí, cancel·lar/i }));
  await screen.findByText('Cancel·lada');

  // Es torna al catàleg: si la memòria cau de bicicletes no s'ha invalidat,
  // aquí encara es veurà «Llogada» i la prova falla.
  await usuari.click(screen.getByRole('link', { name: /catàleg/i }));
  const targeta = (await screen.findAllByTestId('tarjeta-bicicleta'))
    .find((t) => t.dataset.bicicleta === 'bici-002');
  await waitFor(() => expect(targeta).toHaveTextContent('Disponible'));
});

La lliçó que s'extreu de la taula és la que ordena tot l'esforç de proves d'un projecte React:

Nivell Caça bé És cec a
Estàtic Errors de tipus, dependències mal declarades, ARIA mal posat Qualsevol fallada de lògica correctament escrita
Unitari Regles de negoci, càlculs, transicions d'estat Tot el que passa entre peces
De component Contractes de props, accessibilitat, callbacks Memòria cau, xarxa, navegació, integració
D'integració Memòria cau, invalidació, estats de càrrega i error, permisos, navegació Estils reals, comportament del navegador
Extrem a extrem Fluxos complets, persistència real, CSS que tapa un botó Casos límit (seria caríssim cobrir-los aquí)

Per això el pla de la secció 1 té la columna d'integració plena: és el nivell que caça les fallades que aquest projecte produeix de veritat, i el més rendible per minut invertit.

Errors Comuns i Consells

  • Compartir el QueryClient entre proves. És la causa més freqüent de proves que passen aïllades i fallen en conjunt: la memòria cau d'una prova respon a la següent. Un client nou per prova, sempre.
  • Deixar els reintents activats. Amb retry: 3, una prova d'error espera tres intents fallits abans de mostrar el missatge i acaba expirant. retry: false a les proves.
  • Provar el text exacte dels missatges. toHaveProperty('horas') sobreviu a una reescriptura del missatge; toBe('Les hores han d'estar entre 1 i 24') es trenca amb cada retoc de redacció. Prova el camp, no la prosa.
  • Oblidar await a userEvent. Totes les seves crides són asíncrones. Sense await l'asserció s'executa abans del canvi i la fallada és desconcertant.
  • Fer servir getBy* per comprovar que alguna cosa NO hi és. getBy* llança si no ho troba. Per a l'absència, queryBy*.
  • Esperar per temps a Cypress. cy.wait(1000) és la recepta d'una prova inestable. Espera per l'àlies de la petició o per l'element.
  • Perseguir el 100 % de cobertura. Omple el projecte de proves que comproven l'obvi i no aporta confiança. Persegueix les branques d'error no exercitades.
  • Consell d'or: cada vegada que una fallada arribi a producció, escriu primer la prova que la reprodueix i després arregla-la. La suite creix exactament per on el projecte falla de veritat.

Exercicis

Exercici 1. Escriu la prova d'integració de la història H3: en visitar /bicicletas/bici-999, l'aplicació ha de mostrar un 404 propi amb un enllaç de tornada al catàleg, i no una pantalla en blanc ni la traça de l'error.

Exercici 2. Escriu la prova d'extrem a extrem de la història H7: l'operari usr-02 entra a /taller, envia bici-001 a manteniment, i la bicicleta apareix «Al taller» al catàleg públic sense recarregar la pàgina.

Exercici 3. Cobreix un dels forats importants de l'informe de cobertura: la branca de temps d'espera exhaurit de src/api/cliente.js. Prova que, quan la petició triga més del límit, el hook exposa un error amb un missatge comprensible i la interfície ofereix reintentar.

Solucions

Solució 1.

// src/pagines/PaginaFitxaBicicleta.test.jsx
import { describe, test, expect } from 'vitest';
import { renderitzar, screen } from '../proves/utilitats.jsx';
import PaginaFitxaBicicleta from './PaginaFitxaBicicleta.jsx';
import PaginaCataleg from './PaginaCataleg.jsx';

describe('PaginaFitxaBicicleta', () => {
  test('un identificador inexistent mostra un 404 propi amb sortida', async () => {
    const { usuari, enrutador } = renderitzar(null, {
      ruta: '/bicicletas/bici-999',
      rutes: [
        { path: '/', element: <PaginaCataleg /> },
        { path: '/bicicletas/:bicicletaId', element: <PaginaFitxaBicicleta /> },
      ],
    });

    // Missatge propi, en un encapçalament: és una pantalla, no un avís perdut
    expect(await screen.findByRole('heading', { name: /no hem trobat aquesta bicicleta/i })).toBeInTheDocument();
    // I no es filtra informació tècnica a l'usuari
    expect(screen.queryByText(/404/)).not.toBeInTheDocument();
    expect(screen.queryByText(/HttpError/)).not.toBeInTheDocument();

    await usuari.click(screen.getByRole('link', { name: /tornar al catàleg/i }));
    expect(enrutador.state.location.pathname).toBe('/');
  });

  test('un identificador vàlid mostra la fitxa', async () => {
    renderitzar(<PaginaFitxaBicicleta />, {
      ruta: '/bicicletas/bici-001',
      rutes: [{ path: '/bicicletas/:bicicletaId', element: <PaginaFitxaBicicleta /> }],
    });

    expect(await screen.findByRole('heading', { name: 'Urbana Clàssica' })).toBeInTheDocument();
    expect(screen.getByText('Plaça Major')).toBeInTheDocument();
  });
});

Solució 2.

// cypress/e2e/taller.cy.js
describe('l\'operari canvia l\'estat d\'una bicicleta', () => {
  beforeEach(() => {
    cy.sembrarDades();
    cy.accedirCom('usr-02');   // operari
  });

  it('envia una bicicleta a manteniment i es reflecteix al catàleg', () => {
    cy.intercept('PATCH', '**/bicicletas/bici-001').as('canviarEstat');
    cy.intercept('GET', '**/bicicletas*').as('cataleg');

    cy.visit('/taller');
    cy.findByRole('row', { name: /Urbana Clàssica/ })
      .findByRole('button', { name: /envia a taller/i })
      .click();

    cy.wait('@canviarEstat').its('request.body').should('deep.include', { estado: 'mantenimiento' });

    // La invalidació ha de portar el catàleg fresc sense recarregar la pàgina
    cy.findByRole('link', { name: /catàleg/i }).click();
    cy.wait('@cataleg');
    cy.targetaDe('bici-001').should('contain.text', 'Al taller');
    cy.targetaDe('bici-001').findByRole('button', { name: /reservar/i }).should('not.exist');
  });

  it('un client no arriba ni a veure el panell', () => {
    cy.accedirCom('usr-01');
    cy.visit('/taller');
    cy.findByRole('heading', { name: /no tens permís/i }).should('exist');
    cy.perTestId('menu-usuario').should('not.contain.text', 'Taller');
  });
});

Solució 3.

// src/consultes/useBicicletas.test.jsx
import { describe, test, expect, vi, afterEach } from 'vitest';
import { http } from 'msw';
import { delay } from 'msw';
import { renderitzar, screen } from '../proves/utilitats.jsx';
import { servidor } from '../proves/servidor.js';
import PaginaCataleg from '../pagines/PaginaCataleg.jsx';

const API = 'http://localhost:3001';

describe('temps d\'espera del client d\'API', () => {
  afterEach(() => vi.useRealTimers());

  test('si la petició supera el límit, es mostra un error comprensible i es pot reintentar', async () => {
    // El gestor mai no respon a temps: l'AbortController del client ha de tallar
    servidor.use(
      http.get(`${API}/bicicletas`, async () => {
        await delay('infinite');
      })
    );

    vi.useFakeTimers({ shouldAdvanceTime: true });
    const { usuari } = renderitzar(<PaginaCataleg />);

    expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();

    // S'avança més enllà del límit configurat a cliente.js (8 s)
    await vi.advanceTimersByTimeAsync(8500);

    const avis = await screen.findByRole('alert');
    // Missatge per a persones, no el nom de l'excepció
    expect(avis).toHaveTextContent(/està trigant massa/i);
    expect(avis).not.toHaveTextContent(/AbortError/);
    expect(screen.getByRole('button', { name: /reintentar/i })).toBeInTheDocument();

    // I el reintent torna a la via normal
    vi.useRealTimers();
    servidor.resetHandlers();
    await usuari.click(screen.getByRole('button', { name: /reintentar/i }));
    expect(await screen.findAllByTestId('tarjeta-bicicleta')).toHaveLength(5);
  });
});

Conclusió

CicloUrbano ja no depèn que algú recordi vint passos. Té un pla de proves que assigna cada història d'usuari al nivell més barat que detecta la seva fallada, i que diu per escrit què s'ha decidit no provar. Té proves unitàries sobre les regles de negoci —validarReserva amb els seus extrems i la seva acumulació d'errors, el reductor de reserves amb la comprovació que Immer no muta, els hooks amb el temps sota control—. Té proves de component que comproven el que veu i fa una persona, recolzant-se en els rols i les etiquetes que es van posar per accessibilitat. Té proves d'integració que munten pàgines senceres contra una xarxa simulada i cobreixen els tres estats que l'usuari pot arribar a veure: esquelet, contingut i error amb reintent. Té tres fluxos d'extrem a extrem que recorren l'aplicació real en un navegador real, amb el cy.reload() que distingeix una escriptura de veritat d'una il·lusió optimista. I té tot això executant-se sol a cada canvi, amb captures i vídeo quan alguna cosa falla.

La regressió guiada va deixar la lliçó en una taula: el nivell que caça les fallades que aquest projecte produeix de veritat —memòria cau sense invalidar, estats de càrrega mal encadenats, permisos que es colen— és el d'integració, i aquí és on convé invertir. Esborrar una línia d'invalidateQueries passa desapercebut per al linter, per a les unitàries i per a les de component; i no passa desapercebut per a les dues proves que munten l'aplicació de veritat.

Queda un últim pas, i és el que converteix tot aquest treball en alguna cosa que algú pugui usar. L'aplicació funciona a localhost, amb una API que és un fitxer JSON i un servidor de desenvolupament que ningú hauria d'exposar a internet. Desplegament a Producció i Següents Passos tanca el projecte i el curs: la construcció per a producció i la seva revisió de mida, les variables d'entorn i què és realment públic en una aplicació de client, el problema de l'enrutament en client que fa que /reservas retorni un 404 en un servidor estàtic i la seva solució a cada plataforma, el desplegament pas a pas, què faria falta per tenir una API de veritat al darrere, la monitorització després del llançament i el full de ruta per continuar a partir d'aquí.

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