Les quatre lliçons anteriors han construït una xarxa de seguretat sòlida: regles de negoci provades sense React, components provats tal com els fa servir una persona, i pàgines senceres provades amb la xarxa simulada per MSW. I tot i així hi ha una família de fallades que cap d'elles pot veure. Un botó «Reservar» perfectament funcional queda tapat per un avís de cookies amb position: fixed, i les proves de jsdom continuen verdes perquè allà no hi ha maquetació. La redirecció després d'identificar-se porta a /reservas en lloc de a la pantalla des de la qual es va expulsar l'usuari, i com que cada pàgina es prova per separat, ningú se n'assabenta. La sessió no sobreviu a una recàrrega perquè el localStorage s'escriu amb una clau i es llegeix amb una altra, i l'enrutador en memòria no recarrega mai. Els tres són fallades reals, visibles al primer intent d'un usuari, i invisibles per a tot el que precedeix.

Aquesta lliçó munta Cypress: un navegador de veritat, amb l'aplicació real arrencada i json-server responent, recorrent CicloUrbano de principi a fi. És la capa més cara del trofeu i la que més confiança dona, i per això mateix la que cal dosificar amb més criteri. En acabar tindràs els tres fluxos crítics del projecte automatitzats, un flux d'integració contínua que llança tot el mòdul a cada canvi, i la visió completa de què cobreix cada nivell.

Contingut

  1. Què veu una prova d'extrem a extrem que les altres no
  2. Què costa, i quins fluxos mereixen aquest cost
  3. Instal·lació i estructura del projecte
  4. Arrencar l'aplicació i l'API abans de les proves
  5. Anatomia d'una prova de Cypress
  6. La naturalesa asíncrona i amb reintents: per què no s'usa await
  7. Selectors robustos: el contracte data-testid
  8. Asercions amb should i encadenament
  9. Control de la xarxa amb cy.intercept
  10. Estat inicial i aïllament entre proves
  11. cy.session: no repetir l'inici de sessió
  12. Flux 1: identificar-se
  13. Flux 2: reservar una bicicleta
  14. Flux 3: cancel·lar una reserva
  15. Depurar una prova que falla
  16. Integració contínua amb GitHub Actions
  17. Cypress enfront de Playwright
  18. L'estratègia completa del mòdul

  1. Què veu una prova d'extrem a extrem que les altres no

Capacitat jsdom + RTL Cypress
Enrutament real Simulat en memòria; la URL del navegador no canvia La barra d'adreces canvia de veritat; funciona endavant/enrere i la recàrrega
CSS i visibilitat Sense motor de maquetació: toBeVisible és una aproximació Els elements tapats, fora de pantalla o de mida zero fallen com cal
Emmagatzematge i sessió localStorage existeix però es neteja entre proves Persistència real entre navegacions i recàrregues
Xarxa de veritat Interceptada per MSW L'API real (json-server), o interceptada a voluntat
Diverses pantalles encadenades Cada prova munta un component Un recorregut complet: accés → catàleg → fitxa → reserva → llista
Compilació real El codi passa per la transformació de proves S'executa el build que es desplega, amb el seu empaquetat i la seva càrrega mandrosa
Fallades de càrrega de fragments No existeixen Un lazy que no es resol produeix la fallada real (08-04)
Comportament del navegador Simulat Focus, desplaçament, animacions, datetime-local natiu

La frase que resumeix la diferència: les proves anteriors comproven que les peces funcionen; aquesta comprova que l'aplicació funciona.

  1. Què costa, i quins fluxos mereixen aquest cost

El cost no és menyspreable:

Cost Magnitud
Velocitat Segons per prova, enfront de mil·lisegons. Una suite de 30 proves e2e triga diversos minuts
Inestabilitat És la capa més propensa a fallades intermitents: xarxa, temps, animacions
Manteniment Un canvi de disseny pot trencar diverses proves alhora
Infraestructura Requereix l'aplicació aixecada, l'API aixecada i dades en un estat conegut
Diagnòstic Quan falla, la fallada pot ser en qualsevol de les cinc capes que travessa

D'aquí el criteri de decisió:

Pregunta Si la resposta és sí…
Està en el camí dels diners o de la missió del producte? Candidat clar
Travessa diverses pantalles i diverses capes (sessió, xarxa, enrutament)? Candidat clar
La seva fallada seria catastròfica i invisible fins a arribar a l'usuari? Candidat clar
Es pot comprovar igual de bé amb una prova d'integració? No mereix e2e
Depèn sobretot de l'aparença? No: això és revisió visual o proves de regressió visual
És un cas límit d'una regla de negoci? No: això és una prova unitària (09-02)

Aplicat a CicloUrbano, en surten exactament tres fluxos, i la llista curta és deliberada:

  1. Identificar-se. Sense això no hi ha res més. Travessa formulari, Redux, redirecció i persistència.
  2. Reservar una bicicleta. És el camí dels diners: catàleg, filtre, fitxa, formulari, mutació, invalidació i navegació.
  3. Cancel·lar una reserva. Toca dades existents, confirma en un modal i refresca dues llistes.

El que no es prova amb Cypress, i per què: el filtre per tipus (ja cobert a 09-04 amb la URL i MSW), els missatges de validació camp a camp (09-03), els casos límit de validarReserva (09-02), el tema fosc (aparença) i l'estat d'error de cada pàgina (molt més barat amb server.use).

  1. Instal·lació i estructura del projecte

npm install -D cypress
npx cypress open        # la primera vegada crea l'estructura de carpetes
ciclourbano/
├── cypress/
│   ├── e2e/
│   │   ├── acces.cy.js
│   │   ├── reservar.cy.js
│   │   └── cancellar.cy.js
│   ├── fixtures/
│   │   └── bicicletes.json          ← dades fixes per a cy.intercept
│   ├── support/
│   │   ├── e2e.js                    ← es carrega abans de CADA fitxer de proves
│   │   └── comandes.js               ← comandes pròpies: cy.accedirCom(), cy.sembrarDades()
│   └── downloads/
├── cypress.config.js
└── db.json                            ← la base de dades de json-server
// cypress.config.js
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    // Permet escriure cy.visit('/') en lloc de la URL completa
    baseUrl: 'http://localhost:5173',

    supportFile: 'cypress/support/e2e.js',
    specPattern: 'cypress/e2e/**/*.cy.js',

    viewportWidth: 1280,
    viewportHeight: 800,

    // Sense reintents a l'execució interactiva; dos a la integració contínua,
    // on una fallada puntual d'infraestructura no ha de tombar el desplegament.
    retries: { runMode: 2, openMode: 0 },

    video: true,
    screenshotOnRunFailure: true,

    env: {
      apiUrl: 'http://localhost:3001'
    }
  }
});

Una nota sobre retries. A 09-01 es va dir que una prova inestable no es reintenta, s'arregla, i continua sent cert. Els reintents a runMode són una concessió diferent: a la integració contínua hi ha fonts d'inestabilitat alienes a la prova —un contenidor lent, un port que triga a obrir-se— i dos reintents eviten bloquejar un desplegament per això. El que no han de fer és amagar una prova que falla una de cada cinc vegades: Cypress marca els reintents a l'informe, i una prova que apareix repetidament com «recuperada després d'un reintent» cal arreglar-la.

// cypress/support/e2e.js
import './comandes.js';

// Testing Library per a Cypress: aporta cy.findByRole i companyia,
// amb la mateixa prioritat de consultes de 09-03
import '@testing-library/cypress/add-commands';

// Fa fallar la prova si l'aplicació llança un error no capturat.
// És el comportament per defecte i convé NO desactivar-lo:
// un error a la consola és una fallada, encara que la pantalla sembli correcta.
npm install -D @testing-library/cypress

  1. Arrencar l'aplicació i l'API abans de les proves

Cypress no arrenca res: es limita a visitar URLs. Calen dos processos vius —Vite al 5173 i json-server al 3001— i esperar que tots dos responguin abans de llançar les proves. start-server-and-test se n'encarrega:

npm install -D start-server-and-test npm-run-all
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview --port 5173",
    "api": "json-server --watch db.json --port 3001",

    "test": "vitest",
    "test:executar": "vitest run",
    "cobertura": "vitest run --coverage",

    "dev:tot": "npm-run-all --parallel dev api",
    "cy:obrir": "cypress open",
    "cy:executar": "cypress run",

    "e2e": "start-server-and-test dev:tot 'http://localhost:5173|http://localhost:3001' cy:executar",
    "e2e:obrir": "start-server-and-test dev:tot 'http://localhost:5173|http://localhost:3001' cy:obrir",

    "proves:totes": "npm run test:executar && npm run e2e"
  }
}

Què fa start-server-and-test, en ordre: llança dev:tot (que arrenca Vite i json-server en paral·lel), sonda les dues URL fins que responen, executa cy:executar, i en acabar mata els dos processos i propaga el codi de sortida de Cypress. Aquest sondeig és el que evita la fallada clàssica d'integració contínua: llançar Cypress mig segon abans que Vite hagi acabat de compilar, i veure com fallen totes les proves amb ECONNREFUSED.

flowchart LR
    A["npm run e2e"] --> B["dev:tot<br/>Vite 5173 + json-server 3001"]
    B --> C{"Responen<br/>les dues URL?"}
    C -- No --> C
    C -- Sí --> D["cypress run"]
    D --> E["Tanca els dos processos<br/>i propaga el codi de sortida"]

Un apunt sobre dev enfront de preview: en desenvolupament local s'usa el servidor de Vite per la recàrrega en calent. A la integració contínua convé provar contra npm run build + npm run preview, perquè és el codi que es desplega, amb el seu empaquetat i la seva divisió en fragments. Aquí és on apareixen les fallades de lazy del mòdul 8.

  1. Anatomia d'una prova de Cypress

// cypress/e2e/cataleg.cy.js
describe('Catàleg de CicloUrbano', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('mostra les cinc bicicletes de la flota', () => {
    cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
  });

  it('permet veure la fitxa d\'una bicicleta', () => {
    cy.contains('[data-testid="tarjeta-bicicleta"]', 'Urbana Clàssica')
      .findByRole('button', { name: 'Veure fitxa' })
      .click();

    cy.url().should('include', '/bicicletas/bici-001');
    cy.findByRole('heading', { name: 'Urbana Clàssica' }).should('be.visible');
  });
});

describe i it són els mateixos de sempre: Cypress usa Mocha, amb la mateixa estructura de 09-02. El que canvia és tota la resta.

Comanda Què fa
cy.visit(ruta) Carrega una URL (relativa a baseUrl)
cy.get(selector) Cerca elements per selector CSS
cy.contains(text) Cerca per contingut textual
cy.contains(selector, text) Cerca elements d'aquell selector que continguin aquell text
cy.findByRole(...) Consultes de Testing Library, amb la seva prioritat
.click(), .type(), .select(), .check() Interaccions
.should(asserció) Asserció amb reintents
cy.url(), cy.location() La URL actual
cy.intercept(...) Controlar la xarxa
cy.wait('@alias') Esperar una petició concreta

  1. La naturalesa asíncrona i amb reintents: per què no s'usa await

Aquest és el concepte que més costa i el que explica el 90 % de les confusions amb Cypress.

// ❌ Això NO funciona: cy.get no retorna un element
const boto = cy.get('[data-testid="boton-reservar"]');
boto.click();          // `boto` no és un element del DOM

// ❌ Això tampoc: les comandes no són promeses de les quals es pugui fer await
const text = await cy.get('h1').text();

Les comandes de Cypress no executen res quan les escrius: les encuen. El cos de l'it s'executa sencer de forma síncrona i l'única cosa que fa és construir una cua de comandes. Quan acaba, Cypress comença a executar-la, una darrere l'altra, esperant que cadascuna acabi abans de passar a la següent.

it('reserva una bicicleta', () => {
  cy.visit('/');                   // 1) s'encua
  cy.get('[data-testid="x"]');     // 2) s'encua
  cy.contains('Reservar').click(); // 3) s'encua
  // El cos acaba AQUÍ. Ara Cypress executa l'1, el 2 i el 3 en ordre.
});

Conseqüència directa: el resultat d'una comanda no està disponible a la línia següent de JavaScript, perquè aquella línia ja s'ha executat. Per treballar amb un valor, s'usa .then():

cy.get('[data-testid="total-reserva"]').then(($element) => {
  // Aquí sí: la comanda ja s'ha executat i $element és un objecte jQuery
  const total = parseFloat($element.text().replace(',', '.'));
  expect(total).to.be.greaterThan(0);
});

L'espera automàtica i els reintents

La segona meitat del model: cada comanda de consulta reintenta fins que troba el que busca o esgota el temps d'espera (4 segons per defecte, configurable). I cada asserció should reintenta juntament amb la comanda que la precedeix.

// No cal esperar res: cy.get reintenta fins que la targeta apareix,
// encara que les dades triguin un segon a arribar de json-server
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);

Això elimina d'arrel el problema de 09-04: a Cypress no cal escriure esperes explícites per a les dades. I per això mateix continua vigent la prohibició del temps fix:

// ❌ Mai
cy.wait(2000);
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);

// ✅ Sempre
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);

Una precisió important que evita una fallada subtil: els reintents s'apliquen a l'última comanda de la cadena, no a tota ella. Si escrius cy.get('ul').find('li').should('have.length', 3), Cypress reintenta el find, però el cy.get('ul') es va resoldre una vegada. Si la llista sencera es torna a muntar enmig, obtens l'error «element separat del DOM». La solució és una única consulta que ho abasti tot: cy.get('ul li').should('have.length', 3).

I una altra: les comandes d'acció (click, type) no reintenten indefinidament, però sí que comproven que l'element sigui «accionable» —visible, no tapat, no deshabilitat, sense animació— i esperen que ho sigui. Aquí hi ha la detecció del botó tapat pel bàner de cookies que obria aquesta lliçó.

  1. Selectors robustos: el contracte data-testid

A 09-03 es va defensar la prioritat de consultes amb getByRole en primer lloc i data-testid com a últim recurs. En proves d'extrem a extrem el criteri canvia, i convé entendre per què:

Selector En proves de components En proves e2e
getByRole / findByRole Primera opció Molt bona per a elements interactius
Text visible Bona Fràgil: el text canvia per motius editorials, i amb traduccions es trenca
Classes CSS Mai Mai: són hashes de CSS Modules
Estructura del DOM Mai Mai
data-testid / data-cy Últim recurs Recomanat per als ancoratges estructurals

La diferència és de propòsit. Una prova de component verifica un component i es pot permetre dependre de la seva semàntica completa. Una prova e2e recorre cinc pantalles i necessita punts d'ancoratge estables que sobrevisquin a redissenys, canvis de redacció i refactoritzacions de maquetació. Un data-testid és un contracte explícit: qui l'escriu al JSX està declarant «això ho fan servir les proves, no l'esborris sense avisar».

La combinació que fa servir el projecte: data-testid per localitzar la zona o l'element estructural, i findByRole per a l'element interactiu dins d'ella.

// src/components/TargetaBicicleta.jsx (fragment amb els ancoratges)
<article className={estils.targeta} data-testid="tarjeta-bicicleta" data-bicicleta={bicicleta.id}>
  <h3>{bicicleta.model}</h3>
  <p className={estils.estacio}>{nomEstacio}</p>
  <EtiquetaEstat estat={bicicleta.estat} />
  <p className={estils.preu}>{FORMAT_EURO.format(bicicleta.preuHora)} / hora</p>

  <button type="button" onClick={() => alSeleccionar(bicicleta.id)}>Veure fitxa</button>
  <button
    type="button"
    onClick={() => alReservar(bicicleta.id)}
    disabled={bicicleta.estat !== 'disponible'}
  >
    Reservar
  </button>
</article>
// Localitzar una targeta concreta pel seu identificador de domini
cy.get('[data-bicicleta="bici-001"]').findByRole('button', { name: 'Reservar' }).click();

// O pel seu contingut, quan l'identificador no importa
cy.contains('[data-testid="tarjeta-bicicleta"]', 'Elèctrica Pro')
  .findByRole('button', { name: 'Veure fitxa' })
  .click();

Una comanda pròpia estalvia repetició i centralitza el selector:

// cypress/support/comandes.js
Cypress.Commands.add('perTestId', (identificador, ...resta) =>
  cy.get(`[data-testid="${identificador}"]`, ...resta)
);

Cypress.Commands.add('targetaDe', (bicicletaId) =>
  cy.get(`[data-bicicleta="${bicicletaId}"]`)
);
cy.perTestId('lista-bicicletas').should('be.visible');
cy.targetaDe('bici-001').findByRole('button', { name: 'Reservar' }).click();

Si demà l'atribut passa a dir-se data-cy, es canvia en un sol lloc.

  1. Asercions amb should i encadenament

Cypress usa Chai, amb dos estils: should encadenat (l'habitual) i expect dins d'un .then().

// Estil encadenat: reintenta fins que es compleix
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
cy.findByRole('button', { name: 'Reservar' }).should('be.visible').and('not.be.disabled');
cy.url().should('include', '/reservas');
cy.get('[data-testid="aviso"]').should('contain.text', 'Reserva creada');
cy.findByLabelText('Durada (hores)').should('have.value', '2');

// Estil expect: dins de then, sense reintents
cy.get('[data-testid="total-reserva"]').then(($el) => {
  expect($el.text()).to.match(/12,00\s*€/);
});
Asserció Comprova
should('be.visible') Visible de veritat: en pantalla, no tapat, amb mida
should('not.exist') No està al DOM
should('be.disabled') / ('not.be.disabled') Estat del control
should('have.length', n) Nombre d'elements
should('contain.text', t) Conté aquell text
should('have.value', v) Valor d'un camp
should('have.attr', a, v) Atribut, útil per a aria-*
should('have.class', c) Classe: evita-ho amb CSS Modules
.and(...) Encadena una altra asserció sobre el mateix element

La diferència entre not.exist i not.be.visible importa: la primera exigeix que l'element no estigui al DOM; la segona accepta que hi sigui però ocult. Per a un modal tancat, la correcta depèn de com l'implementi Modal, i triar malament produeix una prova que passa pel motiu equivocat.

  1. Control de la xarxa amb cy.intercept

cy.intercept fa tres coses: observar peticions per poder esperar-les, substituir respostes i retardar-les.

Observar i esperar

it('carrega el catàleg des de l\'API', () => {
  // L'àlies permet esperar AQUESTA petició concreta
  cy.intercept('GET', '**/bicicletas*').as('carregarBicicletes');

  cy.visit('/');

  cy.wait('@carregarBicicletes').its('response.statusCode').should('eq', 200);
  cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
});

cy.wait('@alias') és l'única espera legítima de Cypress, perquè espera un succés concret i no un temps. S'utilitza quan cal afirmar sobre la petició —el seu cos, el seu codi d'estat— o quan la interfície no canvia de forma observable en completar-se.

Verificar què s'envia

cy.intercept('POST', '**/reservas').as('crearReserva');
// … omplir i enviar …
cy.wait('@crearReserva').then(({ request, response }) => {
  expect(request.body).to.include({
    bicicletaId: 'bici-001',
    usuari: 'usr-01',
    hores: 2,
    estat: 'activa'
  });
  expect(response.statusCode).to.eq(201);
});

Substituir la resposta

// Un error del servidor
cy.intercept('GET', '**/bicicletas*', { statusCode: 500, body: {} }).as('fallada');

// Una llista buida
cy.intercept('GET', '**/bicicletas*', { body: [] });

// Dades fixes des de cypress/fixtures/bicicletes.json
cy.intercept('GET', '**/bicicletas*', { fixture: 'bicicletes.json' });

// Una resposta lenta, per comprovar l'esquelet
cy.intercept('GET', '**/bicicletas*', (req) => {
  req.reply({ delay: 1000, fixture: 'bicicletes.json' });
});

// Fallar només la primera vegada, per provar el reintent
cy.intercept('GET', '**/bicicletas*', { statusCode: 500, times: 1 });

Quan usar l'API real i quan simular-la

Situació Decisió Per què
Els tres fluxos crítics API real (json-server) És el que dona a una prova e2e el seu valor: verificar la integració de veritat
Comprovar l'estat d'error Simular amb intercept Apagar json-server a mig de la prova no és viable
Comprovar una llista buida Simular Buidar la base de dades afectaria altres proves
Comprovar l'esquelet de càrrega Simular amb delay L'API real respon massa ràpid per observar-lo
Verificar el cos enviat Observar amb àlies, sense substituir Es vol la integració real i poder afirmar sobre la petició
Un servei extern de pagament Simular sempre No es crida un tercer des d'una prova

La regla: el camí feliç dels fluxos crítics va contra l'API real; els camins alternatius se simulen. Si simules el camí feliç, la prova e2e deixa d'aportar res que no aportés ja la de 09-04, i hauràs pagat el cost sense rebre el benefici.

  1. Estat inicial i aïllament entre proves

El problema fonamental de les proves e2e amb API real: la base de dades guarda el que fan. Si una prova crea una reserva, la següent comença amb una reserva de més, i el resultat depèn de l'ordre.

La solució a CicloUrbano té tres peces.

db.json amb un estat llavor reiniciable

{
  "bicicletas": [
    { "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 }
  ],
  "estaciones": [
    { "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 }
  ],
  "reservas": [
    { "id": "res-01", "bicicletaId": "bici-002", "usuari": "usr-01", "dataInici": "2026-05-04T09:00", "hores": 2, "estat": "activa" }
  ]
}

Se'n guarda una còpia intacta a cypress/fixtures/db.llavor.json i una comanda pròpia la restaura:

// cypress/support/comandes.js
Cypress.Commands.add('sembrarDades', () => {
  cy.fixture('db.llavor.json').then((llavor) => {
    // Esborra el que hi hagi i reposa l'estat conegut
    cy.request('GET', `${Cypress.env('apiUrl')}/reservas`).then(({ body }) => {
      body.forEach((reserva) => {
        cy.request('DELETE', `${Cypress.env('apiUrl')}/reservas/${reserva.id}`);
      });
    });

    llavor.reservas.forEach((reserva) => {
      cy.request('POST', `${Cypress.env('apiUrl')}/reservas`, reserva);
    });

    llavor.bicicletas.forEach((bicicleta) => {
      cy.request('PUT', `${Cypress.env('apiUrl')}/bicicletas/${bicicleta.id}`, bicicleta);
    });
  });
});
beforeEach(() => {
  cy.sembrarDades();
});

cy.request fa peticions HTTP fora del navegador: no passa per l'aplicació, no dispara la interfície i és ràpid. És l'eina correcta per preparar i netejar; mai es prepara l'escenari clicant botons.

Aïllament automàtic del navegador

Des de Cypress 12, cada it comença amb localStorage, sessionStorage i les galetes netes (testIsolation: true, el valor per defecte). Això resol la meitat del problema i en crea l'altra: si cada prova comença sense sessió, cal tornar a identificar-se a cadascuna. Per a això hi ha cy.session.

  1. cy.session: no repetir l'inici de sessió

Identificar-se per la interfície a cada prova costa segons i, sobretot, acobla totes les proves al formulari d'accés: un canvi a PaginaAcces en trencaria vint.

cy.session executa l'accés una sola vegada, guarda l'estat resultant —localStorage, galetes, sessionStorage— i el restaura a les proves següents.

// cypress/support/comandes.js
Cypress.Commands.add('accedirCom', (usuariId) => {
  cy.session(
    // 1) Clau de la sessió: sessions diferents per a usuaris diferents
    ['sessio', usuariId],

    // 2) Com s'estableix la sessió. S'executa la primera vegada i quan falla la validació
    () => {
      cy.visit('/acceso');
      cy.findByLabelText('Identifica\'t com').select(usuariId);
      cy.findByRole('button', { name: /Entrar/ }).click();
      cy.url().should('not.include', '/acceso');
    },

    // 3) Validació: si retorna algun valor fals o falla, la sessió es refà
    {
      validate() {
        cy.window().its('localStorage')
          .invoke('getItem', 'ciclourbano:usuario')
          .should('contain', usuariId);
      },
      cacheAcrossSpecs: true      // es reutilitza també entre fitxers de proves
    }
  );
});
describe('Reservar una bicicleta', () => {
  beforeEach(() => {
    cy.sembrarDades();
    cy.accedirCom('usr-01');    // instantani a partir de la segona vegada
    cy.visit('/');
  });

  // …
});

El bloc validate és la peça que fa fiable el mecanisme: comprova que la sessió restaurada continua sent vàlida i, si no ho és, torna a executar l'accés. Sense ell, una sessió caducada produiria fallades incomprensibles en proves que no tenen res a veure amb l'accés.

I una regla derivada: el flux d'accés es prova una vegada, per la interfície, en el seu propi fitxer. Les altres proves fan servir cy.accedirCom.

  1. Flux 1: identificar-se

// cypress/e2e/acces.cy.js
describe('Identificar-se a CicloUrbano', () => {
  beforeEach(() => {
    cy.sembrarDades();
  });

  it('permet entrar com a client i mostra el seu nom a la capçalera', () => {
    cy.visit('/acceso');

    cy.findByRole('heading', { name: /Accés a CicloUrbano/ }).should('be.visible');

    cy.findByLabelText('Identifica\'t com').select('usr-01');
    cy.findByRole('button', { name: /Entrar/ }).click();

    // Redirecció al catàleg
    cy.url().should('eq', `${Cypress.config('baseUrl')}/`);
    cy.perTestId('menu-usuario').should('contain.text', 'Ana Ribera');
  });

  it('retorna l\'usuari a la pantalla que intentava visitar', () => {
    // Sense sessió, /reservas expulsa a l'accés guardant l'origen (06-05)
    cy.visit('/reservas');

    cy.url().should('include', '/acceso');
    cy.findByRole('alert').should('contain.text', '/reservas');

    cy.findByLabelText('Identifica\'t com').select('usr-01');
    cy.findByRole('button', { name: /Entrar/ }).click();

    // I torna a la destinació original, no a l'inici
    cy.url().should('include', '/reservas');
    cy.findByRole('heading', { name: /Les teves reserves/ }).should('be.visible');
  });

  it('la sessió sobreviu a una recàrrega de la pàgina', () => {
    cy.accedirCom('usr-01');
    cy.visit('/');
    cy.perTestId('menu-usuario').should('contain.text', 'Ana Ribera');

    cy.reload();

    cy.perTestId('menu-usuario').should('contain.text', 'Ana Ribera');
    cy.url().should('not.include', '/acceso');
  });

  it('un client no pot entrar al taller', () => {
    cy.accedirCom('usr-01');
    cy.visit('/taller');

    cy.url().should('include', '/sin-permisos');
    cy.findByRole('heading', { name: /No tens permís/ }).should('be.visible');
  });

  it('un operari sí que entra al taller', () => {
    cy.accedirCom('usr-02');
    cy.visit('/taller');

    cy.url().should('include', '/taller');
    cy.findByRole('heading', { name: /Taller/ }).should('be.visible');
  });

  it('tancar la sessió torna a l\'accés i no deixa tornar enrere', () => {
    cy.accedirCom('usr-01');
    cy.visit('/reservas');

    cy.perTestId('menu-usuario').findByRole('button', { name: /Tancar sessió/ }).click();

    cy.url().should('include', '/acceso');
    cy.go('back');
    cy.url().should('include', '/acceso');   // la protecció continua actuant
  });
});

Les dues proves que només pot fer Cypress són aquí: la de la recàrrega (cy.reload()), que verifica que la sessió persisteix de veritat al navegador, i la del botó enrere (cy.go('back')), que verifica que la protecció de rutes de 06-05 no es pot saltar amb l'historial. Cap de les dues existeix en un enrutador en memòria.

  1. Flux 2: reservar una bicicleta

El camí del diner, de principi a fi.

// cypress/e2e/reservar.cy.js
describe('Reservar una bicicleta', () => {
  beforeEach(() => {
    cy.sembrarDades();
    cy.accedirCom('usr-01');
  });

  it('recorre el flux complet: catàleg → fitxa → reserva → llista', () => {
    // S'observa la petició per poder afirmar sobre el cos enviat,
    // però NO se substitueix: el camí feliç va contra l'API real
    cy.intercept('POST', '**/reservas').as('crearReserva');

    cy.visit('/');

    // 1) El catàleg carrega i bici-001 està disponible
    cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
    cy.targetaDe('bici-001').should('contain.text', 'Disponible');

    // 2) S'obre la seva fitxa
    cy.targetaDe('bici-001').findByRole('button', { name: 'Veure fitxa' }).click();
    cy.url().should('include', '/bicicletas/bici-001');
    cy.findByRole('heading', { name: 'Urbana Clàssica' }).should('be.visible');
    cy.contains('Plaça Major').should('be.visible');

    // 3) S'inicia la reserva des de la fitxa
    cy.findByRole('button', { name: /Reservar aquesta bicicleta/ }).click();
    cy.url().should('include', '/reservas/nueva');

    // 4) S'omple el formulari
    cy.findByLabelText('Bicicleta').should('have.value', 'bici-001');   // preseleccionada
    cy.findByLabelText('Inici de la reserva').type('2030-06-01T10:00');
    cy.findByLabelText('Durada (hores)').clear().type('3');
    cy.findByRole('checkbox', { name: /Accepto les condicions/ }).check();

    // El total derivat: 2,50 € × 3 h
    cy.perTestId('total-reserva').should('contain.text', '7,50');

    // 5) S'envia
    cy.findByRole('button', { name: 'Crear reserva' }).click();

    // 6) Es verifica el que ha sortit per la xarxa
    cy.wait('@crearReserva').then(({ request, response }) => {
      expect(response.statusCode).to.eq(201);
      expect(request.body).to.include({
        bicicletaId: 'bici-001',
        usuari: 'usr-01',
        dataInici: '2030-06-01T10:00',
        hores: 3,
        estat: 'activa'
      });
      expect(request.body.id).to.match(/^res-[a-z0-9]{8}$/);
    });

    // 7) Confirmació i navegació
    cy.findByRole('status').should('contain.text', 'Reserva creada');
    cy.url().should('include', '/reservas');

    // 8) La reserva nova és a la llista, junt amb la de la llavor
    cy.get('[data-testid="fila-reserva"]').should('have.length', 2);
    cy.contains('[data-testid="fila-reserva"]', 'Urbana Clàssica')
      .should('contain.text', '3 h')
      .and('contain.text', 'Activa');

    // 9) I persisteix després de recarregar: s'ha guardat de veritat al servidor
    cy.reload();
    cy.get('[data-testid="fila-reserva"]').should('have.length', 2);
  });

  it('no deixa reservar una bicicleta en manteniment', () => {
    cy.visit('/');

    cy.targetaDe('bici-003')
      .should('contain.text', 'En manteniment')
      .findByRole('button', { name: 'Reservar' })
      .should('be.disabled');
  });

  it('mostra els errors de validació i no envia res', () => {
    // Si s'enviés alguna cosa, aquest àlies ho detectaria
    cy.intercept('POST', '**/reservas').as('crearReserva');

    cy.visit('/reservas/nueva');
    cy.findByRole('button', { name: 'Crear reserva' }).click();

    cy.findAllByRole('alert').should('have.length.at.least', 3);
    cy.findByLabelText('Durada (hores)').should('have.attr', 'aria-invalid', 'true');
    cy.url().should('include', '/reservas/nueva');

    cy.get('@crearReserva.all').should('have.length', 0);
  });

  it('avisa si el servidor falla i no perd les dades introduïdes', () => {
    // Aquí SÍ se simula: el camí alternatiu no es pot provocar de cap altra manera
    cy.intercept('POST', '**/reservas', { statusCode: 500, body: {} }).as('crearReservaFallida');

    cy.visit('/reservas/nueva');
    cy.findByLabelText('Bicicleta').select('bici-001');
    cy.findByLabelText('Inici de la reserva').type('2030-06-01T10:00');
    cy.findByLabelText('Durada (hores)').clear().type('2');
    cy.findByRole('checkbox', { name: /Accepto les condicions/ }).check();
    cy.findByRole('button', { name: 'Crear reserva' }).click();

    cy.wait('@crearReservaFallida');
    cy.findByRole('alert').should('contain.text', 'No s\'ha pogut crear la reserva');

    // La feina de l'usuari continua allà
    cy.findByLabelText('Durada (hores)').should('have.value', '2');
    cy.url().should('include', '/reservas/nueva');
  });
});

Tres detalls que mereixen atenció:

  • La data és 2030-06-01, no una data propera. validarReserva rebutja el passat, i una data fixa de 2026 convertiria la prova en una bomba de rellotgeria que comença a fallar sola. A Cypress no hi ha temporitzadors falsos còmodes, així que la solució és una data prou llunyana. L'alternativa —cy.clock()— existeix, però interfereix amb les peticions i complica més del que resol.
  • cy.get('@crearReserva.all').should('have.length', 0) comprova que no s'ha enviat cap petició. És la forma correcta de verificar que la validació de client bloqueja l'enviament.
  • El pas 9, la recàrrega, és el que distingeix aquesta prova de la de 09-04. Allà es va verificar que la interfície s'actualitzava després de la invalidació; aquí es verifica que la dada ha arribat al servidor i continua allà.

  1. Flux 3: cancel·lar una reserva

// cypress/e2e/cancellar.cy.js
describe('Cancel·lar una reserva', () => {
  beforeEach(() => {
    cy.sembrarDades();
    cy.accedirCom('usr-01');
    cy.visit('/reservas');
  });

  it('cancel·la una reserva activa després de confirmar-ho al diàleg', () => {
    cy.intercept('PATCH', '**/reservas/res-01').as('cancellarReserva');

    cy.contains('[data-testid="fila-reserva"]', 'Elèctrica Pro')
      .should('contain.text', 'Activa')
      .findByRole('button', { name: /Cancel·lar/ })
      .click();

    // El diàleg modal ha de rebre el focus i ser accessible (03-06)
    cy.findByRole('dialog').should('be.visible').within(() => {
      cy.findByRole('heading', { name: /Vols cancel·lar la reserva/ }).should('be.visible');
      cy.contains('Elèctrica Pro').should('be.visible');
      cy.findByRole('button', { name: /Sí, cancel·lar/ }).click();
    });

    cy.wait('@cancellarReserva').then(({ request, response }) => {
      expect(request.body).to.include({ estat: 'cancelada' });
      expect(response.statusCode).to.eq(200);
    });

    cy.findByRole('dialog').should('not.exist');
    cy.findByRole('status').should('contain.text', 'Reserva cancel·lada');

    // La fila continua allà, però amb un altre estat: cancel·lar no elimina (09-02)
    cy.contains('[data-testid="fila-reserva"]', 'Elèctrica Pro')
      .should('contain.text', 'Cancel·lada');
    cy.contains('[data-testid="fila-reserva"]', 'Elèctrica Pro')
      .findByRole('button', { name: /Cancel·lar/ })
      .should('not.exist');
  });

  it('tancar el diàleg sense confirmar no cancel·la res', () => {
    cy.intercept('PATCH', '**/reservas/*').as('cancellarReserva');

    cy.contains('[data-testid="fila-reserva"]', 'Elèctrica Pro')
      .findByRole('button', { name: /Cancel·lar/ })
      .click();

    cy.findByRole('dialog').findByRole('button', { name: /No, mantenir-la/ }).click();

    cy.findByRole('dialog').should('not.exist');
    cy.contains('[data-testid="fila-reserva"]', 'Elèctrica Pro').should('contain.text', 'Activa');
    cy.get('@cancellarReserva.all').should('have.length', 0);
  });

  it('el diàleg es tanca amb la tecla Escape', () => {
    cy.contains('[data-testid="fila-reserva"]', 'Elèctrica Pro')
      .findByRole('button', { name: /Cancel·lar/ })
      .click();

    cy.findByRole('dialog').should('be.visible');
    cy.get('body').type('{esc}');

    cy.findByRole('dialog').should('not.exist');
    cy.contains('[data-testid="fila-reserva"]', 'Elèctrica Pro').should('contain.text', 'Activa');
  });

  it('la cancel·lació persisteix després de recarregar i allibera la bicicleta', () => {
    cy.contains('[data-testid="fila-reserva"]', 'Elèctrica Pro')
      .findByRole('button', { name: /Cancel·lar/ })
      .click();
    cy.findByRole('dialog').findByRole('button', { name: /Sí, cancel·lar/ }).click();
    cy.findByRole('status').should('contain.text', 'Reserva cancel·lada');

    cy.reload();
    cy.contains('[data-testid="fila-reserva"]', 'Elèctrica Pro').should('contain.text', 'Cancel·lada');

    // I bici-002 torna a estar disponible al catàleg
    cy.visit('/');
    cy.targetaDe('bici-002').should('contain.text', 'Disponible');
  });

  it('avisa si la cancel·lació falla al servidor', () => {
    cy.intercept('PATCH', '**/reservas/*', { statusCode: 500, body: {} }).as('cancellacioFallida');

    cy.contains('[data-testid="fila-reserva"]', 'Elèctrica Pro')
      .findByRole('button', { name: /Cancel·lar/ })
      .click();
    cy.findByRole('dialog').findByRole('button', { name: /Sí, cancel·lar/ }).click();

    cy.wait('@cancellacioFallida');
    cy.findByRole('alert').should('contain.text', 'No s\'ha pogut cancel·lar');
    // L'estat no ha canviat: no hi ha actualització optimista mal revertida
    cy.contains('[data-testid="fila-reserva"]', 'Elèctrica Pro').should('contain.text', 'Activa');
  });
});

cy.within() limita les consultes a l'interior de l'element, i és imprescindible amb modals: sense ell, findByRole('button', { name: /Cancel·lar/ }) trobaria també el botó de la fila del darrere i fallaria per ambigüitat.

L'última prova verifica una cosa que només es manifesta en integrar: que si el servidor rebutja la cancel·lació, la interfície torna a l'estat anterior. És la fallada clàssica d'una actualització optimista mal revertida, i produeix el pitjor dels resultats: l'usuari creu que ha cancel·lat i no ha cancel·lat.

  1. Depurar una prova que falla

Cypress és, amb diferència, l'eina amb millor depuració de tot el mòdul.

Eina Com s'usa Per a què
Viatge pels passos Passar el ratolí per cada comanda al panell esquerre Veure el DOM tal com estava en aquell instant. El més útil de tot
Captures automàtiques cypress/screenshots/ en mode run Veure l'estat exacte de la fallada en integració contínua
Vídeo automàtic cypress/videos/ amb video: true Reconstruir tota l'execució
cy.pause() S'insereix a la cadena Atura l'execució i permet avançar comanda a comanda
.debug() cy.get('...').debug() Atura i exposa l'element a la consola del navegador
cy.log('…') En qualsevol punt Anotacions al registre de comandes
Consola del navegador Clic en una comanda del panell Imprimeix l'element, la petició o el valor obtinguts
it('depuració', () => {
  cy.visit('/');
  cy.pause();                                    // s'atura aquí
  cy.get('[data-testid="tarjeta-bicicleta"]').debug().first().click();
  cy.log('Fitxa oberta');
});

Els errors més freqüents i la seva lectura:

Missatge Què significa Solució
Timed out retrying: Expected to find element L'element mai va aparèixer Selector correcte? Calia identificar-se? Mira el DOM al pas previ
element is not visible because it has CSS property: display: none Hi és però ocult Falta obrir el panell, o hi ha una fallada real de visibilitat
element is being covered by another element Tapat: la fallada que justifica Cypress Arreglar la maquetació, no la prova
element has become detached from the DOM L'element s'ha tornat a muntar entre la consulta i l'acció Consulta única (cy.get('ul li')), no cadena en dos passos
cy.wait() timed out waiting for route '@alias' La petició mai s'ha fet Coincideix el patró d'URL? S'ha enviat de veritat?

  1. Integració contínua amb GitHub Actions

Una suite que només s'executa quan algú se'n recorda no protegeix res. Aquest és el flux mínim que llança els dos nivells a cada canvi:

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

on:
  push:
    branches: [main]
  pull_request:

jobs:
  unitaries:
    name: Unitàries i de components
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Anàlisi estàtica
        run: npm run lint

      - name: Vitest amb cobertura
        run: npm run cobertura

      - name: Publicar l'informe de cobertura
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: cobertura
          path: coverage/

  e2e:
    name: Extrem a extrem
    runs-on: ubuntu-latest
    needs: unitaries          # si fallen les ràpides, no es gasten minuts en les lentes
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Compilar l'aplicació
        run: npm run build

      - name: Cypress contra el build de producció
        uses: cypress-io/github-action@v6
        with:
          # Arrenca els dos servidors, espera que responguin i llança les proves
          start: npm run dev:todo
          wait-on: 'http://localhost:5173, http://localhost:3001'
          wait-on-timeout: 120
          browser: chrome

      - name: Guardar captures si falla alguna cosa
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: cypress-captures
          path: cypress/screenshots

      - name: Guardar els vídeos
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: cypress-videos
          path: cypress/videos

Les decisions que fan útil aquest flux:

  • needs: unitaries ordena els treballs per cost: les proves ràpides primer. Si validarReserva està trencat, no té sentit gastar tres minuts de navegador per descobrir-ho.
  • npm ci en lloc de npm install instal·la exactament el que diu el package-lock.json: reproduïble i més ràpid.
  • if: failure() a les captures només les puja quan calen, i són el primer que es mira en investigar una fallada que no es reprodueix en local.
  • Mode sense capçalera per defecte. cypress run no obre finestra, que és l'única cosa possible en un servidor d'integració contínua. cypress open és només per a desenvolupament.
  • L'acció oficial cypress-io/github-action ja emmagatzema en memòria cau el binari de Cypress —que pesa centenars de megabytes— entre execucions.

I una recomanació de procés: configura la branca principal perquè exigeixi que aquest flux passi abans d'integrar. Una suite verda que ningú no està obligat a respectar acaba en vermell permanent.

  1. Cypress enfront de Playwright

Playwright, de Microsoft, és la principal alternativa. No es desenvolupa en aquest curs, però convé conèixer el mapa per triar amb criteri.

Criteri Cypress Playwright
Model d'execució Dins del navegador, al costat de l'aplicació Fora, controlant el navegador per protocol
API Cadena de comandes amb reintents, sense await async/await estàndard
Navegadors Chrome, Edge, Firefox, WebKit (experimental) Chromium, Firefox i WebKit, tots de primera classe
Diverses pestanyes o dominis Limitat per disseny Suport complet
Paral·lelisme Requereix Cypress Cloud o configuració pròpia Natiu i gratuït
Velocitat Bona Millor, sobretot en paral·lel
Depuració interactiva Excel·lent: viatge pels passos, DOM històric Bona: trace viewer, molt potent però a posteriori
Corba d'aprenentatge Suau; la cua de comandes desconcerta al principi Més natural per a qui coneix async/await
Espera automàtica
Generació de proves No codegen grava la interacció i escriu la prova
Maduresa de l'ecosistema Molt alta, molts complements Alta i creixent ràpidament
Proves de components Sí (experimental) Sí (experimental)

Criteris d'elecció:

  • Tria Cypress si l'equip comença en proves e2e i valora l'experiència de depuració —el viatge pels passos és difícil de superar—, si l'aplicació és d'un sol domini i una sola pestanya, o si l'ecosistema de complements pesa.
  • Tria Playwright si necessites cobertura real de diversos navegadors, si el flux travessa dominis o pestanyes (passarel·les de pagament, OAuth extern), si el paral·lelisme gratuït importa per al temps d'integració contínua, o si l'equip prefereix async/await.
  • Cap elecció és irreversible, i totes dues comparteixen els conceptes que de veritat importen: selectors estables, aïllament entre proves, control de la xarxa i espera per resultat. El que s'ha après aquí es trasllada gairebé sencer.

Per a CicloUrbano, Cypress és l'elecció: un domini, una pestanya, un equip que comença i una necessitat clara de diagnosticar ràpid.

  1. L'estratègia completa del mòdul

flowchart TB
    subgraph E2E["🌐 Extrem a extrem · Cypress · 3 fluxos · segons"]
        E1["Identificar-se: redirecció, persistència, rols, botó enrere"]
        E2["Reservar: catàleg → fitxa → formulari → API → llista → recàrrega"]
        E3["Cancel·lar: modal, PATCH, estat, bicicleta alliberada"]
    end
    subgraph INT["🔗 Integració · RTL + MSW · la majoria · desenes de ms"]
        I1["PaginaCataleg: càrrega, èxit, error 500, llista buida, reintent"]
        I2["FormulariReserva: errors, aria-describedby, camí feliç"]
        I3["Mutacions: cos enviat, invalidació, refresc"]
        I4["TargetaBicicleta · SelectorTipus · EtiquetaEstat"]
        I5["Hooks: useAlternar, useDebounce, useMagatzemLocal"]
        I6["Rutes: paràmetres, ?tipo=, navegació, RequereixRol"]
    end
    subgraph UNI["🧪 Unitàries · Vitest · lògica pura · mil·lisegons"]
        U1["validarReserva: 4 regles i tots els casos límit"]
        U2["sliceReserves: cicle de vida i guardes de negoci"]
        U3["Selectors: derivació i memoïtzació"]
        U4["classes · retardar · disponibilitat"]
    end
    subgraph EST["⚡ Estàtica · ESLint · contínua · instantània"]
        S1["react-hooks: dependències i regles dels hooks"]
        S2["jsx-a11y: accessibilitat mentre escrius"]
        S3["no-focused-tests: cap test.only oblidat"]
    end
    EST --> UNI --> INT --> E2E
Nivell Quantes Què protegeix Quan s'executa
Estàtica Contínua Errates, hooks mal usats, accessibilitat bàsica En escriure i en el flux de CI
Unitària Desenes Regles de negoci i casos límit En desar (mode vigilància)
Integració La majoria El cablejat entre peces i els estats de la interfície En desar i en CI
Extrem a extrem Tres fluxos Que l'aplicació completa funciona de veritat En CI, i abans de desplegar

I la comprovació final: torna a les nou refactoritzacions del mòdul 8. Avui, canviar la signatura de LlistaBicicletes, moure estat, reescriure el value d'un context o partir les rutes en fragments produeix una resposta en segons —verda o vermella— en lloc d'una sessió de clics a cegues. Aquest era exactament el buit que aquest mòdul venia a omplir.

Errors Comuns i Consells

  • Intentar fer servir await amb les comandes de Cypress. No són promeses: són entrades en una cua. Per treballar amb un valor, .then().
  • Guardar el resultat de cy.get en una variable. No conté un element. Si necessites reutilitzar una consulta, fes servir .as('alias') i després cy.get('@alias').
  • cy.wait(3000) per «donar temps». Lent sempre i insuficient a vegades. L'espera automàtica ja cobreix el cas; i si necessites esperar una petició concreta, cy.wait('@alias').
  • Encadenar consultes sobre llistes que es tornen a muntar. Produeix «element separat del DOM». Una sola consulta: cy.get('ul li'), no cy.get('ul').find('li').
  • Identificar-se per la interfície a cada prova. Multiplica el temps i acobla totes les proves al formulari d'accés. cy.session amb el seu validate.
  • No reiniciar la base de dades entre proves. El resultat comença a dependre de l'ordre, i apareix la inestabilitat més difícil de diagnosticar. cy.sembrarDades() al beforeEach.
  • Simular tota la xarxa a les proves e2e. Si simules el camí feliç, la prova deixa d'aportar res que no donés ja la de 09-04. El camí feliç va contra l'API real; els camins alternatius se simulen.
  • Escriure vint proves e2e. La suite es torna lenta i inestable i l'equip acaba desactivant-la. Els fluxos crítics i res més; la resta, un nivell més avall.
  • Usar dates properes a les dades de prova. Una data de 2026 en una regla que rebutja el passat converteix la prova en una bomba de rellotgeria. Dates llunyanes o rellotge controlat.
  • Consell: quan una prova falli, mira primer el DOM del pas anterior. El viatge pels passos sol donar la resposta abans que cap altra eina, i sovint revela que la fallada era dues comandes enrere.
  • Consell: escriu els data-testid alhora que el component, no quan escriguis la prova. Així l'ancoratge forma part del disseny del component i no d'un pedaç posterior.
  • Consell: una prova e2e per flux, no per asserció. Aquestes proves són cares d'arrencar; recórrer un flux complet comprovant coses pel camí aprofita molt millor aquest cost que deu proves que visiten la mateixa pàgina.

Exercicis

Exercici 1. Escriu la prova d'extrem a extrem del flux de l'operari: Marc Solé (usr-02) entra, va a /taller, marca bici-001 com «en manteniment» i comprova que al catàleg públic aquesta bicicleta ja no es pot reservar. Fes servir cy.accedirCom, sembra les dades i verifica la persistència amb una recàrrega. Indica quines peticions observaries amb cy.intercept i quines no.

Exercici 2. Aquesta prova falla de forma intermitent en integració contínua i sempre passa en local. Identifica cinc problemes i reescriu-la.

it('reserva', () => {
  cy.visit('http://localhost:5173/reservas/nueva');
  cy.get('.formulario_a3f9 > div:nth-child(2) > select').select('bici-001');
  cy.wait(2000);
  cy.get('.boton-enviar').click();
  cy.wait(3000);
  cy.get('.aviso').should('contain', 'creada');
});

Exercici 3. L'equip discuteix si val la pena provar amb Cypress el filtre per tipus de bicicleta (?tipo=electrica), que ja té proves a 09-04 amb MSW. Argumenta amb el criteri de decisió de l'apartat 2 si es mereix una prova e2e, i proposa què caldria afegir en el seu lloc. Després, descriu com comprovaries amb Cypress una cosa que MSW no pot: que en prémer «Elèctrica» la URL canvia, que la vista sobreviu a una recàrrega i que el botó enrere torna al filtre anterior.

Solucions

Solució 1.

// cypress/e2e/taller.cy.js
describe('Flux de l\'operari', () => {
  beforeEach(() => {
    cy.sembrarDades();
    cy.accedirCom('usr-02');       // Marc Solé, rol operari
  });

  it('posar una bicicleta en manteniment la retira del catàleg públic', () => {
    // S'observa l'actualització per afirmar sobre el cos; NO se substitueix:
    // el camí feliç ha d'arribar a json-server de veritat
    cy.intercept('PATCH', '**/bicicletas/bici-001').as('actualitzarBicicleta');

    cy.visit('/taller');
    cy.findByRole('heading', { name: /Taller/ }).should('be.visible');

    cy.targetaDe('bici-001').should('contain.text', 'Disponible');
    cy.targetaDe('bici-001')
      .findByRole('button', { name: /Enviar a manteniment/ })
      .click();

    cy.wait('@actualitzarBicicleta').then(({ request, response }) => {
      expect(request.body).to.include({ estat: 'mantenimiento' });
      expect(response.statusCode).to.eq(200);
    });

    cy.findByRole('status').should('contain.text', 'Bicicleta enviada a manteniment');
    cy.targetaDe('bici-001').should('contain.text', 'En manteniment');

    // L'efecte al catàleg públic
    cy.visit('/');
    cy.targetaDe('bici-001')
      .should('contain.text', 'En manteniment')
      .findByRole('button', { name: 'Reservar' })
      .should('be.disabled');

    // I persisteix: el canvi ha arribat al servidor
    cy.reload();
    cy.targetaDe('bici-001').findByRole('button', { name: 'Reservar' }).should('be.disabled');
  });

  it('un client no veu l\'enllaç al taller ni pot entrar', () => {
    cy.accedirCom('usr-01');
    cy.visit('/');

    cy.findByRole('navigation').findByRole('link', { name: /Taller/ }).should('not.exist');

    cy.visit('/taller');
    cy.url().should('include', '/sin-permisos');
  });
});

Què s'observa i què no:

Petició cy.intercept? Per què
PATCH /bicicletas/bici-001 Sí, observar amb àlies Cal afirmar sobre el cos (estat: 'mantenimiento') i esperar que acabi
GET /bicicletas del catàleg No L'espera automàtica de cy.get ja cobreix la càrrega; interceptar-lo hi afegiria soroll
GET /estaciones No És un detall de la pantalla, no del flux provat
La sessió d'accés No La resol cy.session fora d'aquesta prova

I en cap cas se substitueix la resposta: l'objectiu d'aquesta prova és precisament verificar que el canvi arriba a la base de dades i es reflecteix en una altra pantalla.

Solució 2. Els cinc problemes:

  1. URL absoluta a cy.visit. Ignora el baseUrl de la configuració i trenca la prova en qualsevol entorn on el port sigui un altre. Ha de ser cy.visit('/reservas/nueva').
  2. Selectors per classe de CSS Modules i per estructura del DOM. .formulario_a3f9 és un hash generat que canvia a cada compilació, i > div:nth-child(2) > select es trenca amb qualsevol ajust de maquetació. És la causa més probable que falli en CI, on el build és diferent del de desenvolupament.
  3. cy.wait(2000) i cy.wait(3000). Esperes fixes: lentes en local, insuficients en un contenidor d'integració contínua carregat. Són la causa directa de la intermitència, i a més innecessàries perquè les comandes ja reintenten.
  4. No s'identifica ni se sembren les dades. Sense sessió, /reservas/nueva està protegida i redirigeix a /acceso; i sense dades conegudes, el resultat depèn del que haguessin deixat altres proves.
  5. El formulari no s'omple sencer. Només es tria la bicicleta: falten la data, les hores i les condicions, així que la validació el rebutjaria i l'avís de «creada» no arribaria mai. El nom it('reserva') tampoc no diu quin comportament es verifica.

Reescrita:

describe('Crear una reserva', () => {
  beforeEach(() => {
    cy.sembrarDades();
    cy.accedirCom('usr-01');
  });

  it('crea la reserva i confirma el resultat a l\'usuari', () => {
    cy.intercept('POST', '**/reservas').as('crearReserva');

    cy.visit('/reservas/nueva');

    cy.findByLabelText('Bicicleta').select('bici-001');
    cy.findByLabelText('Inici de la reserva').type('2030-06-01T10:00');
    cy.findByLabelText('Durada (hores)').clear().type('2');
    cy.findByRole('checkbox', { name: /Accepto les condicions/ }).check();

    cy.findByRole('button', { name: 'Crear reserva' }).click();

    cy.wait('@crearReserva').its('response.statusCode').should('eq', 201);
    cy.findByRole('status').should('contain.text', 'Reserva creada');
    cy.url().should('include', '/reservas');
  });
});

Solució 3. No es mereix una prova e2e, i el criteri de l'apartat 2 ho diu de tres maneres: no és al camí del diner (filtrar no genera reserves), es pot comprovar igual de bé amb una prova d'integració —i de fet ja està fet a 09-04, verificant la cadena URL → useSearchParams → clau de consulta → petició → llista—, i la seva fallada no seria catastròfica: l'usuari veuria més bicicletes de les que havia demanat, no perdria diners ni dades.

Què caldria afegir en el seu lloc: res de nou a nivell e2e. Si sobra pressupost de proves, és molt més rendible reforçar el flux de reserva —per exemple, reservar des de la fitxa d'una bicicleta a la qual s'ha arribat amb el filtre aplicat, comprovant que el filtre no es perd en tornar enrere—, perquè això sí que travessa diverses pantalles i no ho cobreix cap altra capa.

Ara bé, hi ha tres coses del filtre que MSW no pot comprovar i que sí que justificarien unes línies dins d'una prova e2e existent:

it('el filtre viu a la URL i sobreviu a la navegació del navegador', () => {
  cy.visit('/');
  cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);

  // 1) Prémer el filtre canvia la URL REAL del navegador
  cy.findByRole('button', { name: 'Elèctrica' }).click();
  cy.url().should('include', '?tipo=electrica');
  cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 2);

  // 2) La vista sobreviu a una recàrrega completa de la pàgina
  cy.reload();
  cy.url().should('include', '?tipo=electrica');
  cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 2);
  cy.findByRole('button', { name: 'Elèctrica', pressed: true }).should('exist');

  // 3) El botó enrere del navegador torna al filtre anterior
  cy.findByRole('button', { name: 'De càrrega' }).click();
  cy.url().should('include', '?tipo=carga');

  cy.go('back');
  cy.url().should('include', '?tipo=electrica');
  cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 2);
});

Les tres comprovacions són impossibles amb createMemoryRouter: la URL del navegador no canvia, no hi ha recàrrega i l'historial no és el del navegador. Justament per això, al mòdul 6 es va argumentar que posar el filtre a la URL el feia compartible i navegable; aquesta prova és la que verifica aquesta promesa. És un bon exemple del criteri general: no es puja un cas a e2e perquè sigui important, sinó perquè només allà es pot comprovar.

Conclusió

Amb aquesta lliçó es tanca el Mòdul 9, i CicloUrbano passa de no tenir cap xarxa de seguretat a tenir quatre nivells que se solapen de forma deliberada.

L'essencial de Cypress. Una prova d'extrem a extrem veu el que cap altra veu: enrutament real amb la seva URL, la seva recàrrega i el seu botó enrere; CSS i visibilitat de veritat, inclòs el botó tapat per un altre element; persistència de la sessió entre navegacions; xarxa real contra json-server; i diverses pantalles encadenades executant el mateix build que es desplega. A canvi costa segons per prova, és la capa més propensa a la inestabilitat i exigeix infraestructura, així que el criteri de selecció és estricte: camí del diner, diverses capes travessades, fallada catastròfica i invisible, i res que es pugui comprovar igual de bé un nivell més avall. A CicloUrbano això són exactament tres fluxos: identificar-se, reservar i cancel·lar.

Del funcionament, el que cal interioritzar és que cy.get(...) no retorna un element i mai no es fa servir await: el cos de l'it només encua comandes, i Cypress les executa després, en ordre, esperant cadascuna. Per treballar amb un valor, .then(). I cada consulta i cada should reintenten fins a trobar el que busquen, cosa que elimina d'arrel les esperes explícites de 09-04 —amb la trampa que els reintents s'apliquen a l'última comanda de la cadena, d'aquí ve l'«element separat del DOM» i la regla d'usar una consulta única. Les comandes d'acció, a més, exigeixen que l'element sigui accionable, i aquí hi ha la detecció del botó tapat.

Els selectors canvien de criteri respecte a 09-03: data-testid deixa de ser l'últim recurs i passa a ser el contracte explícit per als ancoratges estructurals que han de sobreviure a redissenys, combinat amb findByRole de Testing Library per als elements interactius. Queden fixats cypress.config.js amb el seu baseUrl, cypress/support/comandes.js amb cy.perTestId, cy.targetaDe, cy.sembrarDades i cy.accedirCom, i els tres fitxers cypress/e2e/acces.cy.js, reservar.cy.js i cancellar.cy.js. L'arrencada la resol start-server-and-test, que sonda Vite i json-server fins que responen abans de llançar res.

Sobre la xarxa, la regla és clara: el camí feliç va contra l'API real —si el simules, la prova e2e no aporta res que no donés ja la de 09-04— i els camins alternatius se simulen amb cy.intercept, que a més permet observar una petició amb un àlies per afirmar sobre el seu cos i el seu codi d'estat sense substituir-la. L'aïllament es recolza en tres peces: db.json reiniciable des d'una llavor amb cy.request, l'aïllament automàtic del navegador entre proves, i cy.session amb el seu bloc validate, que executa l'accés una vegada i el restaura després. I la depuració —el viatge pels passos amb el DOM històric, les captures i el vídeo automàtics, cy.pause i .debug()— és la millor de tot el mòdul.

Amb GitHub Actions, els dos nivells s'executen a cada canvi, ordenats per cost: linter i Vitest primer, Cypress després amb needs, captures pujades només quan alguna cosa falla i el binari en memòria cau gràcies a l'acció oficial. I ja saps situar Playwright al mapa: millor per a diversos navegadors, diverses pestanyes, dominis creuats i paral·lelisme gratuït; Cypress, millor per començar i per diagnosticar ràpid en una aplicació d'un sol domini, que és el cas de CicloUrbano. Els conceptes que importen —selectors estables, aïllament, control de la xarxa, espera per resultat— es traslladen gairebé sencers entre totes dues.

