L'entorn ja respira: Vitest troba els fitxers, jsdom dona un DOM i els matchers de jest-dom estan registrats. Toca escriure proves de debò, i aquesta lliçó ho fa deliberadament fora de React. No hi ha ni un render en tot el text. El motiu és doble: primer, perquè les eines de l'executor —describe, els ganxos, les assercions, els dobles de prova, els temporitzadors falsos— s'entenen molt millor sense el soroll d'un arbre de components; i segon, perquè la part més rendible de CicloUrbano en relació cost-benefici és precisament la que no necessita React. validarReserva codifica totes les regles de negoci de les reserves. El reductor sliceReserves codifica el cicle de vida activa → confirmada / cancelada. Els selectors deriven el catàleg visible. Les tres coses són funcions pures, i provar una funció pura és cridar-la i comparar. Això és el que es va prometre a 05-05 i a 07-04, i aquesta lliçó ho cobra.

El títol diu «amb Jest», i hi ha una raó per respectar-ho: Jest és el model mental de l'ecosistema. Tot el que aprenguis aquí s'escriu amb Vitest, que és el que correspon a un projecte Vite, però la seva API és la mateixa, així que serveix exactament igual en els milers de projectes que usen Jest. Començarem deixant clara aquesta equivalència.

Contingut

  1. Què és un executor de proves i què fa per tu
  2. Jest i Vitest: el mateix model, dues implementacions
  3. Configurar Jest en un projecte que no usa Vite
  4. Anatomia d'un fitxer de proves: describe, test i els ganxos
  5. Assercions: el catàleg de matchers
  6. toBe enfront de toEqual: la diferència que més errors causa
  7. La suite de validarReserva, regla per regla
  8. Casos límit i test.each
  9. Provar el reductor sliceReserves com a funció pura
  10. Provar els selectors
  11. Dobles de prova: espies, stubs i mocks
  12. vi.fn(): comprovar que s'ha cridat un callback
  13. vi.spyOn i vi.mock: substituir sense trencar
  14. Control del temps: vi.useFakeTimers
  15. Executar, filtrar i llegir la cobertura

  1. Què és un executor de proves i què fa per tu

Un executor de proves (test runner) és un programa que automatitza tot el que envolta una prova. Sense ell, hauries d'escriure a mà el bucle que crida cada funció, el try/catch que captura els errors i el console.log que resumeix el resultat. Amb ell, escrius només la part que aporta valor.

El que fa un executor modern, per ordre d'execució:

Responsabilitat Què significa a la pràctica
Descobrir Troba els fitxers que coincideixen amb un patró (*.test.js, *.spec.jsx) sense que els registris
Transformar Converteix JSX, mòduls ES i sintaxi moderna en alguna cosa que l'entorn pugui executar
Aïllar Cada fitxer s'executa en el seu propi context de mòduls, perquè un no contamini l'altre
Executar en paral·lel Reparteix els fitxers entre diversos processos i aprofita tots els nuclis
Proporcionar l'API describe, test, expect, els ganxos, els dobles de prova, els temporitzadors falsos
Informar Presenta què ha passat i què ha fallat, amb la diferència exacta entre l'esperat i l'obtingut
Vigilar En mode vigilància, detecta quins fitxers han canviat i reexecuta només l'afectat
Mesurar Instrumenta el codi per calcular la cobertura

Aquesta última columna de l'«informar» és la que més es nota en depurar. Quan falla una asserció sobre objectes, un bon executor no diu «no són iguals»: diu quin camp difereix.

  1. Jest i Vitest: el mateix model, dues implementacions

Jest va néixer a Facebook i es va convertir en l'estàndard de facto de les proves en JavaScript: durant anys, «provar en React» i «provar amb Jest» eren la mateixa frase. Create React App el portava preconfigurat, i la major part de la documentació, de les respostes de Stack Overflow i del codi heretat que et trobaràs està escrita amb la seva API.

Vitest va aparèixer després, per a projectes construïts amb Vite, i va prendre una decisió de disseny molt deliberada: replicar l'API de Jest. No és un dialecte semblant; és la mateixa API, amb el mateix comportament, perquè migrar costi gairebé res i perquè tot el que sap l'ecosistema continuï servint.

Per què a CicloUrbano s'usa Vitest i no Jest:

  • Comparteix la configuració de Vite. Els àlies, els complements, les variables d'entorn i la resolució de mòduls ja estan definits a vite.config.js. Amb Jest caldria duplicar-ho tot i mantenir les dues còpies sincronitzades.
  • Entén els mòduls ES de manera nativa. Tot el projecte usa import/export. Jest es recolza en CommonJS i necessita transformació o senyaladors experimentals per als mòduls ES.
  • Transforma amb esbuild. Arrencada en desenes de mil·lisegons enfront de segons, i mode vigilància gairebé instantani.
  • No hi ha una segona cadena de compilació. El que es prova es transforma igual que el que s'executa en desenvolupament. S'elimina tota una família de fallades del tipus «funciona a l'aplicació i no a les proves».

Taula d'equivalències

Concepte Jest Vitest Difereix?
Agrupar describe describe No
Declarar prova test / it test / it No
Asserció expect(x).toBe(y) expect(x).toBe(y) No
Matchers toEqual, toContain, toThrow Els mateixos No
Ganxos beforeEach, afterAll Els mateixos No
Objecte d'utilitats jest vi Sí: el nom
Funció simulada jest.fn() vi.fn() Només el prefix
Espiar un mètode jest.spyOn(obj, 'm') vi.spyOn(obj, 'm') Només el prefix
Simular un mòdul jest.mock('./m') vi.mock('./m') El prefix i que a Vitest no hi ha elevació automàtica de les variables: usa fàbriques
Temporitzadors falsos jest.useFakeTimers() vi.useFakeTimers() Només el prefix
Avançar el rellotge jest.advanceTimersByTime(400) vi.advanceTimersByTime(400) Només el prefix
Restaurar jest.restoreAllMocks() vi.restoreAllMocks() Només el prefix
Configuració jest.config.js Bloc test de vite.config.js
Transformació Babel o ts-jest esbuild, via Vite
Mòduls ES Suport parcial, requereix ajustos Natiu
Entorn DOM testEnvironment: 'jsdom' environment: 'jsdom' Només el nom de la clau
Velocitat d'arrencada Segons Desenes de mil·lisegons
Globals sense importar Per defecte Requereix globals: true

Les diferències de veritat es redueixen a tres: el prefix vi en lloc de jest, on viu la configuració i com es transforma el codi. El 95 % del contingut d'un fitxer de proves és idèntic. De fet, amb globals: true a la configuració, un fitxer de proves escrit per a Jest sol executar-se a Vitest sense tocar ni una línia.

Al projecte s'usarà sempre vi. Quan llegeixis codi amb jest.fn(), sabràs que és exactament el mateix.

  1. Configurar Jest en un projecte que no usa Vite

Aquesta és la recepta per a quan et toqui un projecte amb Webpack, amb Create React App heretat o sense empaquetador modern. No s'usa a CicloUrbano, però convé saber llegir-la.

npm install -D jest jest-environment-jsdom @babel/preset-env @babel/preset-react babel-jest \
               @testing-library/react @testing-library/jest-dom @testing-library/user-event \
               identity-obj-proxy
// jest.config.js
export default {
  // 1) El DOM en memòria: a Jest 28+ és un paquet a part
  testEnvironment: 'jest-environment-jsdom',

  // 2) Equivalent a setupFiles de Vitest
  setupFilesAfterEnv: ['<rootDir>/src/proves/configuracio.js'],

  // 3) Jest NO entén CSS ni imatges: cal substituir-los
  moduleNameMapper: {
    '\\.(css|less|scss)$': 'identity-obj-proxy',
    '\\.(jpg|png|svg|webp)$': '<rootDir>/src/proves/fitxerSimulat.js',
    '^@/(.*)$': '<rootDir>/src/$1'          // l'àlies que a Vite ja estava resolt
  },

  // 4) Quins fitxers són proves
  testMatch: ['**/*.test.{js,jsx}'],

  // 5) Cobertura
  collectCoverageFrom: ['src/**/*.{js,jsx}', '!src/proves/**', '!src/main.jsx']
};
// babel.config.js — Jest necessita transformar JSX i mòduls ES
export default {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' } }],
    ['@babel/preset-react', { runtime: 'automatic' }]
  ]
};
// src/proves/configuracio.js — a Jest, sense el sufix /vitest
import '@testing-library/jest-dom';

Fixa't en la feina extra que apareix i que a Vitest simplement no existeix: un moduleNameMapper per al CSS i les imatges, una configuració de Babel, un paquet a part per a l'entorn DOM i la duplicació dels àlies. Això és exactament el que s'estalvia en compartir la configuració de Vite, i és l'argument pràctic de l'apartat anterior.

  1. Anatomia d'un fitxer de proves: describe, test i els ganxos

// src/utilitats/exemple.test.js
import { describe, test, expect, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';

describe('grup exterior', () => {
  beforeAll(() => console.log('1 · beforeAll exterior'));
  beforeEach(() => console.log('3 · beforeEach exterior'));
  afterEach(() => console.log('5 · afterEach exterior'));
  afterAll(() => console.log('7 · afterAll exterior'));

  describe('grup interior', () => {
    beforeAll(() => console.log('2 · beforeAll interior'));
    beforeEach(() => console.log('4 · beforeEach interior'));
    afterEach(() => console.log('6 · afterEach interior'));

    test('la prova', () => {
      expect(true).toBe(true);
    });
  });
});

Amb globals: true a la configuració, aquesta primera línia d'importació és opcional. En aquest curs s'ometrà per no repetir-la a cada exemple.

Element Quan s'executa Per a què serveix
describe(nom, fn) En carregar el fitxer (agrupa, no aïlla) Organitzar per subjecte i compartir ganxos
test(nom, fn) / it(...) Una vegada per prova La prova en si. test i it són àlies exactes
beforeAll(fn) Una vegada, abans de la primera prova del bloc Preparar alguna cosa cara i compartible: arrencar un servidor
beforeEach(fn) Abans de cada prova del bloc Reconstruir l'escenari net de cada prova
afterEach(fn) Després de cada prova Netejar: restaurar espies, buidar localStorage
afterAll(fn) Una vegada, després de l'última prova del bloc Tancar el que es va obrir a beforeAll

L'ordre d'execució és el que marquen els números de l'exemple, i té la seva lògica: els before van de fora cap a dins i els after de dins cap a fora, com una pila. És a dir, beforeAll exterior → beforeAll interior → beforeEach exterior → beforeEach interior → la provaafterEach interior → afterEach exterior → afterAll interior → afterAll exterior.

Conseqüències pràctiques:

  • Prefereix beforeEach a beforeAll. beforeAll crea estat compartit, que és la causa número u de proves que només fallen en executar la suite completa (el problema 8.3 de la lliçó anterior). Només s'usa per al que és car i de només lectura.
  • El codi dins d'un describe però fora d'un ganxo s'executa en carregar el fitxer, no abans de cada prova. Aquesta és una fallada clàssica:
// ❌ L'objecte es crea UNA vegada i les proves se'l passen mutat entre elles
describe('sliceReserves', () => {
  const estat = { entitats: {}, ids: [] };   // compartit!
  test('a', () => { estat.ids.push('res-01'); /* … */ });
  test('b', () => { /* aquí estat.ids ja té 'res-01' */ });
});

// ✅ Escenari nou per prova
describe('sliceReserves', () => {
  let estat;
  beforeEach(() => { estat = { entitats: {}, ids: [] }; });
  // …
});
  • Els ganxos poden ser asíncrons. Si retornen una promesa, l'executor l'espera. S'aprofitarà a 09-04 per arrencar el servidor de MSW.

  1. Assercions: el catàleg de matchers

Una asserció és expect(valorObtingut).matcher(valorEsperat). Si la comprovació falla, es llança un error amb la diferència i la prova es marca en vermell.

Matcher Comprova Exemple a CicloUrbano
toBe(x) Igualtat per identitat (Object.is) expect(reserva.estat).toBe('activa')
toEqual(x) Igualtat estructural recursiva; ignora undefined expect(errors).toEqual({ hores: '…' })
toStrictEqual(x) Com toEqual però distingeix undefined, forats d'array i la classe de l'objecte expect(reserva).toStrictEqual(new Reserva(...))
toContain(x) Un array conté l'element (per identitat) o una cadena conté la subcadena expect(errors.bicicletaId).toContain('no està disponible')
toContainEqual(x) Un array conté un element estructuralment igual expect(reserves).toContainEqual({ id: 'res-01', … })
toHaveLength(n) .length d'arrays i cadenes expect(estat.ids).toHaveLength(2)
toHaveProperty(ruta, v) Existeix la propietat, opcionalment amb aquest valor expect(errors).toHaveProperty('hores')
toMatchObject(x) L'objecte conté almenys aquestes propietats expect(reserva).toMatchObject({ estat: 'activa' })
toThrow(x) La funció llança; opcionalment amb aquest missatge o classe expect(() => useTema()).toThrow('dins de <ProveidorTema>')
toBeCloseTo(n, d) Nombres decimals amb tolerància expect(total).toBeCloseTo(5.0, 2)
toBeTruthy() / toBeFalsy() Veracitat, no valor exacte expect(esValid).toBeTruthy()
toBeNull() / toBeUndefined() / toBeDefined() Valors concrets expect(estat.error).toBeNull()
toBeGreaterThan(n) / toBeLessThan(n) Comparacions numèriques expect(bicicleta.preuHora).toBeGreaterThan(0)
toMatch(regex) Una cadena coincideix amb l'expressió regular expect(reserva.id).toMatch(/^res-[a-z0-9]{8}$/)
toHaveBeenCalled() Una funció simulada s'ha cridat Apartat 12
toHaveBeenCalledWith(...) S'ha cridat amb aquests arguments Apartat 12
toHaveBeenCalledTimes(n) S'ha cridat exactament n vegades Apartat 12

La negació amb .not

Qualsevol matcher s'inverteix anteposant .not:

expect(errors).not.toHaveProperty('dataInici');    // NO hi ha error de data
expect(estat.ids).not.toContain('res-99');
expect(alReservar).not.toHaveBeenCalled();

Un avís sobre .not, perquè és un parany recurrent: una asserció negativa demostra menys del que sembla. expect(errors).not.toHaveProperty('hores') passa tant si la validació és correcta com si validarReserva retorna {} perquè s'ha trencat sencera. Sempre que puguis, acompanya una asserció negativa d'una de positiva:

// ✅ La positiva subjecta la negativa
expect(errors).toEqual({ dataInici: 'La reserva no pot començar en el passat.' });

Aquesta asserció única diu alhora que hi ha error de data i que no n'hi ha cap altre, que és exactament el que es vol afirmar.

  1. toBe enfront de toEqual: la diferència que més errors causa

const a = { id: 'bici-001', model: 'Urbana Clàssica' };
const b = { id: 'bici-001', model: 'Urbana Clàssica' };

expect(a).toBe(b);      // ❌ FALLA: són dos objectes diferents en memòria
expect(a).toEqual(b);   // ✅ PASSA: tenen el mateix contingut
expect(a).toBe(a);      // ✅ PASSA: és literalment el mateix objecte
  • toBe usa Object.is, la comparació per identitat. Per a primitius —cadenes, nombres, booleans— identitat i contingut coincideixen, així que expect('activa').toBe('activa') funciona. Per a objectes i arrays, compara referències.
  • toEqual recorre l'estructura i compara clau a clau, recursivament.

Aquesta distinció no és un detall acadèmic: és exactament la mateixa comparació per identitat que governa memo i useMemo al mòdul 8, i la que fa que un selector que retorna un array nou repinti el component a 07-05. La regla mnemotècnica: toBe per a primitius, toEqual per a objectes i arrays.

I una diferència entre toEqual i toStrictEqual que convé tenir present:

expect({ id: 'res-01', cancelladaEn: undefined }).toEqual({ id: 'res-01' });        // ✅ PASSA
expect({ id: 'res-01', cancelladaEn: undefined }).toStrictEqual({ id: 'res-01' });  // ❌ FALLA

toEqual ignora les propietats amb valor undefined; toStrictEqual no, i a més comprova que tots dos objectes siguin de la mateixa classe. A CicloUrbano toEqual és l'habitual, perquè un camp opcional absent i un camp opcional a undefined són el mateix per a l'aplicació. Usa toStrictEqual quan l'absència d'una clau sigui significativa.

  1. La suite de validarReserva, regla per regla

Aquí hi ha el cas central de la lliçó. Recordem la funció que es va escriure a 03-05: rep dades i el catàleg de bicicletes, i retorna un objecte amb un missatge per cada camp amb error, o {} si tot és vàlid. Quatre regles: bicicleta obligatòria, existent i disponible; data obligatòria, vàlida i no anterior a ara; hores enter entre 1 i 24; condicions acceptades.

// src/utilitats/validarReserva.test.js
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
import { validarReserva } from './validarReserva.js';

// PREPARAR: un catàleg mínim però representatiu, amb els tres estats possibles
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 }
];

// Fàbrica de dades vàlides: cada prova parteix d'aquí i espatlla NOMÉS un camp.
// És el patró que manté llegibles les suites de validació.
function dadesValides(canvis = {}) {
  return {
    bicicletaId: 'bici-001',
    dataInici: '2026-05-04T09:00',
    hores: 2,
    condicions: true,
    ...canvis
  };
}

describe('validarReserva', () => {
  // El rellotge es congela: la regla "no pot començar en el passat" depèn de Date.now()
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-05-04T08:00:00'));
  });

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

  test('retorna un objecte buit quan totes les dades són correctes', () => {
    expect(validarReserva(dadesValides(), BICICLETES)).toEqual({});
  });
});

Dues decisions de disseny que valen per a qualsevol suite de validació:

  • La fàbrica dadesValides(canvis). Sense ella, cada prova repetiria els quatre camps i el lector hauria de comparar-los visualment per saber quin és el que es prova. Amb ella, dadesValides({ hores: 25 }) diu per si sola què es comprova. A més, si demà el formulari afegeix un cinquè camp, es canvia en un sol lloc.
  • El rellotge congelat a beforeEach. La regla de la data compara amb Date.now(). Si la prova usés el rellotge real, la data '2026-05-04T09:00' seria vàlida avui i invàlida d'aquí a un any: una prova amb data de caducitat, que és un cas de llibre de prova inestable. Congelant el sistema a 2026-05-04T08:00, la prova donarà el mateix resultat d'aquí a una dècada.

Les regles de la bicicleta

describe('bicicleta', () => {
  test('exigeix triar una bicicleta', () => {
    const errors = validarReserva(dadesValides({ bicicletaId: '' }), BICICLETES);
    expect(errors).toEqual({ bicicletaId: 'Tria una bicicleta.' });
  });

  test('rebutja una bicicleta que no és al catàleg', () => {
    const errors = validarReserva(dadesValides({ bicicletaId: 'bici-999' }), BICICLETES);
    expect(errors.bicicletaId).toBe('La bicicleta seleccionada no existeix.');
  });

  test('rebutja una bicicleta llogada i inclou el seu model al missatge', () => {
    const errors = validarReserva(dadesValides({ bicicletaId: 'bici-002' }), BICICLETES);
    expect(errors.bicicletaId).toBe('Elèctrica Pro no està disponible ara mateix.');
  });

  test('rebutja una bicicleta en manteniment', () => {
    const errors = validarReserva(dadesValides({ bicicletaId: 'bici-003' }), BICICLETES);
    expect(errors.bicicletaId).toContain('no està disponible');
  });

  test('rebutja qualsevol bicicleta si el catàleg arriba buit', () => {
    // El valor per defecte és [], i aquest camí també cal recórrer-lo
    expect(validarReserva(dadesValides()).bicicletaId).toBe('La bicicleta seleccionada no existeix.');
  });
});

Fixa't en la barreja deliberada de matchers, perquè cadascun diu una cosa diferent:

  • toEqual({ bicicletaId: '…' }) a la primera afirma que aquest és l'únic error. És l'asserció més forta i la que detectaria una regressió que fes fallar també la data.
  • toBe('Elèctrica Pro no està disponible ara mateix.') comprova el missatge literal, inclosa la interpolació del model. És correcte aquí perquè aquest text el llegeix l'usuari i forma part del comportament.
  • toContain('no està disponible') és més laxa a propòsit: comprova la regla sense lligar-se a la redacció exacta. Fes-la servir quan el text pugui canviar per motius editorials sense que canviï el comportament.

Les regles de la data, les hores i les condicions

describe('data d\'inici', () => {
  test('exigeix una data d\'inici', () => {
    expect(validarReserva(dadesValides({ dataInici: '' }), BICICLETES))
      .toEqual({ dataInici: 'Indica quan comença la reserva.' });
  });

  test('rebutja una data amb format no vàlid', () => {
    expect(validarReserva(dadesValides({ dataInici: 'demà' }), BICICLETES).dataInici)
      .toBe('La data no té un format vàlid.');
  });

  test('rebutja una data anterior al moment actual', () => {
    // Rellotge congelat a les 08:00; la reserva demana les 07:59
    expect(validarReserva(dadesValides({ dataInici: '2026-05-04T07:59' }), BICICLETES).dataInici)
      .toBe('La reserva no pot començar en el passat.');
  });

  test('accepta una data al mateix minut en què som', () => {
    // 08:00:00 no és MENOR que 08:00:00, així que és vàlida: la frontera exacta
    expect(validarReserva(dadesValides({ dataInici: '2026-05-04T08:00' }), BICICLETES))
      .toEqual({});
  });
});

describe('condicions d\'ús', () => {
  test('exigeix acceptar les condicions', () => {
    expect(validarReserva(dadesValides({ condicions: false }), BICICLETES))
      .toEqual({ condicions: 'Has d\'acceptar les condicions d\'ús.' });
  });
});

describe('acumulació d\'errors', () => {
  test('retorna tots els errors alhora, no només el primer', () => {
    const errors = validarReserva(
      { bicicletaId: '', dataInici: '', hores: '', condicions: false },
      BICICLETES
    );
    expect(Object.keys(errors)).toHaveLength(4);
    expect(errors).toEqual({
      bicicletaId: 'Tria una bicicleta.',
      dataInici: 'Indica quan comença la reserva.',
      hores: 'Indica quantes hores vols la bicicleta.',
      condicions: 'Has d\'acceptar les condicions d\'ús.'
    });
  });
});

Aquesta última prova és més valuosa del que sembla. Verifica una decisió de disseny explícita de 03-05: la validació no talla en el primer error, perquè un formulari que ensenya els errors d'un en un obliga l'usuari a quatre intents. Si algú refactoritza validarReserva amb return prematurs, aquesta prova ho enxampa.

  1. Casos límit i test.each

Els casos límit són els valors que estan just a la frontera d'una regla, i són on viu la majoria de les fallades reals. Per a hores, la regla és «enter entre 1 i 24». Les fronteres són 0/1 i 24/25.

Escriure vuit proves gairebé idèntiques és soroll. test.each rep una taula i genera una prova per fila:

describe('durada en hores', () => {
  test.each([
    // valor,   és vàlid?,  fragment esperat del missatge
    [0,     false, 'La reserva mínima és d\'1 hora.'],
    [1,     true,  null],
    [2,     true,  null],
    [24,    true,  null],
    [25,    false, 'La reserva màxima és de 24 hores.'],
    [-3,    false, 'La reserva mínima és d\'1 hora.'],
    [2.5,   false, 'Les hores han de ser un nombre enter.'],
    ['',    false, 'Indica quantes hores vols la bicicleta.'],
    ['dos', false, 'Indica quantes hores vols la bicicleta.']
  ])('amb hores = %p l\'error és %p', (hores, esValid, missatge) => {
    const errors = validarReserva(dadesValides({ hores }), BICICLETES);

    if (esValid) {
      expect(errors).not.toHaveProperty('hores');
    } else {
      expect(errors.hores).toBe(missatge);
    }
  });
});

Com funciona:

  • La taula és un array d'arrays. Cada fila es desestructura en els paràmetres de la funció de prova.
  • El nom admet marcadors a l'estil printf: %p imprimeix el valor tal qual (útil per distingir '' de 'dos'), %s el converteix a cadena, %i a enter. A l'informe apareixen nou proves amb noms diferents, així que una fallada assenyala la fila exacta.
  • També existeix la variant amb plantilles etiquetades, més llegible quan hi ha moltes columnes:
test.each`
  hores   | esperat
  ${0}    | ${'La reserva mínima és d\'1 hora.'}
  ${25}   | ${'La reserva màxima és de 24 hores.'}
  ${2.5}  | ${'Les hores han de ser un nombre enter.'}
`('amb hores = $hores retorna "$esperat"', ({ hores, esperat }) => {
  expect(validarReserva(dadesValides({ hores }), BICICLETES).hores).toBe(esperat);
});

Què revela un cas límit mal cobert

Mira la fila del 2.5. Sense ella, aquesta implementació alternativa de la regla passaria totes les altres proves:

// Implementació amb una fallada que només detecta el cas límit decimal
if (hores < 1)       errors.hores = 'La reserva mínima és d\'1 hora.';
else if (hores > 24) errors.hores = 'La reserva màxima és de 24 hores.';
// ⚠️ Falta la comprovació d'enter: 2.5 passa com a vàlid

Amb 0, 1, 24 i 25 la suite estaria verda, i tanmateix l'usuari podria reservar durant dues hores i mitja, una cosa que el preu per hora i el sistema d'estacions no contemplen. Aquest és l'argument dels casos límit en una frase: les proves dels valors «normals» confirmen el que ja sabies; les dels marges troben el que no sabies.

La llista de marges que convé recórrer sempre: el zero, el valor mínim, el màxim, el màxim més u, el negatiu, el decimal quan s'espera un enter, la cadena buida, null, undefined, i la col·lecció buida.

  1. Provar el reductor sliceReserves com a funció pura

Aquí es tanca el que va quedar pendent a 05-05 i es va prometre de nou a 07-04: un reductor és una funció pura (estat, accio) => nouEstat, així que provar-lo és cridar-lo i comparar. No cal React, ni un magatzem, ni un component, ni Provider.

Recorda la forma de l'estat: { entitats, ids, estatCarrega, error, estatEnviament }, normalitzada.

// src/funcionalitats/reserves/sliceReserves.test.js
import { describe, test, expect, beforeEach, vi } from 'vitest';
import reductorReserves, {
  reservaCreada, reservaConfirmada, reservaCancellada, enviamentIniciat, enviamentFallit
} from './sliceReserves.js';

const ESTAT_BUIT = {
  entitats: {},
  ids: [],
  estatCarrega: 'inactiu',
  error: null,
  estatEnviament: 'inactiu'
};

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

// Un estat ja poblat, per provar transicions
const ESTAT_AMB_RESERVA = {
  ...ESTAT_BUIT,
  entitats: { 'res-01': RESERVA_01 },
  ids: ['res-01']
};

describe('reductor sliceReserves', () => {
  test('retorna l\'estat inicial davant d\'una acció desconeguda', () => {
    const resultat = reductorReserves(undefined, { type: 'accio/inexistent' });
    expect(resultat).toEqual(ESTAT_BUIT);
  });

  test('no modifica l\'estat davant d\'una acció que no li correspon', () => {
    const resultat = reductorReserves(ESTAT_AMB_RESERVA, { type: 'cataleg/termeCanviat' });
    // toBe, no toEqual: comprovem que retorna LA MATEIXA referència
    expect(resultat).toBe(ESTAT_AMB_RESERVA);
  });
});

Aquesta segona prova usa toBe a propòsit, i és un bon exemple de quan la identitat és comportament observable: si el reductor retornés una còpia davant de cada acció aliena, tots els useSelector subscrits a reserves repintarien amb qualsevol acció de l'aplicació. És el problema de 07-05 convertit en prova.

El cicle de vida complet

describe('creació de reserves', () => {
  beforeEach(() => {
    // reservaCreada usa crypto.randomUUID i new Date: cal fixar tots dos
    vi.setSystemTime(new Date('2026-05-04T08:00:00'));
    vi.spyOn(crypto, 'randomUUID').mockReturnValue('abcd1234-0000-0000-0000-000000000000');
  });

  test('afegeix la reserva a entitats i el seu id al final d\'ids', () => {
    // L'acció es construeix amb el creador: `prepare` genera l'id i la data
    const accio = reservaCreada('bici-001', 'usr-01', '2026-05-04T10:00', 3);
    // I el reductor s'invoca com el que és: una funció de dos arguments
    const resultat = reductorReserves(ESTAT_BUIT, accio);

    expect(resultat.ids).toEqual(['res-abcd1234']);
    expect(resultat.entitats['res-abcd1234']).toMatchObject({
      bicicletaId: 'bici-001',
      usuari: 'usr-01',
      dataInici: '2026-05-04T10:00',
      hores: 3,
      estat: 'activa'
    });
    expect(resultat.estatEnviament).toBe('enviat');
    expect(resultat.error).toBeNull();
  });

  test('no muta l\'estat rebut', () => {
    const accio = reservaCreada('bici-001', 'usr-01', '2026-05-04T10:00', 3);
    reductorReserves(ESTAT_BUIT, accio);

    // L'estat d'entrada continua intacte: Immer retorna una còpia
    expect(ESTAT_BUIT.ids).toHaveLength(0);
    expect(ESTAT_BUIT.entitats).toEqual({});
  });
});

Tres coses que verifiquen aquestes proves i que no es poden verificar mirant la pantalla:

  1. La forma exacta de l'estat després de l'acció, inclosos estatEnviament i error, que a la interfície només es manifesten indirectament.
  2. Que prepare genera l'identificador amb el format acordat, res- més vuit caràcters. Per això s'espia crypto.randomUUID: sense fixar-lo, l'identificador seria diferent a cada execució i no hi hauria res a comparar.
  3. Que no hi ha mutació. Encara que escriguis estat.ids.push(...) dins del reductor, Immer —que Redux Toolkit inclou— produeix una còpia. Aquesta prova ho confirma i detectaria el dia que algú tregui aquesta lògica de createSlice i la mutació passi a ser real.

Les guardes de negoci

Les regles més valuoses del reductor són les guardes, perquè són invisibles i difícils de provocar a mà:

describe('confirmació i cancel·lació', () => {
  test('confirma una reserva activa', () => {
    const resultat = reductorReserves(ESTAT_AMB_RESERVA, reservaConfirmada('res-01'));
    expect(resultat.entitats['res-01'].estat).toBe('confirmada');
  });

  test('ignora la confirmació d\'una reserva que no existeix', () => {
    const resultat = reductorReserves(ESTAT_AMB_RESERVA, reservaConfirmada('res-99'));
    expect(resultat).toEqual(ESTAT_AMB_RESERVA);
  });

  test('no confirma una reserva ja cancel·lada', () => {
    const cancellada = {
      ...ESTAT_AMB_RESERVA,
      entitats: { 'res-01': { ...RESERVA_01, estat: 'cancelada' } }
    };
    const resultat = reductorReserves(cancellada, reservaConfirmada('res-01'));
    expect(resultat.entitats['res-01'].estat).toBe('cancelada');
  });

  test('cancel·la una reserva activa i guarda el moment de la cancel·lació', () => {
    vi.setSystemTime(new Date('2026-05-04T12:30:00'));
    const resultat = reductorReserves(ESTAT_AMB_RESERVA, reservaCancellada('res-01'));

    expect(resultat.entitats['res-01'].estat).toBe('cancelada');
    expect(resultat.entitats['res-01'].cancelladaEn).toBe('2026-05-04T12:30:00.000Z');
  });

  test('cancel·lar dues vegades no canvia el moment de la primera cancel·lació', () => {
    vi.setSystemTime(new Date('2026-05-04T12:30:00'));
    const unaVegada = reductorReserves(ESTAT_AMB_RESERVA, reservaCancellada('res-01'));

    vi.setSystemTime(new Date('2026-05-04T18:00:00'));
    const duesVegades = reductorReserves(unaVegada, reservaCancellada('res-01'));

    expect(duesVegades.entitats['res-01'].cancelladaEn).toBe('2026-05-04T12:30:00.000Z');
  });
});

Aquesta última prova és exactament el tipus de comprovació que justifica tot el mòdul: reproduir a mà una doble cancel·lació amb la latència adequada és tediós i poc fiable; en codi són sis línies i s'executa en dos mil·lisegons, per sempre.

Encadenar accions per provar una seqüència

test('recorre el cicle complet: crear, confirmar i cancel·lar', () => {
  vi.spyOn(crypto, 'randomUUID').mockReturnValue('abcd1234-0000-0000-0000-000000000000');

  // L'estat es va passant d'una crida a la següent: això és el magatzem, sense el magatzem
  let estat = reductorReserves(undefined, { type: '@@init' });
  estat = reductorReserves(estat, enviamentIniciat());
  expect(estat.estatEnviament).toBe('enviant');

  estat = reductorReserves(estat, reservaCreada('bici-001', 'usr-01', '2026-05-04T10:00', 2));
  expect(estat.estatEnviament).toBe('enviat');

  estat = reductorReserves(estat, reservaConfirmada('res-abcd1234'));
  expect(estat.entitats['res-abcd1234'].estat).toBe('confirmada');

  estat = reductorReserves(estat, reservaCancellada('res-abcd1234'));
  expect(estat.entitats['res-abcd1234'].estat).toBe('cancelada');
  expect(estat.ids).toEqual(['res-abcd1234']);   // cancel·lar no elimina, només marca
});
flowchart LR
    A["estat inicial"] -->|"enviamentIniciat()"| B["estatEnviament: enviant"]
    B -->|"reservaCreada(...)"| C["res-abcd1234 · activa"]
    C -->|"reservaConfirmada(id)"| D["confirmada"]
    C -->|"reservaCancellada(id)"| E["cancelada + cancelladaEn"]
    D -->|"reservaCancellada(id)"| E
    E -->|"reservaConfirmada(id)"| E2["sense canvis (guarda)"]

  1. Provar els selectors

Un selector és igual de pur: (estat) => valorDerivat. L'única particularitat és que els selectors del projecte reben l'estat global, no el tros del slice, així que cal construir-lo amb la forma que defineix magatzem.js: { reserves, cataleg, sessio }.

// src/funcionalitats/reserves/selectors.test.js
import { describe, test, expect } from 'vitest';
import {
  seleccionarIdsReserves, seleccionarReservaPerId, seleccionarEstatEnviament
} from './sliceReserves.js';
import { seleccionarReservesActives } from './selectorsReserves.js';

function estatGlobal(reserves) {
  return {
    reserves,
    cataleg: { terme: '', ordre: 'model', bicicletes: [] },
    sessio: { usuari: { id: 'usr-01', nom: 'Ana Ribera', rol: 'cliente' }, carregant: false }
  };
}

const ESTAT = estatGlobal({
  entitats: {
    'res-01': { id: 'res-01', bicicletaId: 'bici-002', usuari: 'usr-01', hores: 2, estat: 'activa' },
    'res-02': { id: 'res-02', bicicletaId: 'bici-005', usuari: 'usr-01', hores: 1, estat: 'cancelada' }
  },
  ids: ['res-01', 'res-02'],
  estatCarrega: 'correcte',
  error: null,
  estatEnviament: 'inactiu'
});

describe('selectors de reserves', () => {
  test('seleccionarIdsReserves retorna els ids en ordre', () => {
    expect(seleccionarIdsReserves(ESTAT)).toEqual(['res-01', 'res-02']);
  });

  test('seleccionarReservaPerId retorna la reserva demanada', () => {
    expect(seleccionarReservaPerId(ESTAT, 'res-01')).toMatchObject({ bicicletaId: 'bici-002' });
  });

  test('seleccionarReservaPerId retorna undefined si l\'id no existeix', () => {
    expect(seleccionarReservaPerId(ESTAT, 'res-99')).toBeUndefined();
  });

  test('seleccionarReservesActives filtra les cancel·lades', () => {
    const actives = seleccionarReservesActives(ESTAT);
    expect(actives).toHaveLength(1);
    expect(actives[0].id).toBe('res-01');
  });
});

Provar la memoïtzació d'un createSelector

Un selector memoïtzat té una propietat extra que sí que mereix prova, perquè és la raó de la seva existència: retornar la mateixa referència si les entrades no canvien. És el que evita els repintats de 07-05, i és exactament el que trencaria algú que el substituís per una funció normal.

test('seleccionarReservesActives retorna la MATEIXA referència si l\'estat no canvia', () => {
  const primera = seleccionarReservesActives(ESTAT);
  const segona = seleccionarReservesActives(ESTAT);

  expect(segona).toBe(primera);       // ← toBe: identitat, no contingut
});

test('recalcula quan canvien les reserves', () => {
  const primera = seleccionarReservesActives(ESTAT);

  const altreEstat = estatGlobal({
    ...ESTAT.reserves,
    entitats: { ...ESTAT.reserves.entitats, 'res-03': { id: 'res-03', estat: 'activa' } },
    ids: [...ESTAT.reserves.ids, 'res-03']
  });
  const segona = seleccionarReservesActives(altreEstat);

  expect(segona).not.toBe(primera);
  expect(segona).toHaveLength(2);
});

Un avís important: com que la memoïtzació de createSelector guarda un sol resultat, si intercales crides amb estats diferents, la memòria cau s'invalida a cadascuna. Si escrius aquesta prova i falla inesperadament, comprova que no hi ha una crida amb un altre estat entremig.

  1. Dobles de prova: espies, stubs i mocks

Un doble de prova (test double) és qualsevol objecte que substitueix una dependència real durant una prova, igual que un doble substitueix l'actor en una escena perillosa. Els noms s'usen de manera laxa en el dia a dia, però les distincions són útils:

Tipus Què fa Quan s'usa A Vitest
Espia (spy) Embolcalla la funció real i registra les crides, sense canviar el comportament Comprovar que alguna cosa s'ha cridat, conservant el que fa vi.spyOn(obj, 'metode')
Stub Substitueix la funció per una que retorna un valor fix Forçar una resposta concreta: un error, una llista buida vi.fn().mockReturnValue(x)
Mock Un stub que a més verifica com se l'ha cridat Comprovar la interacció, no només el resultat vi.fn() + toHaveBeenCalledWith
Fake Una implementació alternativa simplificada però funcional Substituir una base de dades per un objecte en memòria Una classe escrita a mà

La regla que governa tot això, i que es repetirà a 09-04 amb MSW:

Simula el mínim. Cada doble de prova és una còpia de la realitat que se'n pot desincronitzar. Una prova plena de simulacions acaba provant les simulacions.

  1. vi.fn(): comprovar que s'ha cridat un callback

vi.fn() crea una funció simulada que no fa res, retorna undefined i registra totes les crides. És l'eina bàsica per verificar els callbacks del projecte: la convenció alX de CicloUrbano —alReservar, alCanviarTipus, alCrearReserva— és precisament el que es comprova amb ella.

// src/utilitats/monitoritzacio.test.js
import { describe, test, expect, vi } from 'vitest';

describe('vi.fn en acció', () => {
  test('registra si s\'ha cridat, quantes vegades i amb què', () => {
    const alReservar = vi.fn();

    alReservar('bici-001');
    alReservar('bici-004');

    expect(alReservar).toHaveBeenCalled();
    expect(alReservar).toHaveBeenCalledTimes(2);
    expect(alReservar).toHaveBeenCalledWith('bici-001');
    expect(alReservar).toHaveBeenLastCalledWith('bici-004');
    expect(alReservar).toHaveBeenNthCalledWith(1, 'bici-001');

    // Accés directe al registre, útil per a assercions complexes
    expect(alReservar.mock.calls).toEqual([['bici-001'], ['bici-004']]);
  });

  test('pot retornar valors controlats', () => {
    const obtenirPreu = vi.fn()
      .mockReturnValueOnce(2.5)      // primera crida
      .mockReturnValueOnce(4.0)      // segona
      .mockReturnValue(0);           // la resta

    expect(obtenirPreu()).toBe(2.5);
    expect(obtenirPreu()).toBe(4.0);
    expect(obtenirPreu()).toBe(0);
  });

  test('pot simular una implementació completa', () => {
    const calcularTotal = vi.fn((preuHora, hores) => preuHora * hores);
    expect(calcularTotal(2.5, 4)).toBe(10);
    expect(calcularTotal).toHaveBeenCalledWith(2.5, 4);
  });
});
Mètode Per a què
mockReturnValue(v) Retorna sempre v
mockReturnValueOnce(v) Retorna v només la propera vegada; s'encadenen
mockResolvedValue(v) Retorna una promesa resolta amb v (funcions asíncrones)
mockRejectedValue(e) Retorna una promesa rebutjada amb e: així es proven els errors
mockImplementation(fn) Substitueix el cos per fn
mockClear() Buida el registre de crides, conserva la implementació
mockReset() Buida el registre i la implementació
mockRestore() Retorna la funció original (només per a vi.spyOn)

Sobre les assercions d'arguments: quan l'argument és un objecte, toHaveBeenCalledWith compara estructuralment, no per identitat. I si només t'interessa part de l'objecte, existeixen els comparadors asimètrics:

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

expect(alCrearReserva).toHaveBeenCalledWith(
  expect.objectContaining({ id: expect.stringMatching(/^res-/) })
);

Això últim és el correcte per a l'identificador generat amb crypto.randomUUID(): comprovar el format, no el valor, evita haver d'espiar la funció criptogràfica.

  1. vi.spyOn i vi.mock: substituir sense trencar

