Las cuatro lecciones anteriores han construido una red de seguridad sólida: reglas de negocio probadas sin React, componentes probados como los usa una persona, y páginas enteras probadas con la red simulada por MSW. Y aun así hay una familia de fallos que ninguna de ellas puede ver. Un botón «Reservar» perfectamente funcional queda tapado por un aviso de cookies con position: fixed, y las pruebas de jsdom siguen verdes porque ahí no hay maquetación. La redirección tras identificarse lleva a /reservas en lugar de a la pantalla desde la que se expulsó al usuario, y como cada página se prueba por separado nadie se entera. La sesión no sobrevive a una recarga porque el localStorage se escribe con una clave y se lee con otra, y el enrutador en memoria no recarga nunca. Los tres son fallos reales, visibles al primer intento de un usuario, e invisibles para todo lo anterior.
Esta lección monta Cypress: un navegador de verdad, con la aplicación real arrancada y json-server respondiendo, recorriendo CicloUrbano de principio a fin. Es la capa más cara del trofeo y la que más confianza da, y por eso mismo la que hay que dosificar con más criterio. Al terminar tendrás los tres flujos críticos del proyecto automatizados, un flujo de integración continua que lanza todo el módulo en cada cambio, y la visión completa de qué cubre cada nivel.
Contenido
- Qué ve una prueba de extremo a extremo que las demás no
- Qué cuesta, y qué flujos merecen ese coste
- Instalación y estructura del proyecto
- Arrancar la aplicación y la API antes de las pruebas
- Anatomía de una prueba de Cypress
- La naturaleza asíncrona y con reintentos: por qué no se usa
await - Selectores robustos: el contrato
data-testid - Aserciones con
shouldy encadenamiento - Control de la red con
cy.intercept - Estado inicial y aislamiento entre pruebas
cy.session: no repetir el inicio de sesión- Flujo 1: identificarse
- Flujo 2: reservar una bicicleta
- Flujo 3: cancelar una reserva
- Depurar una prueba que falla
- Integración continua con GitHub Actions
- Cypress frente a Playwright
- La estrategia completa del módulo
- Qué ve una prueba de extremo a extremo que las demás no
| Capacidad | jsdom + RTL |
Cypress |
|---|---|---|
| Enrutamiento real | Simulado en memoria; la URL del navegador no cambia | La barra de direcciones cambia de verdad; funciona atrás/adelante y la recarga |
| CSS y visibilidad | Sin motor de maquetación: toBeVisible es una aproximación |
Elementos tapados, fuera de pantalla o de tamaño cero fallan como es debido |
| Almacenamiento y sesión | localStorage existe pero se limpia entre pruebas |
Persistencia real entre navegaciones y recargas |
| Red de verdad | Interceptada por MSW | La API real (json-server), o interceptada a voluntad |
| Varias pantallas encadenadas | Cada prueba monta un componente | Un recorrido completo: acceso → catálogo → ficha → reserva → lista |
| Compilación real | El código pasa por la transformación de pruebas | Se ejecuta el build que se despliega, con su empaquetado y su carga perezosa |
| Fallos de carga de fragmentos | No existen | Un lazy que no resuelve produce el fallo real (08-04) |
| Comportamiento del navegador | Simulado | Foco, desplazamiento, animaciones, datetime-local nativo |
La frase que resume la diferencia: las pruebas anteriores comprueban que las piezas funcionan; esta comprueba que la aplicación funciona.
- Qué cuesta, y qué flujos merecen ese coste
El coste no es despreciable:
| Coste | Magnitud |
|---|---|
| Velocidad | Segundos por prueba, frente a milisegundos. Una suite de 30 pruebas e2e tarda varios minutos |
| Inestabilidad | Es la capa más propensa a fallos intermitentes: red, tiempos, animaciones |
| Mantenimiento | Un cambio de diseño puede romper varias pruebas a la vez |
| Infraestructura | Requiere la aplicación levantada, la API levantada y datos en un estado conocido |
| Diagnóstico | Cuando falla, el fallo puede estar en cualquiera de las cinco capas que atraviesa |
De ahí el criterio de decisión:
| Pregunta | Si la respuesta es sí… |
|---|---|
| ¿Está en el camino del dinero o de la misión del producto? | Candidato claro |
| ¿Atraviesa varias pantallas y varias capas (sesión, red, enrutado)? | Candidato claro |
| ¿Su fallo sería catastrófico e invisible hasta llegar al usuario? | Candidato claro |
| ¿Se puede comprobar igual de bien con una prueba de integración? | No merece e2e |
| ¿Depende sobre todo de la apariencia? | No: eso es revisión visual o pruebas de regresión visual |
| ¿Es un caso límite de una regla de negocio? | No: eso es una prueba unitaria (09-02) |
Aplicado a CicloUrbano, salen exactamente tres flujos, y la lista corta es deliberada:
- Identificarse. Sin esto no hay nada más. Atraviesa formulario, Redux, redirección y persistencia.
- Reservar una bicicleta. Es el camino del dinero: catálogo, filtro, ficha, formulario, mutación, invalidación y navegación.
- Cancelar una reserva. Toca datos existentes, confirma en un modal y refresca dos listas.
Lo que no se prueba con Cypress, y por qué: el filtro por tipo (ya cubierto en 09-04 con la URL y MSW), los mensajes de validación campo a campo (09-03), los casos límite de validarReserva (09-02), el tema oscuro (apariencia) y el estado de error de cada página (mucho más barato con server.use).
- Instalación y estructura del proyecto
ciclourbano/ ├── cypress/ │ ├── e2e/ │ │ ├── acceso.cy.js │ │ ├── reservar.cy.js │ │ └── cancelar.cy.js │ ├── fixtures/ │ │ └── bicicletas.json ← datos fijos para cy.intercept │ ├── support/ │ │ ├── e2e.js ← se carga antes de CADA fichero de pruebas │ │ └── comandos.js ← comandos propios: cy.accederComo(), cy.sembrarDatos() │ └── downloads/ ├── cypress.config.js └── db.json ← la base de datos de json-server
// cypress.config.js
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
// Permite escribir cy.visit('/') en lugar de la URL completa
baseUrl: 'http://localhost:5173',
supportFile: 'cypress/support/e2e.js',
specPattern: 'cypress/e2e/**/*.cy.js',
viewportWidth: 1280,
viewportHeight: 800,
// Sin reintentos en la ejecución interactiva; dos en integración continua,
// donde un fallo puntual de infraestructura no debe tumbar el despliegue.
retries: { runMode: 2, openMode: 0 },
video: true,
screenshotOnRunFailure: true,
env: {
apiUrl: 'http://localhost:3001'
}
}
});Una nota sobre retries. En 09-01 se dijo que una prueba inestable no se reintenta, se arregla, y sigue siendo cierto. Los reintentos en runMode son una concesión distinta: en integración continua hay fuentes de inestabilidad ajenas a la prueba —un contenedor lento, un puerto que tarda en abrirse— y dos reintentos evitan bloquear un despliegue por eso. Lo que no deben hacer es tapar una prueba que falla una de cada cinco veces: Cypress marca los reintentos en el informe, y una prueba que aparece repetidamente como «recuperada tras reintento» hay que arreglarla.
// cypress/support/e2e.js
import './comandos.js';
// Testing Library para Cypress: aporta cy.findByRole y compañía,
// con la misma prioridad de consultas de 09-03
import '@testing-library/cypress/add-commands';
// Falla la prueba si la aplicación lanza un error no capturado.
// Es el comportamiento por defecto y conviene NO desactivarlo:
// un error en consola es un fallo, aunque la pantalla parezca correcta.
- Arrancar la aplicación y la API antes de las pruebas
Cypress no arranca nada: se limita a visitar URLs. Hacen falta dos procesos vivos —Vite en el 5173 y json-server en el 3001— y esperar a que ambos respondan antes de lanzar las pruebas. start-server-and-test se encarga:
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5173",
"api": "json-server --watch db.json --port 3001",
"test": "vitest",
"test:ejecutar": "vitest run",
"cobertura": "vitest run --coverage",
"dev:todo": "npm-run-all --parallel dev api",
"cy:abrir": "cypress open",
"cy:ejecutar": "cypress run",
"e2e": "start-server-and-test dev:todo 'http://localhost:5173|http://localhost:3001' cy:ejecutar",
"e2e:abrir": "start-server-and-test dev:todo 'http://localhost:5173|http://localhost:3001' cy:abrir",
"pruebas:todas": "npm run test:ejecutar && npm run e2e"
}
}Qué hace start-server-and-test, en orden: lanza dev:todo (que arranca Vite y json-server en paralelo), sondea las dos URL hasta que responden, ejecuta cy:ejecutar, y al terminar mata los dos procesos y propaga el código de salida de Cypress. Ese sondeo es lo que evita el fallo clásico de integración continua: lanzar Cypress medio segundo antes de que Vite haya terminado de compilar, y ver cómo fallan todas las pruebas con ECONNREFUSED.
flowchart LR
A["npm run e2e"] --> B["dev:todo<br/>Vite 5173 + json-server 3001"]
B --> C{"¿Responden<br/>las dos URL?"}
C -- No --> C
C -- Sí --> D["cypress run"]
D --> E["Cierra ambos procesos<br/>y propaga el código de salida"]
Un apunte sobre dev frente a preview: en desarrollo local se usa el servidor de Vite por la recarga en caliente. En integración continua conviene probar contra npm run build + npm run preview, porque es el código que se despliega, con su empaquetado y su división en fragmentos. Ahí es donde aparecen los fallos de lazy del módulo 8.
- Anatomía de una prueba de Cypress
// cypress/e2e/catalogo.cy.js
describe('Catálogo de CicloUrbano', () => {
beforeEach(() => {
cy.visit('/');
});
it('muestra las cinco bicicletas de la flota', () => {
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
});
it('permite ver la ficha de una bicicleta', () => {
cy.contains('[data-testid="tarjeta-bicicleta"]', 'Urbana Clásica')
.findByRole('button', { name: 'Ver ficha' })
.click();
cy.url().should('include', '/bicicletas/bici-001');
cy.findByRole('heading', { name: 'Urbana Clásica' }).should('be.visible');
});
});describe e it son los mismos de siempre: Cypress usa Mocha, con la misma estructura de 09-02. Lo que cambia es todo lo demás.
| Comando | Qué hace |
|---|---|
cy.visit(ruta) |
Carga una URL (relativa a baseUrl) |
cy.get(selector) |
Busca elementos por selector CSS |
cy.contains(texto) |
Busca por contenido textual |
cy.contains(selector, texto) |
Busca elementos de ese selector que contengan ese texto |
cy.findByRole(...) |
Consultas de Testing Library, con su prioridad |
.click(), .type(), .select(), .check() |
Interacciones |
.should(aserción) |
Aserción con reintentos |
cy.url(), cy.location() |
La URL actual |
cy.intercept(...) |
Controlar la red |
cy.wait('@alias') |
Esperar a una petición concreta |
- La naturaleza asíncrona y con reintentos: por qué no se usa
await
awaitEste es el concepto que más cuesta y el que explica el 90 % de las confusiones con Cypress.
// ❌ Esto NO funciona: cy.get no devuelve un elemento
const boton = cy.get('[data-testid="boton-reservar"]');
boton.click(); // `boton` no es un elemento del DOM
// ❌ Esto tampoco: los comandos no son promesas de las que se pueda hacer await
const texto = await cy.get('h1').text();Los comandos de Cypress no ejecutan nada cuando los escribes: los encolan. El cuerpo del it se ejecuta entero de forma síncrona y lo único que hace es construir una cola de comandos. Cuando termina, Cypress empieza a ejecutarla, uno detrás de otro, esperando a que cada uno acabe antes de pasar al siguiente.
it('reserva una bicicleta', () => {
cy.visit('/'); // 1) se encola
cy.get('[data-testid="x"]'); // 2) se encola
cy.contains('Reservar').click(); // 3) se encola
// El cuerpo termina AQUÍ. Ahora Cypress ejecuta 1, 2 y 3 en orden.
});Consecuencia directa: el resultado de un comando no está disponible en la siguiente línea de JavaScript, porque esa línea ya se ejecutó. Para trabajar con un valor, se usa .then():
cy.get('[data-testid="total-reserva"]').then(($elemento) => {
// Aquí sí: el comando ya se ha ejecutado y $elemento es un objeto jQuery
const total = parseFloat($elemento.text().replace(',', '.'));
expect(total).to.be.greaterThan(0);
});La espera automática y los reintentos
La segunda mitad del modelo: cada comando de consulta reintenta hasta que encuentra lo que busca o agota el tiempo de espera (4 segundos por defecto, configurable). Y cada aserción should reintenta junto con el comando que la precede.
// No hace falta esperar nada: cy.get reintenta hasta que la tarjeta aparece,
// aunque los datos tarden un segundo en llegar de json-server
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);Esto elimina de raíz el problema de 09-04: en Cypress no hay que escribir esperas explícitas para los datos. Y por eso mismo sigue vigente la prohibición del tiempo fijo:
// ❌ Nunca
cy.wait(2000);
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
// ✅ Siempre
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);Una precisión importante que evita un fallo sutil: los reintentos aplican al último comando de la cadena, no a toda ella. Si escribes cy.get('ul').find('li').should('have.length', 3), Cypress reintenta el find, pero el cy.get('ul') se resolvió una vez. Si la lista entera se vuelve a montar en medio, obtienes el error «elemento separado del DOM». La solución es una única consulta que lo abarque todo: cy.get('ul li').should('have.length', 3).
Y otra: los comandos de acción (click, type) no reintentan indefinidamente, pero sí comprueban que el elemento sea «accionable» —visible, no tapado, no deshabilitado, sin animación— y esperan a que lo sea. Ahí está la detección del botón tapado por el banner de cookies que abría esta lección.
- Selectores robustos: el contrato
data-testid
data-testidEn 09-03 se defendió la prioridad de consultas con getByRole en primer lugar y data-testid como último recurso. En pruebas de extremo a extremo el criterio cambia, y conviene entender por qué:
| Selector | En pruebas de componentes | En pruebas e2e |
|---|---|---|
getByRole / findByRole |
Primera opción | Muy buena para elementos interactivos |
| Texto visible | Buena | Frágil: el texto cambia por motivos editoriales, y con traducciones se rompe |
| Clases CSS | Nunca | Nunca: son hashes de CSS Modules |
| Estructura del DOM | Nunca | Nunca |
data-testid / data-cy |
Último recurso | Recomendado para los anclajes estructurales |
La diferencia es de propósito. Una prueba de componente verifica un componente y puede permitirse depender de su semántica completa. Una prueba e2e recorre cinco pantallas y necesita puntos de anclaje estables que sobrevivan a rediseños, cambios de copia y refactorizaciones de maquetación. Un data-testid es un contrato explícito: quien lo escribe en el JSX está declarando «esto lo usan las pruebas, no lo borres sin avisar».
La combinación que usa el proyecto: data-testid para localizar la zona o el elemento estructural, y findByRole para el elemento interactivo dentro de ella.
// src/componentes/TarjetaBicicleta.jsx (fragmento con los anclajes)
<article className={estilos.tarjeta} data-testid="tarjeta-bicicleta" data-bicicleta={bicicleta.id}>
<h3>{bicicleta.modelo}</h3>
<p className={estilos.estacion}>{nombreEstacion}</p>
<EtiquetaEstado estado={bicicleta.estado} />
<p className={estilos.precio}>{FORMATO_EURO.format(bicicleta.precioHora)} / hora</p>
<button type="button" onClick={() => alSeleccionar(bicicleta.id)}>Ver ficha</button>
<button
type="button"
onClick={() => alReservar(bicicleta.id)}
disabled={bicicleta.estado !== 'disponible'}
>
Reservar
</button>
</article>// Localizar una tarjeta concreta por su identificador de dominio
cy.get('[data-bicicleta="bici-001"]').findByRole('button', { name: 'Reservar' }).click();
// O por su contenido, cuando el identificador no importa
cy.contains('[data-testid="tarjeta-bicicleta"]', 'Eléctrica Pro')
.findByRole('button', { name: 'Ver ficha' })
.click();Un comando propio ahorra repetición y centraliza el selector:
// cypress/support/comandos.js
Cypress.Commands.add('porTestId', (identificador, ...resto) =>
cy.get(`[data-testid="${identificador}"]`, ...resto)
);
Cypress.Commands.add('tarjetaDe', (bicicletaId) =>
cy.get(`[data-bicicleta="${bicicletaId}"]`)
);cy.porTestId('lista-bicicletas').should('be.visible');
cy.tarjetaDe('bici-001').findByRole('button', { name: 'Reservar' }).click();Si mañana el atributo pasa a llamarse data-cy, se cambia en un sitio.
- Aserciones con
should y encadenamiento
should y encadenamientoCypress usa Chai, con dos estilos: should encadenado (el habitual) y expect dentro de un .then().
// Estilo encadenado: reintenta hasta que se cumple
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('Duración (horas)').should('have.value', '2');
// Estilo expect: dentro de then, sin reintentos
cy.get('[data-testid="total-reserva"]').then(($el) => {
expect($el.text()).to.match(/12,00\s*€/);
});| Aserción | Comprueba |
|---|---|
should('be.visible') |
Visible de verdad: en pantalla, no tapado, con tamaño |
should('not.exist') |
No está en el DOM |
should('be.disabled') / ('not.be.disabled') |
Estado del control |
should('have.length', n) |
Número de elementos |
should('contain.text', t) |
Contiene ese texto |
should('have.value', v) |
Valor de un campo |
should('have.attr', a, v) |
Atributo, útil para aria-* |
should('have.class', c) |
Clase: evítalo con CSS Modules |
.and(...) |
Encadena otra aserción sobre el mismo elemento |
La diferencia entre not.exist y not.be.visible importa: la primera exige que el elemento no esté en el DOM; la segunda acepta que esté pero oculto. Para un modal cerrado, la correcta depende de cómo lo implemente Modal, y elegir mal produce una prueba que pasa por el motivo equivocado.
- Control de la red con
cy.intercept
cy.interceptcy.intercept hace tres cosas: observar peticiones para poder esperarlas, sustituir respuestas y retrasarlas.
Observar y esperar
it('carga el catálogo desde la API', () => {
// El alias permite esperar a ESTA petición concreta
cy.intercept('GET', '**/bicicletas*').as('cargarBicicletas');
cy.visit('/');
cy.wait('@cargarBicicletas').its('response.statusCode').should('eq', 200);
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
});cy.wait('@alias') es la única espera legítima de Cypress, porque espera a un suceso concreto y no a un tiempo. Se usa cuando hace falta afirmar sobre la petición —su cuerpo, su código de estado— o cuando la interfaz no cambia de forma observable al completarse.
Verificar qué se envía
cy.intercept('POST', '**/reservas').as('crearReserva');
// … rellenar y enviar …
cy.wait('@crearReserva').then(({ request, response }) => {
expect(request.body).to.include({
bicicletaId: 'bici-001',
usuario: 'usr-01',
horas: 2,
estado: 'activa'
});
expect(response.statusCode).to.eq(201);
});Sustituir la respuesta
// Un error del servidor
cy.intercept('GET', '**/bicicletas*', { statusCode: 500, body: {} }).as('fallo');
// Una lista vacía
cy.intercept('GET', '**/bicicletas*', { body: [] });
// Datos fijos desde cypress/fixtures/bicicletas.json
cy.intercept('GET', '**/bicicletas*', { fixture: 'bicicletas.json' });
// Una respuesta lenta, para comprobar el esqueleto
cy.intercept('GET', '**/bicicletas*', (req) => {
req.reply({ delay: 1000, fixture: 'bicicletas.json' });
});
// Fallar solo la primera vez, para probar el reintento
cy.intercept('GET', '**/bicicletas*', { statusCode: 500, times: 1 });Cuándo usar la API real y cuándo simularla
| Situación | Decisión | Por qué |
|---|---|---|
| Los tres flujos críticos | API real (json-server) |
Es lo que da a una prueba e2e su valor: verificar la integración de verdad |
| Comprobar el estado de error | Simular con intercept |
Apagar json-server a mitad de prueba no es viable |
| Comprobar una lista vacía | Simular | Vaciar la base de datos afectaría a otras pruebas |
| Comprobar el esqueleto de carga | Simular con delay |
La API real responde demasiado rápido para observarlo |
| Verificar el cuerpo enviado | Observar con alias, sin sustituir | Se quiere la integración real y poder afirmar sobre la petición |
| Un servicio externo de pago | Simular siempre | No se llama a un tercero desde una prueba |
La regla: el camino feliz de los flujos críticos va contra la API real; los caminos alternativos se simulan. Si simulas el camino feliz, la prueba e2e deja de aportar nada que no aportara ya la de 09-04, y habrás pagado el coste sin recibir el beneficio.
- Estado inicial y aislamiento entre pruebas
El problema fundamental de las pruebas e2e con API real: la base de datos guarda lo que hacen. Si una prueba crea una reserva, la siguiente empieza con una reserva de más, y el resultado depende del orden.
La solución en CicloUrbano tiene tres piezas.
db.json con un estado semilla reiniciable
{
"bicicletas": [
{ "id": "bici-001", "modelo": "Urbana Clásica", "tipo": "urbana", "estado": "disponible", "estacionId": "est-01", "precioHora": 2.5 },
{ "id": "bici-002", "modelo": "Eléctrica Pro", "tipo": "electrica", "estado": "alquilada", "estacionId": "est-01", "precioHora": 4.0 },
{ "id": "bici-003", "modelo": "Carga Max", "tipo": "carga", "estado": "mantenimiento", "estacionId": "est-02", "precioHora": 5.5 },
{ "id": "bici-004", "modelo": "Urbana Clásica", "tipo": "urbana", "estado": "disponible", "estacionId": "est-03", "precioHora": 2.5 },
{ "id": "bici-005", "modelo": "Eléctrica Pro", "tipo": "electrica", "estado": "disponible", "estacionId": "est-02", "precioHora": 4.0 }
],
"estaciones": [
{ "id": "est-01", "nombre": "Plaza Mayor", "barrio": "Centro", "plazas": 20 },
{ "id": "est-02", "nombre": "Parque Norte", "barrio": "Norte", "plazas": 15 },
{ "id": "est-03", "nombre": "Estación Central", "barrio": "Ensanche", "plazas": 30 }
],
"reservas": [
{ "id": "res-01", "bicicletaId": "bici-002", "usuario": "usr-01", "fechaInicio": "2026-05-04T09:00", "horas": 2, "estado": "activa" }
]
}Se guarda una copia intacta en cypress/fixtures/db.semilla.json y un comando propio la restaura:
// cypress/support/comandos.js
Cypress.Commands.add('sembrarDatos', () => {
cy.fixture('db.semilla.json').then((semilla) => {
// Borra lo que haya y repone el estado conocido
cy.request('GET', `${Cypress.env('apiUrl')}/reservas`).then(({ body }) => {
body.forEach((reserva) => {
cy.request('DELETE', `${Cypress.env('apiUrl')}/reservas/${reserva.id}`);
});
});
semilla.reservas.forEach((reserva) => {
cy.request('POST', `${Cypress.env('apiUrl')}/reservas`, reserva);
});
semilla.bicicletas.forEach((bicicleta) => {
cy.request('PUT', `${Cypress.env('apiUrl')}/bicicletas/${bicicleta.id}`, bicicleta);
});
});
});cy.request hace peticiones HTTP fuera del navegador: no pasa por la aplicación, no dispara la interfaz y es rápido. Es la herramienta correcta para preparar y limpiar; nunca se prepara el escenario pulsando botones.
Aislamiento automático del navegador
Desde Cypress 12, cada it empieza con localStorage, sessionStorage y las cookies limpios (testIsolation: true, el valor por defecto). Eso resuelve la mitad del problema y crea el otro: si cada prueba empieza sin sesión, hay que volver a identificarse en cada una. Para eso está cy.session.
cy.session: no repetir el inicio de sesión
cy.session: no repetir el inicio de sesiónIdentificarse por la interfaz en cada prueba cuesta segundos y, sobre todo, acopla todas las pruebas al formulario de acceso: un cambio en PaginaAcceso rompería las veinte.
cy.session ejecuta el acceso una sola vez, guarda el estado resultante —localStorage, cookies, sessionStorage— y lo restaura en las pruebas siguientes.
// cypress/support/comandos.js
Cypress.Commands.add('accederComo', (usuarioId) => {
cy.session(
// 1) Clave de la sesión: sesiones distintas para usuarios distintos
['sesion', usuarioId],
// 2) Cómo se establece la sesión. Se ejecuta la primera vez y cuando falla la validación
() => {
cy.visit('/acceso');
cy.findByLabelText('Identifícate como').select(usuarioId);
cy.findByRole('button', { name: /Entrar/ }).click();
cy.url().should('not.include', '/acceso');
},
// 3) Validación: si devuelve algo falso o falla, la sesión se rehace
{
validate() {
cy.window().its('localStorage')
.invoke('getItem', 'ciclourbano:usuario')
.should('contain', usuarioId);
},
cacheAcrossSpecs: true // se reutiliza también entre ficheros de pruebas
}
);
});describe('Reservar una bicicleta', () => {
beforeEach(() => {
cy.sembrarDatos();
cy.accederComo('usr-01'); // instantáneo a partir de la segunda vez
cy.visit('/');
});
// …
});El bloque validate es la pieza que hace fiable el mecanismo: comprueba que la sesión restaurada sigue siendo válida y, si no lo es, vuelve a ejecutar el acceso. Sin él, una sesión caducada produciría fallos incomprensibles en pruebas que nada tienen que ver con el acceso.
Y una regla derivada: el flujo de acceso se prueba una vez, por la interfaz, en su propio fichero. Las demás pruebas usan cy.accederComo.
- Flujo 1: identificarse
// cypress/e2e/acceso.cy.js
describe('Identificarse en CicloUrbano', () => {
beforeEach(() => {
cy.sembrarDatos();
});
it('permite entrar como cliente y muestra su nombre en la cabecera', () => {
cy.visit('/acceso');
cy.findByRole('heading', { name: /Acceso a CicloUrbano/ }).should('be.visible');
cy.findByLabelText('Identifícate como').select('usr-01');
cy.findByRole('button', { name: /Entrar/ }).click();
// Redirección al catálogo
cy.url().should('eq', `${Cypress.config('baseUrl')}/`);
cy.porTestId('menu-usuario').should('contain.text', 'Ana Ribera');
});
it('devuelve al usuario a la pantalla que intentaba visitar', () => {
// Sin sesión, /reservas expulsa al acceso guardando el origen (06-05)
cy.visit('/reservas');
cy.url().should('include', '/acceso');
cy.findByRole('alert').should('contain.text', '/reservas');
cy.findByLabelText('Identifícate como').select('usr-01');
cy.findByRole('button', { name: /Entrar/ }).click();
// Y vuelve al destino original, no al inicio
cy.url().should('include', '/reservas');
cy.findByRole('heading', { name: /Tus reservas/ }).should('be.visible');
});
it('la sesión sobrevive a una recarga de la página', () => {
cy.accederComo('usr-01');
cy.visit('/');
cy.porTestId('menu-usuario').should('contain.text', 'Ana Ribera');
cy.reload();
cy.porTestId('menu-usuario').should('contain.text', 'Ana Ribera');
cy.url().should('not.include', '/acceso');
});
it('un cliente no puede entrar en el taller', () => {
cy.accederComo('usr-01');
cy.visit('/taller');
cy.url().should('include', '/sin-permisos');
cy.findByRole('heading', { name: /No tienes permiso/ }).should('be.visible');
});
it('un operario sí entra en el taller', () => {
cy.accederComo('usr-02');
cy.visit('/taller');
cy.url().should('include', '/taller');
cy.findByRole('heading', { name: /Taller/ }).should('be.visible');
});
it('cerrar sesión devuelve al acceso y no deja volver atrás', () => {
cy.accederComo('usr-01');
cy.visit('/reservas');
cy.porTestId('menu-usuario').findByRole('button', { name: /Salir/ }).click();
cy.url().should('include', '/acceso');
cy.go('back');
cy.url().should('include', '/acceso'); // la protección sigue actuando
});
});Las dos pruebas que solo puede hacer Cypress están aquí: la de la recarga (cy.reload()), que verifica que la sesión persiste de verdad en el navegador, y la del botón atrás (cy.go('back')), que verifica que la protección de rutas de 06-05 no se puede saltar con el historial. Ninguna de las dos existe en un enrutador en memoria.
- Flujo 2: reservar una bicicleta
El camino del dinero, de principio a fin.
// cypress/e2e/reservar.cy.js
describe('Reservar una bicicleta', () => {
beforeEach(() => {
cy.sembrarDatos();
cy.accederComo('usr-01');
});
it('recorre el flujo completo: catálogo → ficha → reserva → lista', () => {
// Se observa la petición para poder afirmar sobre el cuerpo enviado,
// pero NO se sustituye: el camino feliz va contra la API real
cy.intercept('POST', '**/reservas').as('crearReserva');
cy.visit('/');
// 1) El catálogo carga y bici-001 está disponible
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
cy.tarjetaDe('bici-001').should('contain.text', 'Disponible');
// 2) Se abre su ficha
cy.tarjetaDe('bici-001').findByRole('button', { name: 'Ver ficha' }).click();
cy.url().should('include', '/bicicletas/bici-001');
cy.findByRole('heading', { name: 'Urbana Clásica' }).should('be.visible');
cy.contains('Plaza Mayor').should('be.visible');
// 3) Se inicia la reserva desde la ficha
cy.findByRole('button', { name: /Reservar esta bicicleta/ }).click();
cy.url().should('include', '/reservas/nueva');
// 4) Se rellena el formulario
cy.findByLabelText('Bicicleta').should('have.value', 'bici-001'); // preseleccionada
cy.findByLabelText('Inicio de la reserva').type('2030-06-01T10:00');
cy.findByLabelText('Duración (horas)').clear().type('3');
cy.findByRole('checkbox', { name: /Acepto las condiciones/ }).check();
// El total derivado: 2,50 € × 3 h
cy.porTestId('total-reserva').should('contain.text', '7,50');
// 5) Se envía
cy.findByRole('button', { name: 'Crear reserva' }).click();
// 6) Se verifica lo que salió por la red
cy.wait('@crearReserva').then(({ request, response }) => {
expect(response.statusCode).to.eq(201);
expect(request.body).to.include({
bicicletaId: 'bici-001',
usuario: 'usr-01',
fechaInicio: '2030-06-01T10:00',
horas: 3,
estado: 'activa'
});
expect(request.body.id).to.match(/^res-[a-z0-9]{8}$/);
});
// 7) Confirmación y navegación
cy.findByRole('status').should('contain.text', 'Reserva creada');
cy.url().should('include', '/reservas');
// 8) La reserva nueva está en la lista, junto a la de la semilla
cy.get('[data-testid="fila-reserva"]').should('have.length', 2);
cy.contains('[data-testid="fila-reserva"]', 'Urbana Clásica')
.should('contain.text', '3 h')
.and('contain.text', 'Activa');
// 9) Y persiste tras recargar: se guardó de verdad en el servidor
cy.reload();
cy.get('[data-testid="fila-reserva"]').should('have.length', 2);
});
it('no deja reservar una bicicleta en mantenimiento', () => {
cy.visit('/');
cy.tarjetaDe('bici-003')
.should('contain.text', 'En mantenimiento')
.findByRole('button', { name: 'Reservar' })
.should('be.disabled');
});
it('muestra los errores de validación y no envía nada', () => {
// Si se enviara algo, este alias lo detectaría
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('Duración (horas)').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 y no pierde los datos introducidos', () => {
// Aquí SÍ se simula: el camino alternativo no se puede provocar de otro modo
cy.intercept('POST', '**/reservas', { statusCode: 500, body: {} }).as('crearReservaFallida');
cy.visit('/reservas/nueva');
cy.findByLabelText('Bicicleta').select('bici-001');
cy.findByLabelText('Inicio de la reserva').type('2030-06-01T10:00');
cy.findByLabelText('Duración (horas)').clear().type('2');
cy.findByRole('checkbox', { name: /Acepto las condiciones/ }).check();
cy.findByRole('button', { name: 'Crear reserva' }).click();
cy.wait('@crearReservaFallida');
cy.findByRole('alert').should('contain.text', 'No se ha podido crear la reserva');
// El trabajo del usuario sigue ahí
cy.findByLabelText('Duración (horas)').should('have.value', '2');
cy.url().should('include', '/reservas/nueva');
});
});Tres detalles que merecen atención:
- La fecha es
2030-06-01, no una fecha cercana.validarReservarechaza el pasado, y una fecha fija de 2026 convertiría la prueba en una bomba de relojería que empieza a fallar sola. En Cypress no hay temporizadores falsos cómodos, así que la solución es una fecha lo bastante lejana. La alternativa —cy.clock()— existe, pero interfiere con las peticiones y complica más de lo que resuelve. cy.get('@crearReserva.all').should('have.length', 0)comprueba que no se ha enviado ninguna petición. Es la forma correcta de verificar que la validación de cliente bloquea el envío.- El paso 9, la recarga, es lo que distingue esta prueba de la de 09-04. Allí se verificó que la interfaz se actualizaba tras la invalidación; aquí se verifica que el dato llegó al servidor y sigue ahí.
- Flujo 3: cancelar una reserva
// cypress/e2e/cancelar.cy.js
describe('Cancelar una reserva', () => {
beforeEach(() => {
cy.sembrarDatos();
cy.accederComo('usr-01');
cy.visit('/reservas');
});
it('cancela una reserva activa tras confirmarlo en el diálogo', () => {
cy.intercept('PATCH', '**/reservas/res-01').as('cancelarReserva');
cy.contains('[data-testid="fila-reserva"]', 'Eléctrica Pro')
.should('contain.text', 'Activa')
.findByRole('button', { name: /Cancelar/ })
.click();
// El diálogo modal debe recibir el foco y ser accesible (03-06)
cy.findByRole('dialog').should('be.visible').within(() => {
cy.findByRole('heading', { name: /¿Cancelar la reserva/ }).should('be.visible');
cy.contains('Eléctrica Pro').should('be.visible');
cy.findByRole('button', { name: /Sí, cancelar/ }).click();
});
cy.wait('@cancelarReserva').then(({ request, response }) => {
expect(request.body).to.include({ estado: 'cancelada' });
expect(response.statusCode).to.eq(200);
});
cy.findByRole('dialog').should('not.exist');
cy.findByRole('status').should('contain.text', 'Reserva cancelada');
// La fila sigue ahí, pero con otro estado: cancelar no elimina (09-02)
cy.contains('[data-testid="fila-reserva"]', 'Eléctrica Pro')
.should('contain.text', 'Cancelada');
cy.contains('[data-testid="fila-reserva"]', 'Eléctrica Pro')
.findByRole('button', { name: /Cancelar/ })
.should('not.exist');
});
it('cerrar el diálogo sin confirmar no cancela nada', () => {
cy.intercept('PATCH', '**/reservas/*').as('cancelarReserva');
cy.contains('[data-testid="fila-reserva"]', 'Eléctrica Pro')
.findByRole('button', { name: /Cancelar/ })
.click();
cy.findByRole('dialog').findByRole('button', { name: /No, mantenerla/ }).click();
cy.findByRole('dialog').should('not.exist');
cy.contains('[data-testid="fila-reserva"]', 'Eléctrica Pro').should('contain.text', 'Activa');
cy.get('@cancelarReserva.all').should('have.length', 0);
});
it('el diálogo se cierra con la tecla Escape', () => {
cy.contains('[data-testid="fila-reserva"]', 'Eléctrica Pro')
.findByRole('button', { name: /Cancelar/ })
.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 cancelación persiste tras recargar y libera la bicicleta', () => {
cy.contains('[data-testid="fila-reserva"]', 'Eléctrica Pro')
.findByRole('button', { name: /Cancelar/ })
.click();
cy.findByRole('dialog').findByRole('button', { name: /Sí, cancelar/ }).click();
cy.findByRole('status').should('contain.text', 'Reserva cancelada');
cy.reload();
cy.contains('[data-testid="fila-reserva"]', 'Eléctrica Pro').should('contain.text', 'Cancelada');
// Y bici-002 vuelve a estar disponible en el catálogo
cy.visit('/');
cy.tarjetaDe('bici-002').should('contain.text', 'Disponible');
});
it('avisa si la cancelación falla en el servidor', () => {
cy.intercept('PATCH', '**/reservas/*', { statusCode: 500, body: {} }).as('cancelacionFallida');
cy.contains('[data-testid="fila-reserva"]', 'Eléctrica Pro')
.findByRole('button', { name: /Cancelar/ })
.click();
cy.findByRole('dialog').findByRole('button', { name: /Sí, cancelar/ }).click();
cy.wait('@cancelacionFallida');
cy.findByRole('alert').should('contain.text', 'No se ha podido cancelar');
// El estado no ha cambiado: no hay actualización optimista mal revertida
cy.contains('[data-testid="fila-reserva"]', 'Eléctrica Pro').should('contain.text', 'Activa');
});
});cy.within() limita las consultas al interior del elemento, y es imprescindible con modales: sin él, findByRole('button', { name: /Cancelar/ }) encontraría también el botón de la fila que hay detrás y fallaría por ambigüedad.
La última prueba verifica algo que solo se manifiesta al integrar: que si el servidor rechaza la cancelación, la interfaz vuelve al estado anterior. Es el fallo clásico de una actualización optimista mal revertida, y produce el peor de los resultados: el usuario cree que canceló y no canceló.
- Depurar una prueba que falla
Cypress es, con diferencia, la herramienta con mejor depuración de todo el módulo.
| Herramienta | Cómo se usa | Para qué |
|---|---|---|
| Viaje por los pasos | Pasar el ratón por cada comando en el panel izquierdo | Ver el DOM tal como estaba en ese instante. Lo más útil de todo |
| Capturas automáticas | cypress/screenshots/ en modo run |
Ver el estado exacto del fallo en integración continua |
| Vídeo automático | cypress/videos/ con video: true |
Reconstruir toda la ejecución |
cy.pause() |
Se inserta en la cadena | Detiene la ejecución y permite avanzar comando a comando |
.debug() |
cy.get('...').debug() |
Detiene y expone el elemento en la consola del navegador |
cy.log('…') |
En cualquier punto | Anotaciones en el registro de comandos |
| Consola del navegador | Clic en un comando del panel | Imprime el elemento, la petición o el valor obtenidos |
it('depuración', () => {
cy.visit('/');
cy.pause(); // se detiene aquí
cy.get('[data-testid="tarjeta-bicicleta"]').debug().first().click();
cy.log('Ficha abierta');
});Los errores más frecuentes y su lectura:
| Mensaje | Qué significa | Solución |
|---|---|---|
Timed out retrying: Expected to find element |
El elemento nunca apareció | ¿Selector correcto? ¿Hacía falta identificarse? Mira el DOM en el paso previo |
element is not visible because it has CSS property: display: none |
Está pero oculto | Falta abrir el panel, o hay un fallo real de visibilidad |
element is being covered by another element |
Tapado: el fallo que justifica Cypress | Arreglar la maquetación, no la prueba |
element has become detached from the DOM |
El elemento se volvió a montar entre la consulta y la acción | Consulta única (cy.get('ul li')), no cadena en dos pasos |
cy.wait() timed out waiting for route '@alias' |
La petición nunca se hizo | ¿Coincide el patrón de URL? ¿Se envió realmente? |
- Integración continua con GitHub Actions
Una suite que solo se ejecuta cuando alguien se acuerda no protege nada. Este es el flujo mínimo que lanza los dos niveles en cada cambio:
# .github/workflows/pruebas.yml
name: Pruebas
on:
push:
branches: [main]
pull_request:
jobs:
unitarias:
name: Unitarias y de componentes
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álisis estático
run: npm run lint
- name: Vitest con cobertura
run: npm run cobertura
- name: Publicar el informe de cobertura
uses: actions/upload-artifact@v4
if: always()
with:
name: cobertura
path: coverage/
e2e:
name: Extremo a extremo
runs-on: ubuntu-latest
needs: unitarias # si fallan las rápidas, no se gastan minutos en las lentas
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Compilar la aplicación
run: npm run build
- name: Cypress contra el build de producción
uses: cypress-io/github-action@v6
with:
# Arranca los dos servidores, espera a que respondan y lanza las pruebas
start: npm run dev:todo
wait-on: 'http://localhost:5173, http://localhost:3001'
wait-on-timeout: 120
browser: chrome
- name: Guardar capturas si algo falla
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-capturas
path: cypress/screenshots
- name: Guardar los vídeos
uses: actions/upload-artifact@v4
if: always()
with:
name: cypress-videos
path: cypress/videosLas decisiones que hacen útil este flujo:
needs: unitariasordena los trabajos por coste: las pruebas rápidas primero. SivalidarReservaestá roto, no tiene sentido gastar tres minutos de navegador para descubrirlo.npm cien vez denpm installinstala exactamente lo delpackage-lock.json: reproducible y más rápido.if: failure()en las capturas solo las sube cuando hacen falta, y son lo primero que se mira al investigar un fallo que no se reproduce en local.- Modo sin cabeza por defecto.
cypress runno abre ventana, que es lo único posible en un servidor de integración continua.cypress openes solo para desarrollo. - La acción oficial
cypress-io/github-actionya cachea el binario de Cypress —que pesa cientos de megabytes— entre ejecuciones.
Y una recomendación de proceso: configura la rama principal para que exija que este flujo pase antes de integrar. Una suite verde que nadie está obligado a respetar acaba en rojo permanente.
- Cypress frente a Playwright
Playwright, de Microsoft, es la principal alternativa. No se desarrolla en este curso, pero conviene conocer el mapa para elegir con criterio.
| Criterio | Cypress | Playwright |
|---|---|---|
| Modelo de ejecución | Dentro del navegador, junto a la aplicación | Fuera, controlando el navegador por protocolo |
| API | Cadena de comandos con reintentos, sin await |
async/await estándar |
| Navegadores | Chrome, Edge, Firefox, WebKit (experimental) | Chromium, Firefox y WebKit, todos de primera clase |
| Varias pestañas o dominios | Limitado por diseño | Soporte completo |
| Paralelismo | Requiere Cypress Cloud o configuración propia | Nativo y gratuito |
| Velocidad | Buena | Mejor, sobre todo en paralelo |
| Depuración interactiva | Excelente: viaje por los pasos, DOM histórico | Buena: trace viewer, muy potente pero a posteriori |
| Curva de aprendizaje | Suave; la cola de comandos despista al principio | Más natural para quien conoce async/await |
| Espera automática | Sí | Sí |
| Generación de pruebas | No | codegen graba la interacción y escribe la prueba |
| Madurez del ecosistema | Muy alta, muchos complementos | Alta y creciendo rápido |
| Pruebas de componentes | Sí (experimental) | Sí (experimental) |
Criterios de elección:
- Elige Cypress si el equipo empieza en pruebas e2e y valora la experiencia de depuración —el viaje por los pasos es difícil de superar—, si la aplicación es de un solo dominio y una sola pestaña, o si el ecosistema de complementos pesa.
- Elige Playwright si necesitas cobertura real de varios navegadores, si el flujo cruza dominios o pestañas (pasarelas de pago, OAuth externo), si el paralelismo gratuito importa para el tiempo de integración continua, o si el equipo prefiere
async/await. - Ninguna elección es irreversible, y ambas comparten los conceptos que de verdad importan: selectores estables, aislamiento entre pruebas, control de la red y espera por resultado. Lo aprendido aquí se traslada casi entero.
Para CicloUrbano, Cypress es la elección: un dominio, una pestaña, un equipo que empieza y una necesidad clara de diagnosticar rápido.
- La estrategia completa del módulo
flowchart TB
subgraph E2E["🌐 Extremo a extremo · Cypress · 3 flujos · segundos"]
E1["Identificarse: redirección, persistencia, roles, botón atrás"]
E2["Reservar: catálogo → ficha → formulario → API → lista → recarga"]
E3["Cancelar: modal, PATCH, estado, bicicleta liberada"]
end
subgraph INT["🔗 Integración · RTL + MSW · la mayoría · decenas de ms"]
I1["PaginaCatalogo: carga, éxito, error 500, lista vacía, reintento"]
I2["FormularioReserva: errores, aria-describedby, camino feliz"]
I3["Mutaciones: cuerpo enviado, invalidación, refresco"]
I4["TarjetaBicicleta · SelectorTipo · EtiquetaEstado"]
I5["Hooks: useAlternar, useDebounce, useAlmacenLocal"]
I6["Rutas: parámetros, ?tipo=, navegación, RequiereRol"]
end
subgraph UNI["🧪 Unitarias · Vitest · lógica pura · milisegundos"]
U1["validarReserva: 4 reglas y todos los casos límite"]
U2["sliceReservas: ciclo de vida y guardas de negocio"]
U3["Selectores: derivación y memorización"]
U4["clases · retrasar · disponibilidad"]
end
subgraph EST["⚡ Estática · ESLint · continua · instantánea"]
S1["react-hooks: dependencias y reglas de los hooks"]
S2["jsx-a11y: accesibilidad mientras escribes"]
S3["no-focused-tests: ningún test.only olvidado"]
end
EST --> UNI --> INT --> E2E
| Nivel | Cuántas | Qué protege | Cuándo se ejecuta |
|---|---|---|---|
| Estática | Continua | Erratas, hooks mal usados, accesibilidad básica | Al escribir y en el flujo de CI |
| Unitaria | Decenas | Reglas de negocio y casos límite | En cada guardado (modo vigilancia) |
| Integración | La mayoría | El cableado entre piezas y los estados de la interfaz | En cada guardado y en CI |
| Extremo a extremo | Tres flujos | Que la aplicación completa funciona de verdad | En CI, y antes de desplegar |
Y la comprobación final: vuelve a las nueve refactorizaciones del módulo 8. Hoy, cambiar la firma de ListaBicicletas, mover estado, reescribir el value de un contexto o partir las rutas en fragmentos produce una respuesta en segundos —verde o rojo— en lugar de una sesión de clics a ciegas. Ese era exactamente el vacío que este módulo venía a llenar.
Errores Comunes y Consejos
- Intentar usar
awaitcon los comandos de Cypress. No son promesas: son entradas en una cola. Para trabajar con un valor,.then(). - Guardar el resultado de
cy.geten una variable. No contiene un elemento. Si necesitas reutilizar una consulta, usa.as('alias')y luegocy.get('@alias'). cy.wait(3000)para «dar tiempo». Lento siempre e insuficiente a veces. La espera automática ya cubre el caso; y si necesitas esperar a una petición concreta,cy.wait('@alias').- Encadenar consultas sobre listas que se vuelven a montar. Produce «elemento separado del DOM». Una sola consulta:
cy.get('ul li'), nocy.get('ul').find('li'). - Identificarse por la interfaz en cada prueba. Multiplica el tiempo y acopla todas las pruebas al formulario de acceso.
cy.sessioncon suvalidate. - No reiniciar la base de datos entre pruebas. El resultado empieza a depender del orden, y aparece la inestabilidad más difícil de diagnosticar.
cy.sembrarDatos()en elbeforeEach. - Simular toda la red en las pruebas e2e. Si simulas el camino feliz, la prueba deja de aportar nada que no diera ya la de 09-04. El camino feliz va contra la API real; los caminos alternativos se simulan.
- Escribir veinte pruebas e2e. La suite se vuelve lenta e inestable y el equipo acaba desactivándola. Los flujos críticos y nada más; el resto, un nivel más abajo.
- Usar fechas cercanas en los datos de prueba. Una fecha de 2026 en una regla que rechaza el pasado convierte la prueba en una bomba de relojería. Fechas lejanas o reloj controlado.
- Consejo: cuando una prueba falle, mira primero el DOM del paso anterior. El viaje por los pasos suele dar la respuesta antes que ninguna otra herramienta, y a menudo revela que el fallo estaba dos comandos atrás.
- Consejo: escribe los
data-testida la vez que el componente, no cuando escribas la prueba. Así el anclaje forma parte del diseño del componente y no de un parche posterior. - Consejo: una prueba e2e por flujo, no por aserción. Estas pruebas son caras de arrancar; recorrer un flujo completo comprobando cosas por el camino aprovecha mucho mejor ese coste que diez pruebas que visitan la misma página.
Ejercicios
Ejercicio 1. Escribe la prueba de extremo a extremo del flujo del operario: Marc Solé (usr-02) entra, va a /taller, marca bici-001 como «en mantenimiento» y comprueba que en el catálogo público esa bicicleta ya no se puede reservar. Usa cy.accederComo, siembra los datos y verifica la persistencia con una recarga. Indica qué peticiones observarías con cy.intercept y cuáles no.
Ejercicio 2. Esta prueba falla de forma intermitente en integración continua y siempre pasa en local. Identifica cinco problemas y reescríbela.
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');
});Ejercicio 3. El equipo discute si probar con Cypress el filtro por tipo de bicicleta (?tipo=electrica), que ya tiene pruebas en 09-04 con MSW. Argumenta con el criterio de decisión del apartado 2 si merece una prueba e2e, y propón qué sí habría que añadir en su lugar. Después, describe cómo comprobarías con Cypress algo que MSW no puede: que al pulsar «Eléctrica» la URL cambia, que la vista sobrevive a una recarga y que el botón atrás devuelve al filtro anterior.
Soluciones
Solución 1.
// cypress/e2e/taller.cy.js
describe('Flujo del operario', () => {
beforeEach(() => {
cy.sembrarDatos();
cy.accederComo('usr-02'); // Marc Solé, rol operario
});
it('poner una bicicleta en mantenimiento la retira del catálogo público', () => {
// Se observa la actualización para afirmar sobre el cuerpo; NO se sustituye:
// el camino feliz debe llegar a json-server de verdad
cy.intercept('PATCH', '**/bicicletas/bici-001').as('actualizarBicicleta');
cy.visit('/taller');
cy.findByRole('heading', { name: /Taller/ }).should('be.visible');
cy.tarjetaDe('bici-001').should('contain.text', 'Disponible');
cy.tarjetaDe('bici-001')
.findByRole('button', { name: /Enviar a mantenimiento/ })
.click();
cy.wait('@actualizarBicicleta').then(({ request, response }) => {
expect(request.body).to.include({ estado: 'mantenimiento' });
expect(response.statusCode).to.eq(200);
});
cy.findByRole('status').should('contain.text', 'Bicicleta enviada a mantenimiento');
cy.tarjetaDe('bici-001').should('contain.text', 'En mantenimiento');
// El efecto en el catálogo público
cy.visit('/');
cy.tarjetaDe('bici-001')
.should('contain.text', 'En mantenimiento')
.findByRole('button', { name: 'Reservar' })
.should('be.disabled');
// Y persiste: el cambio llegó al servidor
cy.reload();
cy.tarjetaDe('bici-001').findByRole('button', { name: 'Reservar' }).should('be.disabled');
});
it('un cliente no ve el enlace al taller ni puede entrar', () => {
cy.accederComo('usr-01');
cy.visit('/');
cy.findByRole('navigation').findByRole('link', { name: /Taller/ }).should('not.exist');
cy.visit('/taller');
cy.url().should('include', '/sin-permisos');
});
});Qué se observa y qué no:
| Petición | ¿cy.intercept? |
Por qué |
|---|---|---|
PATCH /bicicletas/bici-001 |
Sí, observar con alias | Hay que afirmar sobre el cuerpo (estado: 'mantenimiento') y esperar a que termine |
GET /bicicletas del catálogo |
No | La espera automática de cy.get ya cubre la carga; interceptarlo añadiría ruido |
GET /estaciones |
No | Es un detalle de la pantalla, no del flujo probado |
| La sesión de acceso | No | La resuelve cy.session fuera de esta prueba |
Y en ningún caso se sustituye la respuesta: el objetivo de esta prueba es precisamente verificar que el cambio llega a la base de datos y se refleja en otra pantalla.
Solución 2. Los cinco problemas:
- URL absoluta en
cy.visit. Ignora elbaseUrlde la configuración y rompe la prueba en cualquier entorno donde el puerto sea otro. Debe sercy.visit('/reservas/nueva'). - Selectores por clase de CSS Modules y por estructura del DOM.
.formulario_a3f9es un hash generado que cambia en cada compilación, y> div:nth-child(2) > selectse rompe con cualquier ajuste de maquetación. Es la causa más probable de que falle en CI, donde el build es distinto al de desarrollo. cy.wait(2000)ycy.wait(3000). Esperas fijas: lentas en local, insuficientes en un contenedor de integración continua cargado. Son la causa directa de la intermitencia, y además innecesarias porque los comandos ya reintentan.- No se identifica ni se siembran los datos. Sin sesión,
/reservas/nuevaestá protegida y redirige a/acceso; y sin datos conocidos, el resultado depende de lo que dejaran otras pruebas. - El formulario no se rellena entero. Solo se elige la bicicleta: faltan la fecha, las horas y las condiciones, así que la validación lo rechazaría y el aviso de «creada» no llegaría nunca. El nombre
it('reserva')tampoco dice qué comportamiento se verifica.
Reescrita:
describe('Crear una reserva', () => {
beforeEach(() => {
cy.sembrarDatos();
cy.accederComo('usr-01');
});
it('crea la reserva y confirma el resultado al usuario', () => {
cy.intercept('POST', '**/reservas').as('crearReserva');
cy.visit('/reservas/nueva');
cy.findByLabelText('Bicicleta').select('bici-001');
cy.findByLabelText('Inicio de la reserva').type('2030-06-01T10:00');
cy.findByLabelText('Duración (horas)').clear().type('2');
cy.findByRole('checkbox', { name: /Acepto las condiciones/ }).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ón 3. No merece una prueba e2e, y el criterio del apartado 2 lo dice de tres formas: no está en el camino del dinero (filtrar no genera reservas), se puede comprobar igual de bien con una prueba de integración —y de hecho ya está hecho en 09-04, verificando la cadena URL → useSearchParams → clave de consulta → petición → lista—, y su fallo no sería catastrófico: el usuario vería más bicicletas de las que pidió, no perdería dinero ni datos.
Qué habría que añadir en su lugar: nada nuevo a nivel e2e. Si sobra presupuesto de pruebas, es mucho más rentable reforzar el flujo de reserva —por ejemplo, reservar desde la ficha de una bicicleta a la que se llegó con el filtro aplicado, comprobando que el filtro no se pierde al volver atrás—, porque eso sí atraviesa varias pantallas y no lo cubre ninguna otra capa.
Ahora bien, hay tres cosas del filtro que MSW no puede comprobar y que sí justificarían unas líneas dentro de una prueba e2e existente:
it('el filtro vive en la URL y sobrevive a la navegación del navegador', () => {
cy.visit('/');
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
// 1) Pulsar el filtro cambia 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 sobrevive a una recarga 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ón atrás del navegador devuelve al filtro anterior
cy.findByRole('button', { name: 'De carga' }).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);
});Las tres comprobaciones son imposibles con createMemoryRouter: la URL del navegador no cambia, no hay recarga y el historial no es el del navegador. Justamente por eso, en el módulo 6 se argumentó que poner el filtro en la URL lo hacía compartible y navegable; esta prueba es la que verifica esa promesa. Es un buen ejemplo del criterio general: no se sube un caso a e2e porque sea importante, sino porque solo ahí se puede comprobar.
Conclusión
Con esta lección se cierra el Módulo 9, y CicloUrbano pasa de no tener ninguna red de seguridad a tener cuatro niveles que se solapan de forma deliberada.
Lo esencial de Cypress. Una prueba de extremo a extremo ve lo que ninguna otra ve: enrutamiento real con su URL, su recarga y su botón atrás; CSS y visibilidad de verdad, incluido el botón tapado por otro elemento; persistencia de la sesión entre navegaciones; red real contra json-server; y varias pantallas encadenadas ejecutando el mismo build que se despliega. A cambio cuesta segundos por prueba, es la capa más propensa a inestabilidad y exige infraestructura, así que el criterio de selección es estricto: camino del dinero, varias capas atravesadas, fallo catastrófico e invisible, y nada que se pueda comprobar igual de bien un nivel más abajo. En CicloUrbano eso son exactamente tres flujos: identificarse, reservar y cancelar.
Del funcionamiento, lo que hay que interiorizar es que cy.get(...) no devuelve un elemento y nunca se usa await: el cuerpo del it solo encola comandos, y Cypress los ejecuta después, en orden, esperando a cada uno. Para trabajar con un valor, .then(). Y cada consulta y cada should reintentan hasta encontrar lo que buscan, lo que elimina de raíz las esperas explícitas de 09-04 —con la trampa de que los reintentos aplican al último comando de la cadena, de ahí el «elemento separado del DOM» y la regla de usar una consulta única. Los comandos de acción, además, exigen que el elemento sea accionable, y ahí está la detección del botón tapado.
Los selectores cambian de criterio respecto a 09-03: data-testid deja de ser el último recurso y pasa a ser el contrato explícito para los anclajes estructurales que deben sobrevivir a rediseños, combinado con findByRole de Testing Library para los elementos interactivos. Quedan fijados cypress.config.js con su baseUrl, cypress/support/comandos.js con cy.porTestId, cy.tarjetaDe, cy.sembrarDatos y cy.accederComo, y los tres ficheros cypress/e2e/acceso.cy.js, reservar.cy.js y cancelar.cy.js. El arranque lo resuelve start-server-and-test, que sondea Vite y json-server hasta que responden antes de lanzar nada.
Sobre la red, la regla es clara: el camino feliz va contra la API real —si lo simulas, la prueba e2e no aporta nada que no diera la de 09-04— y los caminos alternativos se simulan con cy.intercept, que además permite observar una petición con un alias para afirmar sobre su cuerpo y su código de estado sin sustituirla. El aislamiento se apoya en tres piezas: db.json reiniciable desde una semilla con cy.request, el aislamiento automático del navegador entre pruebas, y cy.session con su bloque validate, que ejecuta el acceso una vez y lo restaura después. Y la depuración —el viaje por los pasos con el DOM histórico, las capturas y el vídeo automáticos, cy.pause y .debug()— es la mejor de todo el módulo.
Con GitHub Actions, los dos niveles se ejecutan en cada cambio, ordenados por coste: linter y Vitest primero, Cypress después con needs, capturas subidas solo cuando algo falla y el binario cacheado por la acción oficial. Y ya sabes situar Playwright en el mapa: mejor para varios navegadores, varias pestañas, dominios cruzados y paralelismo gratuito; Cypress, mejor para empezar y para diagnosticar rápido en una aplicación de un solo dominio, que es el caso de CicloUrbano. Los conceptos que importan —selectores estables, aislamiento, control de la red, espera por resultado— se trasladan casi enteros entre ambas.
Con esto se cierra el Módulo 9. El vacío que dejó el módulo 8 está tapado: las nueve refactorizaciones que en su momento solo se comprobaron abriendo el navegador hoy producen una respuesta en segundos. La estrategia completa tiene cuatro niveles y cada uno hace lo que los otros no pueden: el análisis estático caza el hook mal declarado mientras escribes; las unitarias cubren validarReserva con todos sus casos límite, el ciclo de vida y las guardas de sliceReservas, y la memorización de los selectores, todo sin montar un solo componente; las de integración —el grueso del trofeo— prueban los componentes como los usa una persona, apoyadas en la accesibilidad del módulo 3, con el renderizar propio que envuelve los proveedores reales y con MSW simulando la red para verificar carga, éxito, error, lista vacía y reintento; y las tres pruebas de extremo a extremo confirman que la aplicación entera funciona de verdad, con su URL, su sesión, su base de datos y su navegador. El principio que las gobierna a todas sigue siendo el de la primera lección: probar el comportamiento visible, no la implementación, para que las pruebas fallen cuando algo se rompe y solo entonces.
Lo que viene ahora cambia de terreno por completo. Hasta aquí, CicloUrbano ha sido una aplicación que se descarga al navegador y se ejecuta allí: rápida de desarrollar, pero con un primer pintado que espera al JavaScript y con un contenido que los buscadores ven a medias. El Módulo 10: Temas Avanzados ataca precisamente esa frontera y las que la rodean. Verás el renderizado en el servidor (SSR) con Next.js, donde el HTML llega ya construido; la generación de sitios estáticos (SSG), para el contenido que no cambia en cada visita; Suspense y los React Server Components, el modelo que redefine dónde se ejecuta cada componente y que reaprovecha todo lo que sabes de lazy y de estados de carga; TypeScript con React, que convierte en errores de compilación buena parte de lo que hoy solo detectan las pruebas —y que después de este módulo entenderás en su sitio exacto de la pirámide: análisis estático, la capa más barata—; y React Native, para llevar lo aprendido a aplicaciones móviles nativas. La próxima lección es Renderizado del Lado del Servidor (SSR) con Next.js.
Curso de React
Módulo 1: Introducción a React
- ¿Qué es React?
- Configuración del Entorno de Desarrollo
- Hola Mundo en React
- JSX: Extensión de Sintaxis de JavaScript
- Cómo Renderiza React: Virtual DOM y Reconciliación
Módulo 2: Componentes de React
- Entendiendo los Componentes
- Componentes Funcionales vs de Clase
- Props: Pasando Datos a Componentes
- State: Gestión del Estado del Componente
- Estilos en los Componentes: CSS, Módulos y Utilidades
Módulo 3: Trabajando con Eventos
- Manejo de Eventos en React
- Renderizado Condicional
- Listas y Claves
- Formularios y Componentes Controlados
- Validación de Formularios y Componentes No Controlados
- Accesibilidad en Componentes Interactivos
Módulo 4: Conceptos Avanzados de Componentes
- Elevando el Estado
- Composición vs Herencia
- Métodos del Ciclo de Vida de React
- Hooks: Introducción y Uso Básico
- Límites de Error: Capturar Fallos en la Interfaz
Módulo 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef y Acceso al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalizados
Módulo 6: Enrutamiento en React
- Introducción a React Router
- Configuración de React Router
- Rutas Anidadas
- Navegación Programática
- Rutas Protegidas y Control de Acceso
Módulo 7: Gestión del Estado
- Introducción a la Gestión del Estado
- API de Contexto
- Redux: Introducción y Configuración
- Redux: Acciones y Reductores
- Redux: Conectando a React
- Estado del Servidor: Peticiones, Caché y Sincronización
Módulo 8: Optimización del Rendimiento
- Técnicas de Optimización del Rendimiento en React
- Memorización con React.memo
- Hooks useMemo y useCallback
- División de Código y Carga Perezosa
- Medir el Rendimiento con React DevTools Profiler
Módulo 9: Pruebas en React
- Introducción a las Pruebas
- Pruebas Unitarias con Jest
- Pruebas de Componentes con React Testing Library
- Pruebas de Código Asíncrono y Simulación de APIs
- Pruebas de Extremo a Extremo con Cypress
Módulo 10: Temas Avanzados
- Renderizado del Lado del Servidor (SSR) con Next.js
- Generación de Sitios Estáticos (SSG) con Next.js
- Suspense y React Server Components
- TypeScript con React
- React Native: Creación de Aplicaciones Móviles