Amb això es tanca el Mòdul 9. El buit que va deixar el mòdul 8 està tapat: les nou refactoritzacions que en el seu moment només es van comprovar obrint el navegador avui produeixen una resposta en segons. L'estratègia completa té quatre nivells i cadascun fa el que els altres no poden: l'anàlisi estàtica caça el hook mal declarat mentre escrius; les unitàries cobreixen validarReserva amb tots els seus casos límit, el cicle de vida i les guardes de sliceReserves, i la memoïtzació dels selectors, tot sense muntar un sol component; les d'integració —el gruix del trofeu— proven els components tal com els fa servir una persona, recolzades en l'accessibilitat del mòdul 3, amb el renderitzar propi que embolcalla els proveïdors reals i amb MSW simulant la xarxa per verificar càrrega, èxit, error, llista buida i reintent; i les tres proves d'extrem a extrem confirmen que l'aplicació sencera funciona de veritat, amb la seva URL, la seva sessió, la seva base de dades i el seu navegador. El principi que les governa totes continua sent el de la primera lliçó: provar el comportament visible, no la implementació, perquè les proves fallin quan alguna cosa es trenca i només llavors.

El que ve ara canvia de terreny per complet. Fins aquí, CicloUrbano ha estat una aplicació que es descarrega al navegador i s'executa allà: ràpida de desenvolupar, però amb un primer pintat que espera el JavaScript i amb un contingut que els cercadors veuen a mitges. El Mòdul 10: Temes Avançats ataca precisament aquesta frontera i les que l'envolten. Veuràs el renderitzat al servidor (SSR) amb Next.js, on l'HTML arriba ja construït; la generació de llocs estàtics (SSG), per al contingut que no canvia a cada visita; Suspense i els React Server Components, el model que redefineix on s'executa cada component i que reaprofita tot el que saps de lazy i d'estats de càrrega; TypeScript amb React, que converteix en errors de compilació bona part del que avui només detecten les proves —i que després d'aquest mòdul entendràs en el seu lloc exacte de la piràmide: anàlisi estàtica, la capa més barata—; i React Native, per portar el que has après a aplicacions mòbils natives. La lliçó següent és Renderitzat al Servidor (SSR) amb Next.js.

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