vi.spyOn: embolcallar un mètode existent

vi.spyOn(objecte, 'metode') substitueix aquest mètode per una funció simulada que, per defecte, continua cridant l'original. El cas més habitual en proves de React és silenciar console.error, i apareixerà una altra vegada a 09-04 en provar LimitError.

import { describe, test, expect, vi, afterEach } from 'vitest';
import { registrarError } from './monitoritzacio.js';

describe('registrarError', () => {
  afterEach(() => {
    vi.restoreAllMocks();     // imprescindible: retorna console.error al seu ser
  });

  test('escriu l\'error a la consola en desenvolupament', () => {
    // mockImplementation(() => {}) silencia la sortida: la consola no s'embruta
    const espia = vi.spyOn(console, 'error').mockImplementation(() => {});

    registrarError(new Error('Error en carregar la flota'), { component: 'PaginaCataleg' });

    expect(espia).toHaveBeenCalledTimes(1);
    expect(espia.mock.calls[0][0]).toContain('Error en carregar la flota');
  });
});

El vi.restoreAllMocks() a l'afterEach no és opcional. Si no restaures console.error, totes les proves posteriors del mateix procés queden mudes, inclosos els avisos legítims de React. Es pot automatitzar a la configuració global:

// vite.config.js, dins del bloc test
test: {
  restoreMocks: true,     // vi.restoreAllMocks() automàtic després de cada prova
  clearMocks: true        // vi.clearAllMocks() automàtic després de cada prova
}

vi.mock: substituir un mòdul sencer

Quan el que cal substituir és un mòdul complet —perquè fa peticions, escriu en un servei extern o depèn de l'entorn—, s'usa vi.mock:

// src/utilitats/registreReserves.test.js
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { registrarError } from './monitoritzacio.js';
import { guardarReserva } from './registreReserves.js';

// Substitueix TOT el mòdul monitoritzacio.js per funcions simulades.
// La crida s'eleva a l'inici del fitxer, abans dels imports,
// així que la fàbrica NO pot usar variables definides fora d'ella.
vi.mock('./monitoritzacio.js', () => ({
  registrarError: vi.fn(),
  registrarEsdeveniment: vi.fn()
}));

describe('guardarReserva', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  test('registra l\'error quan la reserva no és vàlida', () => {
    guardarReserva({ bicicletaId: '', hores: 0 });

    expect(registrarError).toHaveBeenCalledTimes(1);
    expect(registrarError).toHaveBeenCalledWith(
      expect.any(Error),
      expect.objectContaining({ origen: 'guardarReserva' })
    );
  });
});

Tres detalls que causen errors si s'ignoren:

  1. vi.mock s'eleva a l'inici del fitxer. Encara que l'escriguis després dels import, s'executa abans. Per això la fàbrica no pot tancar sobre variables externes: a dins es declaren els vi.fn() directament. Si necessites una referència fora, s'usa vi.hoisted.
  2. El mòdul simulat substitueix totes les seves exportacions. Si monitoritzacio.js exportés deu funcions i només en declares dues a la fàbrica, les altres vuit passen a ser undefined. Per conservar la resta, es combina amb importActual:
vi.mock('./monitoritzacio.js', async (importarOriginal) => {
  const original = await importarOriginal();
  return { ...original, registrarError: vi.fn() };   // només se'n substitueix una
});
  1. vi.mock és el martell més gran de la caixa. Substitueix el mòdul real per una còpia que no evoluciona amb ell: si demà registrarError canvia de signatura, la prova continua passant amb la signatura antiga. Per això la regla de l'apartat 11. En ordre de preferència: passar la dependència com a paràmetre > vi.spyOn sobre un mètode concret > vi.mock del mòdul sencer. I per a la xarxa, cap de les tres: MSW (09-04).

  1. Control del temps: vi.useFakeTimers

El temps és la font més gran de proves lentes i inestables. vi.useFakeTimers() substitueix setTimeout, setInterval, clearTimeout, Date i performance.now per implementacions controlades: el rellotge només avança quan tu li mans avançar.

Funció Què fa
vi.useFakeTimers() Activa els temporitzadors falsos
vi.useRealTimers() Els retorna a la normalitat. Sempre en un afterEach
vi.advanceTimersByTime(ms) Avança el rellotge ms mil·lisegons i executa el que vencés
vi.runAllTimers() Executa tots els temporitzadors pendents de cop
vi.runOnlyPendingTimers() Executa els pendents sense encadenar els que aquests programin
vi.setSystemTime(fecha) Fixa Date.now() i new Date() a una data concreta
vi.getTimerCount() Quants temporitzadors hi ha pendents

Provarem el mecanisme d'useDebounce a nivell de funció, sense React. El hook embolcalla un setTimeout que es cancel·la en la neteja; la lògica és aquesta, i és la que cal verificar:

// src/utilitats/retardar.js — el mecanisme de useDebounce extret com a funció
export function retardar(accio, retardMs = 400) {
  let identificador = null;

  function invocar(...args) {
    clearTimeout(identificador);                       // cancel·la el pendent
    identificador = setTimeout(() => accio(...args), retardMs);
  }

  invocar.cancelar = () => clearTimeout(identificador);
  return invocar;
}
// src/utilitats/retardar.test.js
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { retardar } from './retardar.js';

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

  test('no crida l\'acció abans que venci el retard', () => {
    const cercar = vi.fn();
    const cercarRetardat = retardar(cercar, 400);

    cercarRetardat('urb');
    vi.advanceTimersByTime(399);

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

  test('crida l\'acció una vegada complert el retard', () => {
    const cercar = vi.fn();
    const cercarRetardat = retardar(cercar, 400);

    cercarRetardat('urb');
    vi.advanceTimersByTime(400);

    expect(cercar).toHaveBeenCalledTimes(1);
    expect(cercar).toHaveBeenCalledWith('urb');
  });

  test('escriure sis lletres seguides produeix UNA sola crida, amb l\'última', () => {
    const cercar = vi.fn();
    const cercarRetardat = retardar(cercar, 400);

    // L'usuari tecleja "urbana" a 100 ms per lletra
    for (const text of ['u', 'ur', 'urb', 'urba', 'urban', 'urbana']) {
      cercarRetardat(text);
      vi.advanceTimersByTime(100);
    }

    expect(cercar).not.toHaveBeenCalled();     // encara no han passat 400 ms des de l'última

    vi.advanceTimersByTime(400);

    expect(cercar).toHaveBeenCalledTimes(1);
    expect(cercar).toHaveBeenCalledWith('urbana');
  });

  test('cancel·lar impedeix la crida pendent', () => {
    const cercar = vi.fn();
    const cercarRetardat = retardar(cercar, 400);

    cercarRetardat('urb');
    cercarRetardat.cancelar();
    vi.advanceTimersByTime(1000);

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

La tercera prova és la que justifica tot l'useDebounce del mòdul 5 i tota l'optimització del cercador del mòdul 8, i és la que seria gairebé impossible de verificar a mà: teclejar sis lletres en menys de 400 ms de manera reproduïble no és una cosa que es pugui fer amb els dits. Amb temporitzadors falsos, és determinista i triga un mil·lisegon.

Amb temporitzadors falsos, si t'oblides d'avançar el rellotge, la prova es queda esperant per sempre. El símptoma és un temps d'espera exhaurit. La prova d'useDebounce dins de React, amb renderHook, es veu a 09-04, perquè requereix combinar els temporitzadors falsos amb act.

  1. Executar, filtrar i llegir la cobertura

Mode vigilància

npm test          # vitest, mode vigilància

Vitest es queda escoltant, i en desar un fitxer reexecuta només les proves afectades per aquest canvi, seguint el graf d'importacions. Desar validarReserva.js reexecuta validarReserva.test.js i FormulariReserva.test.jsx, però no les 40 proves de reserves. En mode vigilància hi ha tecles útils:

Tecla Què fa
a Reexecuta totes les proves
f Reexecuta només les que han fallat
p Filtra per nom de fitxer
t Filtra per nom de prova
q Sortir

Filtrar des del codi i des de la línia d'ordres

test.only('només aquesta prova s\'executa en aquest fitxer', () => { /* … */ });
test.skip('aquesta se salta i apareix marcada a l\'informe', () => { /* … */ });
test.todo('pendent: rebutjar reserves solapades de la mateixa bicicleta');
describe.only('tot aquest bloc', () => { /* … */ });
test.fails('aquesta prova HA de fallar', () => { /* … */ });
npx vitest run src/utilitats               # només els fitxers d'aquesta carpeta
npx vitest run -t "manteniment"            # només les proves el nom de les quals contingui això
npx vitest run --reporter=verbose          # l'arbre complet, prova a prova

Sobre test.only: és utilíssim mentre depures i catastròfic si te n'oblides, perquè desactiva silenciosament la resta del fitxer i la suite continua en verd. La protecció és una regla del linter (vitest/no-focused-tests o jest/no-focused-tests) que el converteix en error. Activa-la.

test.todo és la manera correcta d'anotar el que falta: apareix a l'informe com a pendent, no falla, i no menteix sobre la cobertura com faria un test.skip permanent.

Llegir un informe de cobertura

npm run cobertura
 % Coverage report from v8
---------------------------|---------|----------|---------|---------|-------------------
File                       | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
---------------------------|---------|----------|---------|---------|-------------------
All files                  |   78.42 |    71.05 |   80.00 |   78.42 |
 utilitats                 |   96.15 |    94.44 |  100.00 |   96.15 |
  validarReserva.js        |  100.00 |   100.00 |  100.00 |  100.00 |
  classes.js               |  100.00 |   100.00 |  100.00 |  100.00 |
  monitoritzacio.js        |   72.72 |    50.00 |  100.00 |   72.72 | 18-24
 funcionalitats/reserves   |   91.30 |    88.88 |  100.00 |   91.30 |
  sliceReserves.js         |   91.30 |    88.88 |  100.00 |   91.30 | 47,63
 components                |   31.25 |    12.50 |   25.00 |   31.25 |
  FormulariReserva.jsx     |    0.00 |     0.00 |    0.00 |    0.00 | 1-142
---------------------------|---------|----------|---------|---------|-------------------

Com es llegeix això sense caure en el parany de l'apartat 8.1 de la lliçó anterior:

  • La columna que més informa és % Branch. Un 100 % de sentències amb un 50 % de branques significa que s'executen totes les línies però només un dels dos camins de cada if. A monitoritzacio.js, aquest 50 % apunta que la branca de producció mai no es recorre.
  • Uncovered Line #s és la llista de tasques. Les línies 47 i 63 de sliceReserves.js són, molt probablement, dues guardes (if (!reserva) return;) que encara no s'han provocat. Mereixen prova: són regles de negoci.
  • El 0 % de FormulariReserva.jsx és correcte avui, perquè els components es proven a 09-03. No és una alarma: és un buit conegut i planificat.
  • L'informe HTML de coverage/index.html pinta el codi font amb les línies no cobertes en vermell i les branques parcials en groc. És molt més útil que la taula per decidir què provar.

I la regla de sempre: l'objectiu no és el número. L'objectiu és que les línies vermelles de la lògica de negoci deixin d'estar vermelles.

Errors Comuns i Consells

  • Usar toBe amb objectes i arrays. La fallada número u del principiant. expect({ a: 1 }).toBe({ a: 1 }) sempre falla. Regla: primitius amb toBe, estructures amb toEqual, i identitat deliberada amb toBe només quan la referència sigui el comportament que es prova (memoïtzació, reductor que no ha de copiar).
  • Compartir estat entre proves amb beforeAll o amb una constant mutable. Produeix proves que passen en solitari i fallen a la suite, o al revés. beforeEach reconstrueix; beforeAll només per al que és car i immutable.
  • Oblidar vi.useRealTimers() a l'afterEach. Els temporitzadors falsos es queden actius i contaminen la resta del fitxer; una prova posterior que esperi de debò es penja fins a exhaurir el temps. El mateix amb vi.restoreAllMocks() i console.error.
  • Deixar-se un test.only al codi. Desactiva la resta del fitxer en silenci i la suite es queda verda per buida. Activa la regla del linter que ho prohibeix.
  • Provar la implementació del reductor en lloc del seu resultat. No comprovis que estat.ids.push s'ha cridat; comprova que l'estat retornat conté l'identificador nou. És el principi de 09-01 aplicat a Redux.
  • Simular de més. Un vi.mock d'un mòdul del mateix projecte sol ser senyal d'acoblament. Abans de simular, pregunta't si la dependència pot entrar com a paràmetre: validarReserva(dades, bicicletes) rep el catàleg precisament per això, i per això es prova sense cap doble.
  • Provar una funció pura amb més preparació de la necessària. Si per provar el reductor construeixes un magatzem de Redux complet, has perdut l'avantatge: crida'l directament.
  • Consell: escriu primer la prova que falla. Encara que no practiquis desenvolupament dirigit per proves, verificar que la prova falla abans d'escriure el codi és l'única manera de saber que la prova comprova alguna cosa. Una prova que mai no ha estat en vermell pot no estar comprovant res.
  • Consell: quan arreglis una fallada, escriu abans la prova que la reprodueix. Et dona la confirmació que ho has entès i evita que torni. És el millor moment per escriure una prova, perquè el cas concret ja el tens a la mà.
  • Consell: si una prova necessita més de deu línies de preparació, el codi sota prova té massa dependències. La dificultat de provar és un indicador de disseny, no una molèstia de l'executor.

Exercicis

Exercici 1. Escriu la suite de src/utilitats/classes.js, la utilitat que compon noms de classe ignorant els valors falsos:

export function classes(...noms) {
  return noms.filter(Boolean).join(' ');
}

Cobreix almenys: diversos noms, un valor false intercalat (el cas condicio && estils.actiu), undefined i null, la crida sense arguments i la crida amb un sol nom. Usa test.each on tingui sentit i justifica per què el resultat es compara amb toBe i no amb toEqual.

Exercici 2. L'equip afegeix una regla nova a validarReserva: una bicicleta de tipus carga no es pot reservar més de 8 hores. Escriu les proves abans d'implementar-la —incloent-hi els casos límit— i després la implementació mínima que les fa passar. Indica quina prova de la suite existent podria fallar amb el canvi i per què.

Exercici 3. Aquesta prova de sliceReserves passa sempre, encara que el reductor estigui trencat. Explica per què i reescriu-la perquè verifiqui el que pretén.

test('la cancel·lació funciona', () => {
  const estat = {
    entitats: { 'res-01': { id: 'res-01', estat: 'activa' } },
    ids: ['res-01'], estatCarrega: 'inactiu', error: null, estatEnviament: 'inactiu'
  };
  const resultat = reductorReserves(estat, reservaCancellada('res-01'));
  expect(resultat).toBeTruthy();
  expect(resultat.entitats).toBeDefined();
  expect(Object.keys(resultat.entitats)).toHaveLength(1);
});

Solucions

Solució 1.

// src/utilitats/classes.test.js
import { describe, test, expect } from 'vitest';
import { classes } from './classes.js';

describe('classes', () => {
  test.each([
    [['targeta', 'destacada'],           'targeta destacada'],
    [['targeta', false, 'activa'],       'targeta activa'],
    [['targeta', undefined],             'targeta'],
    [['targeta', null, 'activa'],        'targeta activa'],
    [['targeta', '', 'activa'],          'targeta activa'],
    [['targeta'],                        'targeta'],
    [[],                                 '']
  ])('classes(...%p) retorna %p', (entrada, esperat) => {
    expect(classes(...entrada)).toBe(esperat);
  });

  test('reprodueix l\'ús real del projecte', () => {
    const estils = { control: 'control_a1', invalid: 'invalid_b2' };
    const hiHaError = true;
    expect(classes(estils.control, hiHaError && estils.invalid)).toBe('control_a1 invalid_b2');
    expect(classes(estils.control, false && estils.invalid)).toBe('control_a1');
  });
});

Es compara amb toBe perquè el resultat és una cadena, és a dir, un primitiu: identitat i contingut coincideixen, i toBe dona a més un missatge d'error més clar amb la diferència de text. toEqual funcionaria igual, però usar el matcher més específic documenta el tipus del valor retornat.

Fixa't en l'última fila: classes() sense arguments retorna '', no undefined ni un espai. És un cas límit real, perquè aquest valor acaba a className i un undefined allà pintaria l'atribut literalment en alguns escenaris.

Solució 2. Primer les proves:

describe('límit de 8 hores per a bicicletes de càrrega', () => {
  test.each([
    [1,  true],
    [8,  true],
    [9,  false],
    [24, false]
  ])('bici-003 (carga) amb %i hores: és vàlida? %p', (hores, esValida) => {
    // bici-003 està en manteniment al catàleg base; per a aquesta regla necessitem una de càrrega DISPONIBLE
    const cataleg = [{ id: 'bici-006', model: 'Càrrega Max', tipus: 'carga', estat: 'disponible', preuHora: 5.5 }];
    const errors = validarReserva(dadesValides({ bicicletaId: 'bici-006', hores }), cataleg);

    if (esValida) {
      expect(errors).not.toHaveProperty('hores');
    } else {
      expect(errors.hores).toBe('Les bicicletes de càrrega es reserven un màxim de 8 hores.');
    }
  });

  test('una bicicleta urbana sí que admet 24 hores', () => {
    expect(validarReserva(dadesValides({ bicicletaId: 'bici-001', hores: 24 }), BICICLETES))
      .not.toHaveProperty('hores');
  });
});

La implementació mínima, afegida després de la comprovació del màxim general:

const MAX_HORES_CARGA = 8;
// … dins de la secció d'hores, després de la comprovació de MAX_HORES
const escollida = bicicletes.find((b) => b.id === dades.bicicletaId);
if (!errors.hores && escollida?.tipus === 'carga' && hores > MAX_HORES_CARGA) {
  errors.hores = 'Les bicicletes de càrrega es reserven un màxim de 8 hores.';
}

Quina prova existent podria fallar: cap de les que usen bici-001, perquè és urbana. Però sí que fallaria qualsevol prova futura que reservés bici-003 durant més de 8 hores esperant el missatge genèric de les 24. I hi ha un detall de disseny que la suite obliga a resoldre: la comprovació nova no ha de substituir l'error de disponibilitat, d'aquí el !errors.hores i d'aquí que el cas de prova usi una bicicleta de càrrega disponible. Escriure la prova primer és el que ha fet aflorar aquesta decisió abans d'implementar-la.

Solució 3. Per què passa sempre: les tres assercions són tautològiques.

  • expect(resultat).toBeTruthy() passa amb qualsevol objecte, fins i tot amb l'estat sense tocar.
  • expect(resultat.entitats).toBeDefined() passa mentre el reductor retorni alguna cosa amb aquesta clau.
  • Object.keys(resultat.entitats)).toHaveLength(1) compta les entitats, que no canvien en cancel·lar: cancel·lar marca, no elimina. És a dir, aquesta asserció passaria igual si el reductor no fes absolutament res.

Cap de les tres mira estat, que és l'única cosa que l'acció hauria de canviar. A més, el nom («la cancel·lació funciona») no diu quin comportament es verifica.

Reescrita:

describe('reservaCancellada', () => {
  const ESTAT = {
    entitats: { 'res-01': { id: 'res-01', bicicletaId: 'bici-002', estat: 'activa' } },
    ids: ['res-01'], estatCarrega: 'inactiu', error: null, estatEnviament: 'inactiu'
  };

  beforeEach(() => vi.setSystemTime(new Date('2026-05-04T12:30:00')));
  afterEach(() => vi.useRealTimers());

  test('marca la reserva com a cancel·lada i anota el moment', () => {
    const resultat = reductorReserves(ESTAT, reservaCancellada('res-01'));

    expect(resultat.entitats['res-01'].estat).toBe('cancelada');
    expect(resultat.entitats['res-01'].cancelladaEn).toBe('2026-05-04T12:30:00.000Z');
  });

  test('conserva la reserva a la llista: cancel·lar no elimina', () => {
    const resultat = reductorReserves(ESTAT, reservaCancellada('res-01'));
    expect(resultat.ids).toEqual(['res-01']);
  });

  test('no muta l\'estat rebut', () => {
    reductorReserves(ESTAT, reservaCancellada('res-01'));
    expect(ESTAT.entitats['res-01'].estat).toBe('activa');
  });
});

Ara cada prova afirma un comportament concret i verificable, i qualsevol d'elles es posaria en vermell si el reductor deixés de fer la seva feina. La segona, a més, documenta una decisió de disseny —cancel·lar no esborra— que d'una altra manera només estaria al cap de qui la va escriure.

Conclusió

Aquesta lliçó ha construït la base del mòdul sense tocar React, i ha demostrat per què aquesta part és la més rendible: validarReserva, el reductor sliceReserves i els selectors concentren totes les regles de negoci de CicloUrbano, i provar-los és cridar-los i comparar.

L'essencial. Un executor de proves descobreix, transforma, aïlla, paral·lelitza, informa, vigila i mesura. Jest és el model mental de l'ecosistema i Vitest implementa la mateixa API; les úniques diferències reals són el prefix vi en lloc de jest, on viu la configuració i com es transforma el codi, així que el que has après aquí serveix en tots dos —i tens la recepta de jest.config.js amb el seu moduleNameMapper, el seu Babel i el seu jest-environment-jsdom per al dia que et toqui un projecte sense Vite. L'anatomia d'un fitxer és describe, test/it i quatre ganxos l'ordre dels quals és una pila: els before de fora cap a dins, els after de dins cap a fora; i la regla derivada d'això és preferir beforeEach a beforeAll, perquè l'estat compartit és la causa número u de proves que només fallen a la suite completa.

Del catàleg de matchers, la distinció que més errors causa és toBe enfront de toEqual: identitat contra estructura, la mateixa comparació que governa memo al mòdul 8 i els selectors al 7. Primitius amb toBe, objectes amb toEqual, toStrictEqual quan l'absència d'una clau sigui significativa, i .not sempre acompanyat d'una asserció positiva que el subjecti —perquè toEqual({ dataInici: '…' }) afirma alhora que hi ha aquest error i que no n'hi ha cap altre.

La suite de validarReserva ha establert dos patrons reutilitzables: la fàbrica dadesValides(canvis), que fa evident quin camp s'està espatllant a cada prova, i el rellotge congelat amb vi.setSystemTime, sense el qual la regla de la data caducaria. I test.each ha convertit nou casos límit d'hores en una taula llegible, amb la lliçó de fons: el cas decimal 2.5 és el que atrapa una implementació que 0, 1, 24 i 25 deixarien passar. Les proves dels valors normals confirmen el que ja sabies; les dels marges troben el que no.

El reductor ha tancat el que es va prometre a 05-05 i 07-04. Es prova cridant-lo directament —sense magatzem, sense Provider, sense components—, encadenant l'estat d'una crida a la següent per recórrer el cicle activa → confirmada / cancelada, i verificant el que la pantalla no mostra: les guardes de negoci, l'absència de mutació i —amb toBe— que retorna la mateixa referència davant d'accions alienes, que és el que evita repintar l'aplicació sencera. Els selectors es proven igual, construint l'estat global amb la forma de magatzem.js, i els memoïtzats amb createSelector tenen una prova pròpia i necessària: que retornen la mateixa referència si les entrades no canvien.

Sobre els dobles de prova, ja distingeixes espia, stub, mock i fake, i domines les tres eines: vi.fn() per verificar els callbacks alX del projecte amb toHaveBeenCalledWith i els comparadors asimètrics com expect.objectContaining; vi.spyOn per embolcallar console.error sense embrutar la sortida, sempre amb el seu restoreAllMocks; i vi.mock per substituir un mòdul sencer, amb les seves tres trampes —l'elevació, la substitució total de les exportacions i el desacoblament silenciós enfront del mòdul real—. D'aquí la jerarquia: paràmetre > spyOn > mock, i per a la xarxa cap de les tres. Els temporitzadors falsos han convertit en determinista el que era impossible de reproduir a mà: sis polsacions en 500 ms que han de produir una sola crida amb 'urbana'. I saps executar en vigilància, filtrar amb test.only/test.skip/test.todo —amb la regla del linter que impedeix oblidar-se un only— i llegir un informe de cobertura mirant la columna de branques i les línies sense cobrir de la lògica de negoci.

Queda la meitat visible. Les regles estan provades, però ningú ha comprovat encara que l'usuari vegi el missatge «La reserva màxima és de 24 hores» al costat del camp correcte, ni que el botó «Reservar» estigui deshabilitat per a una bicicleta en manteniment, ni que prémer un filtre avisi el pare amb el tipus escollit. Aquest és el terreny de la propera lliçó, i on el trofeu de proves posa el seu pes més gran: components de debò, muntats a jsdom, consultats com els consultaria una persona —per rol, per etiqueta, per text accessible— i interactuats amb userEvent. Allà veuràs per què tota l'accessibilitat del mòdul 3 era, sense dir-ho, la preparació per a això. La propera lliçó és Proves de Components amb React Testing Library.

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