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

  1. Qué ve una prueba de extremo a extremo que las demás no
  2. Qué cuesta, y qué flujos merecen ese coste
  3. Instalación y estructura del proyecto
  4. Arrancar la aplicación y la API antes de las pruebas
  5. Anatomía de una prueba de Cypress
  6. La naturaleza asíncrona y con reintentos: por qué no se usa await
  7. Selectores robustos: el contrato data-testid
  8. Aserciones con should y encadenamiento
  9. Control de la red con cy.intercept
  10. Estado inicial y aislamiento entre pruebas
  11. cy.session: no repetir el inicio de sesión
  12. Flujo 1: identificarse
  13. Flujo 2: reservar una bicicleta
  14. Flujo 3: cancelar una reserva
  15. Depurar una prueba que falla
  16. Integración continua con GitHub Actions
  17. Cypress frente a Playwright
  18. La estrategia completa del módulo

  1. 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.

  1. 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:

  1. Identificarse. Sin esto no hay nada más. Atraviesa formulario, Redux, redirección y persistencia.
  2. Reservar una bicicleta. Es el camino del dinero: catálogo, filtro, ficha, formulario, mutación, invalidación y navegación.
  3. 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).

  1. Instalación y estructura del proyecto

npm install -D cypress
npx cypress open        # la primera vez crea la estructura de carpetas
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.
npm install -D @testing-library/cypress

  1. 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:

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

    "test": "vitest",
    "test: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.

  1. 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

  1. La naturaleza asíncrona y con reintentos: por qué no se usa await

Este 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.

  1. Selectores robustos: el contrato data-testid

En 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.

  1. Aserciones con should y encadenamiento

Cypress 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.

  1. Control de la red con cy.intercept

cy.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.

  1. 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);
    });
  });
});
beforeEach(() => {
  cy.sembrarDatos();
});

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.

  1. cy.session: no repetir el inicio de sesión

Identificarse 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.

  1. 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.

  1. 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. validarReserva rechaza 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í.

  1. 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ó.

  1. 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?

  1. 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/videos

Las decisiones que hacen útil este flujo:

  • needs: unitarias ordena los trabajos por coste: las pruebas rápidas primero. Si validarReserva está roto, no tiene sentido gastar tres minutos de navegador para descubrirlo.
  • npm ci en vez de npm install instala exactamente lo del package-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 run no abre ventana, que es lo único posible en un servidor de integración continua. cypress open es solo para desarrollo.
  • La acción oficial cypress-io/github-action ya 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.

  1. 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
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.

  1. 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 await con los comandos de Cypress. No son promesas: son entradas en una cola. Para trabajar con un valor, .then().
  • Guardar el resultado de cy.get en una variable. No contiene un elemento. Si necesitas reutilizar una consulta, usa .as('alias') y luego cy.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'), no cy.get('ul').find('li').
  • Identificarse por la interfaz en cada prueba. Multiplica el tiempo y acopla todas las pruebas al formulario de acceso. cy.session con su validate.
  • 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 el beforeEach.
  • 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-testid a 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é 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:

  1. URL absoluta en cy.visit. Ignora el baseUrl de la configuración y rompe la prueba en cualquier entorno donde el puerto sea otro. Debe ser cy.visit('/reservas/nueva').
  2. Selectores por clase de CSS Modules y por estructura del DOM. .formulario_a3f9 es un hash generado que cambia en cada compilación, y > div:nth-child(2) > select se 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.
  3. cy.wait(2000) y cy.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.
  4. No se identifica ni se siembran los datos. Sin sesión, /reservas/nueva está protegida y redirige a /acceso; y sin datos conocidos, el resultado depende de lo que dejaran otras pruebas.
  5. 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

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados