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
- Què veu una prova d'extrem a extrem que les altres no
- Què costa, i quins fluxos mereixen aquest cost
- Instal·lació i estructura del projecte
- Arrencar l'aplicació i l'API abans de les proves
- Anatomia d'una prova de Cypress
- La naturalesa asíncrona i amb reintents: per què no s'usa
await - Selectors robustos: el contracte
data-testid - Asercions amb
shouldi encadenament - Control de la xarxa amb
cy.intercept - Estat inicial i aïllament entre proves
cy.session: no repetir l'inici de sessió- Flux 1: identificar-se
- Flux 2: reservar una bicicleta
- Flux 3: cancel·lar una reserva
- Depurar una prova que falla
- Integració contínua amb GitHub Actions
- Cypress enfront de Playwright
- L'estratègia completa del mòdul
- 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.
- 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:
- Identificar-se. Sense això no hi ha res més. Travessa formulari, Redux, redirecció i persistència.
- Reservar una bicicleta. És el camí dels diners: catàleg, filtre, fitxa, formulari, mutació, invalidació i navegació.
- 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).
- Instal·lació i estructura del projecte
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.
- 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:
{
"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.
- 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 |
- La naturalesa asíncrona i amb reintents: per què no s'usa
await
awaitAquest é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çó.
- Selectors robustos: el contracte
data-testid
data-testidA 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.
- Asercions amb
should i encadenament
should i encadenamentCypress 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.
- Control de la xarxa amb
cy.intercept
cy.interceptcy.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.
- 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);
});
});
});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.
cy.session: no repetir l'inici de sessió
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.
- 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.
- 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.validarReservarebutja 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à.
- 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.
- 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? |
- 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/videosLes decisions que fan útil aquest flux:
needs: unitariesordena els treballs per cost: les proves ràpides primer. SivalidarReservaestà trencat, no té sentit gastar tres minuts de navegador per descobrir-ho.npm cien lloc denpm installinstal·la exactament el que diu elpackage-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 runno 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-actionja 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.
- 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 | Sí | Sí |
| 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.
- 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
awaitamb 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.geten una variable. No conté un element. Si necessites reutilitzar una consulta, fes servir.as('alias')i despréscy.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'), nocy.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.sessionamb el seuvalidate. - 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()albeforeEach. - 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-testidalhora 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è sí 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:
- URL absoluta a
cy.visit. Ignora elbaseUrlde la configuració i trenca la prova en qualsevol entorn on el port sigui un altre. Ha de sercy.visit('/reservas/nueva'). - 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) > selectes trenca amb qualsevol ajust de maquetació. És la causa més probable que falli en CI, on el build és diferent del de desenvolupament. cy.wait(2000)icy.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.- No s'identifica ni se sembren les dades. Sense sessió,
/reservas/nuevaestà protegida i redirigeix a/acceso; i sense dades conegudes, el resultat depèn del que haguessin deixat altres proves. - 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
- Què és React?
- Configuració de l'Entorn de Desenvolupament
- Hola Món amb React
- JSX: Extensió de Sintaxi de JavaScript
- Com Renderitza React: Virtual DOM i Reconciliació
Mòdul 2: Components de React
- Entendre els Components
- Components Funcionals vs de Classe
- Props: Passar Dades als Components
- State: Gestió de l'Estat del Component
- Estils en els Components: CSS, Mòduls i Utilitats
Mòdul 3: Treballar amb Esdeveniments
- Gestió d'Esdeveniments a React
- Renderitzat Condicional
- Llistes i Claus
- Formularis i Components Controlats
- Validació de Formularis i Components No Controlats
- Accessibilitat en Components Interactius
Mòdul 4: Conceptes Avançats de Components
- Elevar l'Estat
- Composició vs Herència
- Mètodes del Cicle de Vida de React
- Hooks: Introducció i Ús Bàsic
- Límits d'Error: Capturar Fallades a la Interfície
Mòdul 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef i Accés al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalitzats
Mòdul 6: Enrutament a React
- Introducció a React Router
- Configuració de React Router
- Rutes Imbricades
- Navegació Programàtica
- Rutes Protegides i Control d'Accés
Mòdul 7: Gestió de l'Estat
- Introducció a la Gestió de l'Estat
- API de Context
- Redux: Introducció i Configuració
- Redux: Accions i Reductors
- Redux: Connectar-lo a React
- Estat del Servidor: Peticions, Memòria Cau i Sincronització
Mòdul 8: Optimització del Rendiment
- Tècniques d'Optimització del Rendiment a React
- Memoïtzació amb React.memo
- Hooks useMemo i useCallback
- Divisió de Codi i Càrrega Mandrosa
- Mesurar el Rendiment amb React DevTools Profiler
Mòdul 9: Proves a React
- Introducció a les Proves
- Proves Unitàries amb Jest
- Proves de Components amb React Testing Library
- Proves de Codi Asíncron i Simulació d'APIs
- Proves d'Extrem a Extrem amb Cypress
Mòdul 10: Temes Avançats
- Renderitzat al Servidor (SSR) amb Next.js
- Generació de Llocs Estàtics (SSG) amb Next.js
- Suspense i React Server Components
- TypeScript amb React
- React Native: Creació d'Aplicacions Mòbils
