Las 124 pruebas de la lección anterior se ejecutan en jsdom, que es una imitación del navegador: no pinta nada, todas las medidas valen cero, el service worker de 07-05 no existe y el index.html, el estilos.css y el manifest.json reales no se han cargado ni una vez. Un botón tapado por otro elemento, un import sin extensión que el navegador rechaza, un CSS que oculta la columna de tareas hechas o un worker que sirve una versión antigua de la aplicación: nada de eso aparece en la suite, y todo eso deja a Marta mirando una pantalla rota. Esta lección cierra el módulo abriendo la aplicación de verdad, en un navegador de verdad, y usándola como la usaría ella. Aprenderás la arquitectura de Cypress y por qué es distinta de la de cualquier otro ejecutor, la anatomía de una prueba con selectores estables, el modelo de encadenamiento y espera automática que hace de cy.wait(3000) un antipatrón, la intercepción de red con cy.intercept, la siembra de estado con cy.session, los comandos personalizados y las fixtures, la ejecución en integración continua, una comparativa honesta con Playwright, y —lo más importante— qué no se debe probar en E2E.

Contenido

  1. Qué valida una prueba de extremo a extremo
  2. El coste: lentas, frágiles y caras de mantener
  3. Cypress: instalación y estructura del proyecto
  4. La arquitectura: dentro del navegador
  5. El ejecutor interactivo frente al modo run
  6. Anatomía de una prueba
  7. Selectores estables: data-cy y por qué no la clase CSS
  8. Encadenamiento y espera automática
  9. Por qué cy.wait(3000) es un antipatrón
  10. Interceptar la red con cy.intercept
  11. Sembrar el estado inicial
  12. Comandos personalizados y fixtures
  13. Recorrido 1: crear una tarea y verla en el tablero
  14. Recorrido 2: completarla y comprobar el resumen
  15. Recorrido 3: filtrar por responsable con la URL compartible
  16. Depurar un fallo: capturas, vídeos y viaje en el tiempo
  17. Ejecutar en integración continua
  18. Accesibilidad automatizada con axe
  19. Cypress frente a Playwright
  20. Qué NO probar en E2E
  21. Errores Comunes y Consejos
  22. Ejercicios
  23. Conclusión

  1. Qué valida una prueba de extremo a extremo

Una prueba E2E arranca la aplicación real en un navegador real y la usa desde fuera, exactamente como una persona. No importa nada de tu código: lo único que toca es la interfaz.

Y por eso valida cosas que ninguna de las 124 pruebas anteriores puede tocar:

Solo lo ve una prueba E2E Por qué
Que index.html carga y enlaza bien los módulos jsdom no carga el HTML real ni resuelve los import del navegador
Que el CSS no tapa ni oculta nada jsdom no pinta: todas las medidas son cero
Que el botón es realmente pulsable Un elemento tapado por otro recibe el clic en jsdom y no en el navegador
Que los módulos ES cargan sin errores Un import './tarea' sin extensión falla solo en el navegador (08-02)
Que el service worker sirve lo correcto jsdom no lo implementa
Que la aplicación entera arranca Nadie ha ejecutado nunca js/app.js completo
Que el recorrido de negocio funciona de principio a fin Es la única prueba que responde «¿esto sirve?»

La última fila es la clave. Todas las pruebas anteriores responden preguntas técnicas: ¿esta función calcula bien?, ¿estas dos piezas se entienden? Una prueba E2E responde la única pregunta que le importa a Marta: ¿puedo crear una tarea, marcarla como hecha y ver el resumen correcto?

flowchart LR
    subgraph J["Pruebas en jsdom (08-05)"]
        A["Tu código JS"] --> B["DOM simulado"]
    end
    subgraph C["Prueba E2E"]
        D["Navegador real"] --> E["index.html real"]
        E --> F["css/estilos.css"]
        E --> G["js/app.js + 14 módulos"]
        G --> H["Service worker"]
        G --> I["Red"]
    end
    style C fill:#dbeafe,stroke:#1d4ed8

  1. El coste: lentas, frágiles y caras de mantener

Si son tan convincentes, ¿por qué no hacerlo todo así? Porque el precio es alto, y conviene verlo con números:

Dimensión Unitaria Integración E2E
Tiempo por prueba ~2 ms ~30 ms 2-10 s
Suite completa 0,9 s 3,7 s 3-15 min
Precisión al fallar La línea exacta El módulo «algo del recorrido»
Fragilidad Muy baja Media Alta
Coste de mantenimiento Bajo Medio Alto
Confianza que aporta Baja Media Máxima

Los tres problemas específicos:

Son lentas. Cada prueba arranca un navegador, carga la aplicación, espera la red y hace clics reales. Cien pruebas E2E son quince minutos, y una suite de quince minutos deja de ejecutarse en cada commit.

Son frágiles. Cambiar el texto de un botón, mover un elemento o añadir una animación puede romperlas. Y sufren la flakiness de 08-05 multiplicada: red real, temporizadores reales, animaciones, carreras.

Cuando fallan, no dicen dónde. «El recorrido de crear tarea falla» puede ser el formulario, el modelo, la API, el render o el CSS. Toca investigar con el método de 08-01.

De ahí la conclusión que gobierna toda la lección: pocas pruebas E2E, y muy bien elegidas. Tres o cuatro recorridos que representen el valor real del producto. Si te encuentras escribiendo la vigésima, casi seguro que estás cubriendo en E2E algo que pertenece a un nivel inferior.

  1. Cypress: instalación y estructura del proyecto

npm install --save-dev cypress
npx cypress open        # la primera vez, crea la estructura
nomada-tareas/
  cypress.config.js
  cypress/
    e2e/                        ← las pruebas
      crear-tarea.cy.js
      completar-tarea.cy.js
      filtrar-por-responsable.cy.js
    fixtures/                   ← datos de prueba
      backlog.json
    support/
      commands.js               ← comandos personalizados
      e2e.js                    ← se ejecuta antes de cada fichero
    screenshots/                ← capturas automáticas de los fallos (no versionar)
    videos/                     ← grabaciones (no versionar)
// cypress.config.js
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    // Base de todas las cy.visit(): permite escribir cy.visit('/')
    baseUrl: 'http://localhost:5173',

    // Tamaño de la ventana: fijarlo hace las pruebas reproducibles
    viewportWidth: 1280,
    viewportHeight: 800,

    // Tiempos límite. 4 s por defecto suele bastar; súbelos solo con motivo
    defaultCommandTimeout: 5000,
    requestTimeout: 8000,

    video: true,
    screenshotOnRunFailure: true,

    // Cypress reintenta un fichero fallido en CI: mitiga la intermitencia
    // sin ocultarla del todo, porque el informe marca la prueba como inestable
    retries: { runMode: 2, openMode: 0 }
  }
});

Y en .gitignore:

cypress/screenshots/
cypress/videos/

  1. La arquitectura: dentro del navegador

Aquí está la diferencia más importante entre Cypress y el resto de herramientas, y explica casi todas sus virtudes y sus límites.

La mayoría de los ejecutores E2E (Selenium, Playwright, Puppeteer) funcionan fuera del navegador: envían órdenes por un protocolo de automatización y esperan la respuesta. Cypress, en cambio, se ejecuta dentro del mismo bucle de eventos que tu aplicación: carga tu página en un iframe y corre a su lado.

flowchart TD
    subgraph N["Navegador"]
        subgraph P["Proceso de la pestaña"]
            C["Cypress<br/>tu prueba"]
            A["Nómada Tareas<br/>en un iframe"]
            C <-->|acceso directo<br/>mismo bucle de eventos| A
        end
    end
    S["Proceso de Node<br/>servidor, ficheros, tareas"] <-->|websocket| C
    style C fill:#bbf7d0,stroke:#15803d

Consecuencias a favor:

  • Espera automática de verdad. Como Cypress ve el DOM directamente, sabe cuándo un elemento aparece sin sondear desde fuera.
  • Acceso al código de la aplicación. Puedes espiar window, llamar a funciones, interceptar fetch desde dentro.
  • Depuración excepcional. Los mensajes de la consola, los errores y el estado del DOM en cada paso están al alcance.
  • Instantáneas del DOM en cada comando: el «viaje en el tiempo» del apartado 16.

Consecuencias en contra, y conviene conocerlas antes de elegir:

  • Un solo navegador por prueba. No puedes probar dos pestañas ni dos sesiones a la vez.
  • Restricciones de origen. Navegar entre dominios distintos dentro de una misma prueba requiere cy.origin().
  • No hay pestañas nuevas. Un enlace con target="_blank" hay que gestionarlo de otra manera.
  • La prueba se ejecuta en el navegador, así que el código de Node (leer un fichero, lanzar un proceso) necesita cy.task().

Para Nómada Tareas —una aplicación de una sola página, un solo origen— ninguna de esas limitaciones estorba.

  1. El ejecutor interactivo frente al modo run

Cypress tiene dos modos, y se usan en momentos distintos:

npx cypress open      # interactivo: navegador visible, recarga al guardar
npx cypress run       # sin interfaz: para CI, con vídeo y capturas
open (interactivo) run (consola)
Navegador Visible, se puede inspeccionar Sin interfaz (headless)
Al guardar el fichero Recarga y reejecuta
Viaje en el tiempo No (pero graba vídeo)
Velocidad Menor Mayor
Uso Escribir y depurar Integración continua

Los scripts del proyecto:

{
  "scripts": {
    "dev": "vite",
    "e2e:abrir": "cypress open",
    "e2e": "cypress run",
    "e2e:ci": "start-server-and-test dev http://localhost:5173 e2e"
  }
}

start-server-and-test arranca el servidor de desarrollo, espera a que responda en la URL indicada, lanza las pruebas y al terminar mata el servidor. Sin él, la integración continua tendría que arrancar el servidor en segundo plano y adivinar cuándo está listo, que es precisamente el tipo de espera por tiempo que causa intermitencia.

  1. Anatomía de una prueba

// cypress/e2e/primer-vistazo.cy.js
describe('Tablero de Nómada Tareas', () => {
  beforeEach(() => {
    cy.visit('/');                                    // carga la aplicación real
  });

  it('muestra las seis tareas del backlog al arrancar', () => {
    cy.get('[data-cy="tarjeta"]').should('have.length', 6);
    cy.contains('Rediseñar la sala polivalente').should('be.visible');
    cy.get('[data-cy="resumen"]').should('contain', '45');
  });
});

Los comandos esenciales:

Comando Qué hace
cy.visit(url) Carga una página
cy.get(selector) Selecciona elementos, esperando a que existan
cy.contains(texto) Selecciona por texto visible
cy.find(selector) Busca dentro del elemento actual
.click() Pulsa (comprueba antes que sea pulsable de verdad)
.type(texto) Escribe, tecla a tecla
.select(valor) Elige en un <select>
.clear() Vacía un campo
.should(aserción) Comprueba, reintentando hasta el tiempo límite
.and(aserción) Encadena otra aserción sobre lo mismo
cy.url() La URL actual, para comprobarla
cy.intercept(...) Intercepta peticiones de red

Y describe, it, beforeEach son los mismos de Jest: Cypress usa Mocha por debajo, así que la estructura te resulta familiar.

  1. Selectores estables: data-cy y por qué no la clase CSS

En 08-05 la respuesta era consultar por rol accesible. En E2E, la recomendación de Cypress es distinta y merece entenderse: atributos dedicados a las pruebas.

<!-- En index.html y en las plantillas -->
<li class="tarea" data-cy="tarjeta" data-id="3" data-estado="pendiente">
  <h3 class="tarea__titulo" data-cy="titulo">Actualizar la web de reservas</h3>
  <button data-accion="avanzar" data-cy="avanzar">Empezar</button>
</li>

<form id="form-tarea" data-cy="form-tarea">
  <input id="titulo" name="titulo" data-cy="campo-titulo">
  <button type="submit" data-cy="crear">Crear tarea</button>
</form>
// ❌ Frágil: se rompe al retocar el CSS o la estructura
cy.get('.columna:nth-child(1) > ul > li:first-child .tarea__titulo');
cy.get('#tablero > section > ul > li').first();

// ✅ Estable: el atributo existe SOLO para las pruebas y nadie lo toca al maquetar
cy.get('[data-cy="tarjeta"]').first().find('[data-cy="titulo"]');
Selector Estabilidad Problema
.tarea__titulo ❌ Baja Cambia al refactorizar el CSS
#form-tarea ⚠️ Media Los id cambian y a veces se reutilizan
nth-child, > ❌ Muy baja Cualquier elemento nuevo lo rompe
Texto visible ⚠️ Media Cambia al corregir la redacción o traducir
[data-cy] Alta Ninguno: existe solo para esto

¿Y el rol accesible, que en 08-05 era la respuesta? Sigue siendo excelente, y merece usarse para lo que es la experiencia del usuario: cy.contains('button', 'Crear tarea') documenta que ese botón dice eso. La combinación que mejor funciona es usar data-cy para localizar y aserciones sobre texto y estado para verificar:

cy.get('[data-cy="crear"]')                        // localizar: estable
  .should('be.visible')
  .and('contain', 'Crear tarea');                  // verificar: lo que ve el usuario

Y un detalle de higiene: añade data-cy solo donde una prueba lo necesite. Sembrar el HTML de atributos que nadie usa es ruido.

  1. Encadenamiento y espera automática

Este es el modelo mental que hay que interiorizar, porque es distinto de todo lo anterior.

Los comandos de Cypress no se ejecutan cuando los escribes: se encolan. Todo el cuerpo del it se recorre primero, encolando comandos, y después se ejecutan uno a uno, de forma asíncrona.

it('demuestra la cola de comandos', () => {
  cy.visit('/');                          // encolado 1
  cy.get('[data-cy="tarjeta"]');          // encolado 2
  console.log('esto se imprime PRIMERO'); // ← se ejecuta ya, antes que 1 y 2
});

De ahí sale el error más común de quien empieza:

// ❌ NO funciona: cy.get() no devuelve el elemento, devuelve una cadena de comandos
const tarjetas = cy.get('[data-cy="tarjeta"]');
expect(tarjetas.length).to.equal(6);      // undefined

// ✅ El valor se recibe en un callback
cy.get('[data-cy="tarjeta"]').then(($tarjetas) => {
  expect($tarjetas).to.have.length(6);
});

// ✅ Mejor aún: la aserción encadenada, que además REINTENTA
cy.get('[data-cy="tarjeta"]').should('have.length', 6);

La espera automática es la segunda pieza. Cada comando reintenta hasta que se cumple su condición o se agota el tiempo límite:

  • cy.get('[data-cy="tarjeta"]') reintenta hasta que el elemento exista en el DOM.
  • .should('be.visible') reintenta hasta que sea visible (no display:none, no visibility:hidden, con tamaño).
  • .click() comprueba antes que el elemento exista, sea visible, no esté tapado por otro, no esté deshabilitado y no se esté moviendo (por una animación).

Esa comprobación de «no tapado por otro» es una de las joyas del nivel E2E: es exactamente el fallo que jsdom no puede detectar. Si un modal transparente cubre el botón, Cypress falla con un mensaje explícito señalando el elemento que estorba.

CypressError: cy.click() failed because this element is being covered by another element:
<div class="overlay">...</div>

  1. Por qué cy.wait(3000) es un antipatrón

Con el modelo anterior claro, la conclusión es inevitable:

// ❌ El antipatrón
cy.get('[data-cy="crear"]').click();
cy.wait(3000);                                    // "por si acaso tarda"
cy.get('[data-cy="tarjeta"]').should('have.length', 7);

Los cuatro problemas:

  1. Si tarda más de 3 s, falla igual. No has resuelto nada; has puesto un límite arbitrario distinto del que ya tenías.
  2. Si tarda 100 ms, has perdido 2,9 s. Multiplicado por veinte esperas, son casi un minuto por ejecución.
  3. Oculta un problema real. ¿Por qué tarda? Puede ser una petición innecesaria o una carrera, y la espera fija lo tapa.
  4. Es inestable por naturaleza. El servidor de CI es más lento que tu portátil: el número que funciona en local no funciona allí.

La alternativa es siempre la misma idea de 08-05: esperar a una condición, no a un tiempo.

// ✅ Esperar al estado esperado: reintenta hasta 5 s y sigue en cuanto se cumple
cy.get('[data-cy="crear"]').click();
cy.get('[data-cy="tarjeta"]').should('have.length', 7);

// ✅ Esperar a una PETICIÓN concreta, no a un tiempo
cy.intercept('POST', '**/tareas').as('crearTarea');
cy.get('[data-cy="crear"]').click();
cy.wait('@crearTarea');                        // ← este cy.wait SÍ es correcto
cy.get('[data-cy="tarjeta"]').should('have.length', 7);

// ✅ Esperar a que algo desaparezca
cy.get('[data-cy="cargando"]').should('not.exist');

Fíjate en la distinción: cy.wait('@alias') es correcto y cy.wait(3000) no. El primero espera a un suceso concreto —esa petición ha terminado— y continúa en cuanto ocurre. El segundo cuenta el reloj a ciegas.

La única excepción legítima al cy.wait(ms) es probar deliberadamente que no ocurre algo durante un intervalo («el buscador no lanza la petición antes de 300 ms»), y aun así suele haber una forma mejor de expresarlo.

  1. Interceptar la red con cy.intercept

cy.intercept intercepta las peticiones del navegador. Sirve para tres cosas distintas, y conviene distinguirlas:

A · Observar, sin modificar nada:

cy.intercept('GET', '**/tareas').as('listar');
cy.visit('/');
cy.wait('@listar').its('response.statusCode').should('eq', 200);

B · Fijar la respuesta (stubbing), para que la prueba no dependa del servidor:

cy.intercept('GET', '**/tareas', { fixture: 'backlog.json' }).as('listar');

C · Simular un fallo, que es lo que en un servidor real es casi imposible:

cy.intercept('GET', '**/tareas', { statusCode: 500, body: { mensaje: 'Error interno' } });
cy.intercept('GET', '**/tareas', { forceNetworkError: true });     // caída de red
cy.intercept('GET', '**/tareas', { statusCode: 200, body: [], delay: 2000 });  // lentitud

La decisión de fondo: ¿servidor real o respuestas fijas?

Servidor real cy.intercept con datos fijos
Realismo Máximo: prueba el contrato completo Medio: el servidor podría cambiar sin que te enteres
Velocidad Baja Alta
Determinismo Bajo: los datos cambian Alto: siempre el backlog canónico
Provocar errores Muy difícil Trivial
Depende de otro equipo No

La política recomendada, que es la que seguiremos: el recorrido feliz principal contra un servidor real (aunque sea un json-server local con el backlog canónico, el de 07-02), y todo lo demás —errores, casos límite, estados vacíos— con cy.intercept. Así se comprueba el contrato de verdad al menos una vez y el resto de las pruebas son rápidas y deterministas.

Y aquí se cobra una decisión de 08-05: los manejadores de MSW que escribiste allí describen la misma API. Compartir esa definición entre integración y E2E evita que las dos suites simulen servidores distintos y ninguno se parezca al real.

  1. Sembrar el estado inicial

Cada prueba debe partir de un punto conocido: el mismo principio del beforeEach de 08-03, ahora con un navegador completo por medio.

Cypress limpia cookies y almacenamiento entre pruebas, pero el estado inicial hay que ponerlo. Tres técnicas, de peor a mejor:

1 · Por la interfaz (lento, frágil). Crear las seis tareas pulsando botones antes de cada prueba. Nunca hagas esto: multiplica el tiempo y, si el formulario se rompe, fallan todas las pruebas por el mismo motivo.

2 · Sembrando localStorage directamente. Rápido y directo, aprovechando el formato de 07-01:

// cypress/support/commands.js
Cypress.Commands.add('sembrarTablero', (tareas) => {
  cy.fixture('backlog.json').then((backlog) => {
    window.localStorage.setItem('nomada:tablero:v1', JSON.stringify({
      nombre: 'Taller Nómada',
      version: 1,
      tareas: tareas ?? backlog
    }));
  });
});

// Uso: se siembra ANTES de visitar, para que la app arranque con esos datos
beforeEach(() => {
  cy.clearLocalStorage();
  cy.sembrarTablero();
  cy.visit('/');
});

3 · cy.session para lo que es caro de repetir. Cachea el estado (cookies, localStorage, sessionStorage) tras la primera vez y lo restaura en las siguientes, sin volver a ejecutar los pasos:

Cypress.Commands.add('sesionDeMarta', () => {
  cy.session('marta', () => {
    cy.visit('/');
    cy.get('[data-cy="usuario"]').select('Marta');
    cy.get('[data-cy="entrar"]').click();
    cy.url().should('include', '/tablero');
  }, {
    // Comprobación de que la sesión restaurada sigue siendo válida
    validate() {
      cy.window().its('localStorage').invoke('getItem', 'nomada:usuario').should('eq', 'Marta');
    },
    cacheAcrossSpecs: true
  });
});

En una aplicación con autenticación, cy.session es la diferencia entre una suite de doce minutos y una de tres.

Y una nota sobre el service worker de 07-05, que en E2E puede confundir mucho: si sirve una versión cacheada de la aplicación, tus cambios no se ven y las pruebas fallan de forma incomprensible. Conviene desactivarlo durante las pruebas:

// cypress/support/e2e.js
beforeEach(() => {
  // Impedir que el service worker sirva una versión antigua durante las pruebas
  if (window.navigator?.serviceWorker) {
    cy.window().then((win) =>
      win.navigator.serviceWorker.getRegistrations()
        .then((registros) => registros.forEach((r) => r.unregister()))
    );
  }
});

Y una prueba específica, aparte, que sí compruebe el funcionamiento sin conexión. Mezclar ambas cosas produce fallos que nadie entiende.

  1. Comandos personalizados y fixtures

Los comandos personalizados extienden cy con acciones del dominio. Convierten una prueba en algo que se lee como una descripción del comportamiento:

// cypress/support/commands.js

/** Crea una tarea rellenando el formulario, como lo haría una persona. */
Cypress.Commands.add('crearTarea', ({
  titulo, responsable = 'Marta', prioridad = 'media',
  horas = 2, fecha = '2026-10-20', etiquetas = ''
} = {}) => {
  cy.get('[data-cy="campo-titulo"]').clear().type(titulo);
  cy.get('[data-cy="campo-responsable"]').select(responsable);
  cy.get('[data-cy="campo-prioridad"]').select(prioridad);
  cy.get('[data-cy="campo-horas"]').clear().type(String(horas));
  cy.get('[data-cy="campo-fecha"]').clear().type(fecha);
  if (etiquetas) cy.get('[data-cy="campo-etiquetas"]').clear().type(etiquetas);
  cy.get('[data-cy="crear"]').click();
});

/** Selecciona la tarjeta cuyo título contenga ese texto. */
Cypress.Commands.add('tarjeta', (titulo) =>
  cy.contains('[data-cy="tarjeta"]', titulo));

/** Aserción de dominio: el resumen muestra estas cifras. */
Cypress.Commands.add('resumenDebeDecir', ({ abiertas, horas }) => {
  cy.get('[data-cy="resumen"]')
    .should('contain', `${abiertas} abiertas`)
    .and('contain', `${horas} h`);
});

Con ellos, una prueba se lee así:

cy.crearTarea({ titulo: 'Revisar extintores', responsable: 'Marta', horas: 2 });
cy.tarjeta('Revisar extintores').should('be.visible');
cy.resumenDebeDecir({ abiertas: 6, horas: 47 });

Las fixtures son ficheros JSON con datos de prueba, y aquí van los seis canónicos:

// cypress/fixtures/backlog.json
[
  { "id": 1, "titulo": "Rediseñar la sala polivalente", "responsable": "Iván",
    "prioridad": "alta", "estado": "en-curso", "etiquetas": ["espacio", "diseño"],
    "horasEstimadas": 12, "fechaLimite": "2026-09-30", "revisor": "Marta" },
  { "id": 2, "titulo": "Cartelería del taller de serigrafía", "responsable": "Marta",
    "prioridad": "media", "estado": "pendiente", "etiquetas": ["serigrafía", "comunicación"],
    "horasEstimadas": 6, "fechaLimite": "2026-10-15", "revisor": null },
  { "id": 3, "titulo": "Actualizar la web de reservas", "responsable": "Lucía",
    "prioridad": "alta", "estado": "pendiente", "etiquetas": ["web", "reservas"],
    "horasEstimadas": 14, "fechaLimite": "2026-10-02", "revisor": "Iván" },
  { "id": 4, "titulo": "Inventario de tintas de serigrafía", "responsable": "Marta",
    "prioridad": "baja", "estado": "hecha", "etiquetas": ["serigrafía", "almacén"],
    "horasEstimadas": 3, "fechaLimite": "2026-09-12", "revisor": null },
  { "id": 5, "titulo": "Guía de encuadernación para residentes", "responsable": "Iván",
    "prioridad": "media", "estado": "en-curso", "etiquetas": ["encuadernación", "documentación"],
    "horasEstimadas": 8, "fechaLimite": "2026-11-05", "revisor": "Lucía" },
  { "id": 6, "titulo": "Presupuesto de la carpintería", "responsable": "Iván",
    "prioridad": "alta", "estado": "pendiente", "etiquetas": ["carpintería", "compras"],
    "horasEstimadas": 5, "fechaLimite": "2026-09-05", "revisor": "Marta" }
]
cy.intercept('GET', '**/tareas', { fixture: 'backlog.json' }).as('listar');

  1. Recorrido 1: crear una tarea y verla en el tablero

El primero de los tres recorridos críticos. Comprueba el camino completo: formulario → validación → modelo → API → render → persistencia.

// cypress/e2e/crear-tarea.cy.js
describe('Recorrido 1 · Crear una tarea', () => {
  beforeEach(() => {
    cy.clock(new Date('2026-09-20T09:00:00Z'), ['Date']);   // fecha canónica congelada
    cy.intercept('GET', '**/tareas', { fixture: 'backlog.json' }).as('listar');
    cy.visit('/');
    cy.wait('@listar');
    cy.get('[data-cy="tarjeta"]').should('have.length', 6);  // punto de partida conocido
  });

  it('Marta crea una tarea y la ve aparecer en la columna de pendientes', () => {
    cy.intercept('POST', '**/tareas', {
      statusCode: 201,
      body: { id: 7, titulo: 'Revisar extintores', responsable: 'Marta', prioridad: 'media',
              estado: 'pendiente', etiquetas: ['seguridad'], horasEstimadas: 2,
              fechaLimite: '2026-10-20', revisor: null }
    }).as('crear');

    cy.crearTarea({ titulo: 'Revisar extintores', responsable: 'Marta',
                    horas: 2, fecha: '2026-10-20', etiquetas: 'seguridad' });

    // 1 · La petición salió con los datos correctos
    cy.wait('@crear').its('request.body').should('deep.include', {
      titulo: 'Revisar extintores',
      responsable: 'Marta',
      horasEstimadas: 2
    });

    // 2 · La tarjeta aparece, en la columna correcta y con su contenido
    cy.tarjeta('Revisar extintores')
      .should('be.visible')
      .and('have.attr', 'data-estado', 'pendiente')
      .within(() => {
        cy.contains('Marta').should('be.visible');
        cy.contains('2 h').should('be.visible');
        cy.contains('seguridad').should('be.visible');
      });

    cy.get('[data-cy="columna-pendiente"] [data-cy="tarjeta"]').should('have.length', 4);

    // 3 · El resumen se ha actualizado: 47 h abiertas (45 + 2)
    cy.get('[data-cy="resumen"]').should('contain', '47');

    // 4 · El formulario se ha vaciado y el foco ha vuelto al primer campo
    cy.get('[data-cy="campo-titulo"]').should('have.value', '').and('be.focused');
  });

  it('sobrevive a una recarga: la tarea sigue ahí', () => {
    cy.intercept('POST', '**/tareas', { statusCode: 201, body: { id: 7, titulo: 'Revisar extintores',
      responsable: 'Marta', prioridad: 'media', estado: 'pendiente', etiquetas: [],
      horasEstimadas: 2, fechaLimite: '2026-10-20', revisor: null } });

    cy.crearTarea({ titulo: 'Revisar extintores' });
    cy.tarjeta('Revisar extintores').should('be.visible');

    cy.reload();                                  // ← la prueba que no existía en jsdom

    cy.tarjeta('Revisar extintores').should('be.visible');
  });

  it('un título vacío muestra el error accesible y no crea nada', () => {
    cy.get('[data-cy="campo-horas"]').type('2');
    cy.get('[data-cy="campo-fecha"]').type('2026-10-20');
    cy.get('[data-cy="crear"]').click();

    cy.get('[role="alert"]').should('be.visible').and('contain', 'título');
    cy.get('[data-cy="campo-titulo"]')
      .should('have.attr', 'aria-invalid', 'true')
      .and('be.focused');
    cy.get('[data-cy="tarjeta"]').should('have.length', 6);     // nada creado
  });

  it('un 500 del servidor muestra el error y el botón de reintentar (07-03)', () => {
    cy.intercept('POST', '**/tareas', { statusCode: 500, body: { mensaje: 'Error interno' } })
      .as('crearFallido');

    cy.crearTarea({ titulo: 'Revisar extintores' });
    cy.wait('@crearFallido');

    cy.get('[role="alert"]').should('be.visible').and('contain', 'servidor');
    cy.get('[data-cy="reintentar"]').should('be.visible');

    // Y al reintentar con el servidor recuperado, funciona
    cy.intercept('POST', '**/tareas', { statusCode: 201, body: { id: 7, titulo: 'Revisar extintores',
      responsable: 'Marta', prioridad: 'media', estado: 'pendiente', etiquetas: [],
      horasEstimadas: 2, fechaLimite: '2026-10-20', revisor: null } }).as('crearOk');

    cy.get('[data-cy="reintentar"]').click();
    cy.wait('@crearOk');

    cy.tarjeta('Revisar extintores').should('be.visible');
    cy.get('[role="alert"]').should('not.exist');
  });
});

Cuatro cosas de esta suite que solo son posibles en E2E: la recarga con cy.reload(), que comprueba la persistencia real de principio a fin; el should('be.focused'), que verifica la gestión del foco en un navegador de verdad; la comprobación de que la petición salió con el cuerpo correcto; y el ciclo completo error → reintento → éxito con el servidor cambiando de comportamiento a mitad de prueba.

cy.clock(fecha, ['Date']) congela solo Date y deja los temporizadores reales funcionando: así la tarea 6 sigue apareciendo vencida el día que se ejecute la prueba, sin romper las animaciones ni el debounce.

  1. Recorrido 2: completarla y comprobar el resumen

El segundo recorrido cubre las transiciones R6 y el recálculo de las cifras, con el ciclo estado → render de 06-06.

// cypress/e2e/completar-tarea.cy.js
describe('Recorrido 2 · Completar una tarea', () => {
  beforeEach(() => {
    cy.clock(new Date('2026-09-20T09:00:00Z'), ['Date']);
    cy.intercept('GET', '**/tareas', { fixture: 'backlog.json' }).as('listar');
    cy.intercept('PATCH', '**/tareas/*', (peticion) => {
      peticion.reply({ statusCode: 200, body: { ...peticion.body, id: Number(peticion.url.split('/').pop()) } });
    }).as('actualizar');
    cy.visit('/');
    cy.wait('@listar');
  });

  it('Marta lleva una tarea de pendiente a hecha y el resumen cuadra en cada paso', () => {
    // Estado inicial: 5 abiertas, 45 h
    cy.resumenDebeDecir({ abiertas: 5, horas: 45 });

    // ── Paso 1 · pendiente → en-curso ──────────────────────────────────
    cy.tarjeta('Cartelería del taller de serigrafía').within(() => {
      cy.get('[data-cy="avanzar"]').should('contain', 'Empezar').click();
    });
    cy.wait('@actualizar').its('request.body').should('deep.include', { estado: 'en-curso' });

    cy.tarjeta('Cartelería del taller de serigrafía')
      .should('have.attr', 'data-estado', 'en-curso')
      .find('[data-cy="avanzar"]').should('contain', 'Marcar hecha');

    cy.resumenDebeDecir({ abiertas: 5, horas: 45 });      // sigue abierta: no cambia

    // ── Paso 2 · en-curso → hecha ──────────────────────────────────────
    cy.tarjeta('Cartelería del taller de serigrafía')
      .find('[data-cy="avanzar"]').click();
    cy.wait('@actualizar').its('request.body').should('deep.include', { estado: 'hecha' });

    cy.resumenDebeDecir({ abiertas: 4, horas: 39 });      // 45 − 6

    // La tarjeta se ha movido a la columna de hechas
    cy.get('[data-cy="columna-hecha"]')
      .should('contain', 'Cartelería del taller de serigrafía');
    cy.get('[data-cy="columna-hecha"] [data-cy="tarjeta"]').should('have.length', 2);
  });

  it('una tarea hecha no se puede reabrir: el botón está desactivado (R6)', () => {
    cy.tarjeta('Inventario de tintas de serigrafía').within(() => {
      cy.get('[data-cy="avanzar"]')
        .should('be.disabled')
        .and('contain', 'Completada');
    });

    cy.resumenDebeDecir({ abiertas: 5, horas: 45 });      // nada ha cambiado
  });

  it('el reparto por responsable se recalcula al completar', () => {
    cy.get('[data-cy="carga-Iván"]').should('contain', '25');

    // La tarea 1 es de Iván: en-curso, 12 h
    cy.tarjeta('Rediseñar la sala polivalente').find('[data-cy="avanzar"]').click();
    cy.wait('@actualizar');

    cy.get('[data-cy="carga-Iván"]').should('contain', '13');   // 25 − 12
    cy.resumenDebeDecir({ abiertas: 4, horas: 33 });
  });

  it('si el servidor rechaza el cambio, la interfaz revierte (optimistic UI de 07-03)', () => {
    cy.intercept('PATCH', '**/tareas/2', { statusCode: 500, body: { mensaje: 'Error' } })
      .as('fallo');

    cy.tarjeta('Cartelería del taller de serigrafía').find('[data-cy="avanzar"]').click();
    cy.wait('@fallo');

    // La tarjeta vuelve a su estado anterior y se avisa
    cy.tarjeta('Cartelería del taller de serigrafía')
      .should('have.attr', 'data-estado', 'pendiente');
    cy.get('[role="alert"]').should('be.visible');
    cy.resumenDebeDecir({ abiertas: 5, horas: 45 });
  });
});

La última prueba es especialmente valiosa: comprobar una reversión optimista exige que el modelo, la vista y la capa de red colaboren correctamente ante un fallo. Ninguna prueba unitaria ni de integración cubre ese recorrido completo con el navegador real por medio.

  1. Recorrido 3: filtrar por responsable con la URL compartible

El tercero cubre el enrutador de 07-06 y una propiedad de producto concreta: que la URL se pueda compartir.

// cypress/e2e/filtrar-por-responsable.cy.js
describe('Recorrido 3 · Filtrar por responsable con URL compartible', () => {
  beforeEach(() => {
    cy.clock(new Date('2026-09-20T09:00:00Z'), ['Date']);
    cy.intercept('GET', '**/tareas*', { fixture: 'backlog.json' }).as('listar');
  });

  it('Iván filtra por su nombre y la URL refleja el filtro', () => {
    cy.visit('/');
    cy.wait('@listar');
    cy.get('[data-cy="tarjeta"]').should('have.length', 6);

    cy.get('[data-cy="filtro-Iván"]').click();

    cy.get('[data-cy="tarjeta"]').should('have.length', 3);
    cy.get('[data-cy="tarjeta"]').each(($t) => {
      cy.wrap($t).should('contain', 'Iván');
    });

    cy.url().should('include', 'responsable=Iv%C3%A1n');
    cy.get('[data-cy="filtro-Iván"]').should('have.attr', 'aria-pressed', 'true');

    // El filtro es PRESENTACIÓN: el resumen sigue siendo del tablero completo
    cy.resumenDebeDecir({ abiertas: 5, horas: 45 });
  });

  it('la URL filtrada, abierta directamente, muestra ya el filtro aplicado', () => {
    cy.visit('/?responsable=Lucía');
    cy.wait('@listar');

    cy.get('[data-cy="tarjeta"]').should('have.length', 1);
    cy.tarjeta('Actualizar la web de reservas').should('be.visible');
    cy.get('[data-cy="filtro-Lucía"]').should('have.attr', 'aria-pressed', 'true');
  });

  it('el botón atrás del navegador restaura el filtro anterior', () => {
    cy.visit('/');
    cy.wait('@listar');

    cy.get('[data-cy="filtro-Iván"]').click();
    cy.get('[data-cy="tarjeta"]').should('have.length', 3);

    cy.get('[data-cy="filtro-Marta"]').click();
    cy.get('[data-cy="tarjeta"]').should('have.length', 2);

    cy.go('back');                                       // ← imposible en jsdom

    cy.url().should('include', 'responsable=Iv%C3%A1n');
    cy.get('[data-cy="tarjeta"]').should('have.length', 3);

    cy.go('back');

    cy.url().should('not.include', 'responsable');
    cy.get('[data-cy="tarjeta"]').should('have.length', 6);
  });

  it('el buscador filtra sin ensuciar el historial', () => {
    cy.visit('/');
    cy.wait('@listar');

    cy.get('[data-cy="buscador"]').type('carpint');

    cy.get('[data-cy="tarjeta"]').should('have.length', 1);   // espera al debounce sola
    cy.url().should('include', 'q=carpint');

    cy.go('back');                                            // replaceState: vuelve al inicio

    cy.url().should('not.include', 'q=');
  });

  it('el enlace copiado lleva a la misma vista', () => {
    cy.visit('/?responsable=Iv%C3%A1n&orden=fecha');
    cy.wait('@listar');

    cy.get('[data-cy="tarjeta"]').should('have.length', 3);
    cy.get('[data-cy="tarjeta"]').first().should('contain', 'Presupuesto de la carpintería');
  });
});

Tres cosas aquí son exclusivas de E2E: cy.go('back') con el historial real del navegador; la comprobación de que ?responsable=Iván abierta en frío funciona, que es lo que hace que el enlace sea de verdad compartible; y la espera del debounce sin ningún cy.wait(300), porque la aserción reintenta hasta que la lista cambia.

  1. Depurar un fallo: capturas, vídeos y viaje en el tiempo

Cuando una prueba E2E falla en CI, tienes cuatro herramientas:

1 · La captura automática. Cypress guarda un PNG en cypress/screenshots/ en el momento exacto del fallo, con el nombre del fichero y de la prueba.

2 · El vídeo. Con video: true, la ejecución completa queda grabada. Es la forma más rápida de ver qué pasó antes del fallo.

3 · El viaje en el tiempo (modo interactivo). El panel izquierdo lista todos los comandos ejecutados; al pasar el ratón por uno, el navegador muestra el DOM tal como estaba en ese instante. Es literalmente rebobinar la prueba, y no tiene equivalente en ninguna otra herramienta.

4 · Los comandos de depuración, que conectan con todo lo de 08-01:

cy.get('[data-cy="tarjeta"]').debug();      // pausa e imprime el sujeto en la consola
cy.pause();                                  // detiene la prueba; se reanuda a mano
cy.get('[data-cy="resumen"]').then(($r) => {
  debugger;                                  // el depurador de 08-01, con el elemento a mano
});
cy.window().its('tablero').invoke('resumen', '2026-09-20').then(console.table);

Esa última línea es un puente precioso con la lección que abrió el módulo: cy.window() da acceso al window real de la aplicación, así que puedes inspeccionar el modelo vivo desde la prueba y volcarlo con console.table.

Y una funcionalidad muy útil en CI: retries en la configuración. Con runMode: 2, una prueba que falla se reintenta dos veces antes de darla por fallida, y el informe la marca como inestable. Es un equilibrio razonable: absorbe la intermitencia inevitable del entorno real (un servidor que tarda, un recurso que se retrasa) sin ocultarla, porque la marca de inestable sigue ahí para que la investigues. Lo que no debe hacerse nunca es subir el número hasta que todo pase.

  1. Ejecutar en integración continua

Ampliamos el flujo de 08-02, ahora con las tres capas de pruebas:

# .github/workflows/calidad.yml
name: Calidad

on:
  push:
    branches: [master]
  pull_request:

jobs:
  # ── Trabajo 1 · rápido: análisis estático y pruebas de Jest ────────────
  verificar:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: lts/*
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run format:check
      - run: npm test -- --coverage --ci

  # ── Trabajo 2 · lento: extremo a extremo ───────────────────────────────
  e2e:
    runs-on: ubuntu-latest
    needs: verificar             # ← solo si lo barato ya pasó: ahorra minutos
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: lts/*
          cache: npm

      # La acción oficial instala Cypress, cachea su binario, arranca el
      # servidor, espera a que responda y ejecuta las pruebas
      - name: Ejecutar Cypress
        uses: cypress-io/github-action@v6
        with:
          start: npm run dev
          wait-on: 'http://localhost:5173'
          wait-on-timeout: 120
          browser: chrome

      # Si algo falla, subimos las pruebas del delito
      - name: Guardar capturas
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: cypress-capturas
          path: cypress/screenshots
          retention-days: 7

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

Tres decisiones de este flujo:

  • needs: verificar encadena los trabajos: si el lint falla, no se gastan minutos en E2E. Lo barato primero.
  • wait-on espera a que el servidor responda de verdad, en lugar de dormir un número de segundos.
  • if: failure() sube las capturas solo cuando hay fallo, que es cuando sirven; los vídeos se suben siempre pero caducan en tres días.

Y una recomendación de calendario, porque el tiempo de CI es dinero y paciencia:

Cuándo Qué se ejecuta
Al guardar (editor) Prettier + ESLint
Pre-commit ESLint + Prettier sobre lo preparado
Cada push / PR Lint + Jest completo + los tres recorridos E2E
Cada noche E2E ampliado, varios navegadores, accesibilidad
Antes de desplegar Todo, contra el entorno de preproducción

  1. Accesibilidad automatizada con axe

axe es un motor de auditoría de accesibilidad que se integra con Cypress y comprueba automáticamente decenas de reglas de las WCAG:

npm install --save-dev cypress-axe axe-core
// cypress/support/e2e.js
import 'cypress-axe';
it('el tablero no tiene problemas graves de accesibilidad', () => {
  cy.visit('/');
  cy.injectAxe();

  cy.checkA11y(null, {
    includedImpacts: ['critical', 'serious']    // empieza por lo grave, no por todo
  });
});

it('el formulario con errores sigue siendo accesible', () => {
  cy.visit('/');
  cy.injectAxe();

  cy.get('[data-cy="crear"]').click();          // provoca los errores de validación

  cy.checkA11y('[data-cy="form-tarea"]');       // auditar solo esa región
});

Lo que axe detecta: contraste insuficiente, imágenes sin texto alternativo, campos sin etiqueta, atributos ARIA mal usados, encabezados desordenados, elementos interactivos inalcanzables con el teclado.

Y lo que conviene tener claro: las herramientas automáticas detectan en torno a un tercio de los problemas reales de accesibilidad. No comprueban si el orden de lectura tiene sentido, si un texto alternativo es útil o si la aplicación se puede usar con un lector de pantalla. Son un suelo mínimo, no un certificado. Dicho eso, ese tercio es barato de conseguir y cubre los fallos más frecuentes, así que merece estar en la suite.

Fíjate además en una coherencia agradable: consultar por rol accesible en 08-05 y auditar con axe aquí empujan en la misma dirección. Un HTML que se deja consultar por rol suele pasar axe sin esfuerzo.

  1. Cypress frente a Playwright

Playwright es la otra herramienta seria del ecosistema. Comparativa honesta:

Cypress Playwright
Arquitectura Dentro del navegador Fuera, por protocolo de automatización
Navegadores Chrome, Edge, Firefox, Electron; WebKit experimental Chromium, Firefox y WebKit de serie
Varias pestañas / ventanas No
Varios orígenes en una prueba Con cy.origin() Nativo
Paralelismo De pago en su servicio, o configurado a mano Gratuito e incorporado
API Encadenada, con cola implícita async/await estándar
Espera automática
Depuración Excepcional: viaje en el tiempo Muy buena: trace viewer, modo UI
Curva de aprendizaje Más suave Algo más pronunciada
Emulación móvil Limitada Completa, con dispositivos predefinidos
Grabar una prueba Con extensión codegen incorporado

La diferencia de API se ve de un vistazo:

// Cypress: cola de comandos, sin await
cy.visit('/');
cy.get('[data-cy="crear"]').click();
cy.get('[data-cy="tarjeta"]').should('have.length', 7);

// Playwright: async/await estándar, como el resto de tu JavaScript
await page.goto('/');
await page.getByTestId('crear').click();
await expect(page.getByTestId('tarjeta')).toHaveCount(7);

Cuándo elegir cada uno:

  • Cypress: aplicación de una sola página y un solo origen, equipo que empieza con E2E, prioridad en la experiencia de depuración. Es el caso de Nómada Tareas.
  • Playwright: hace falta probar en WebKit (Safari) de verdad, varias pestañas o sesiones simultáneas, emulación móvil seria, o paralelismo gratuito en CI.

Y lo importante para ti: los conceptos son los mismos y se transfieren enteros. Selectores estables, espera por condición y nunca por tiempo, interceptar la red, sembrar el estado, pocas pruebas bien elegidas. Cambia la sintaxis; no cambia nada de lo que has aprendido en esta lección.

  1. Qué NO probar en E2E

Tan importante como saber escribirlas es saber cuándo no hacerlo. Cada prueba E2E de más es tiempo de CI, mantenimiento y una fuente potencial de intermitencia.

No pruebes en E2E Pruébalo en… Por qué
Las nueve transiciones de R6 Unitaria (08-03) Nueve pruebas de 2 ms frente a nueve de 5 s, y con mejor diagnóstico
Los valores frontera de horas (0, 1, 40, 41) Unitaria Combinatoria: E2E es el peor sitio para muchos casos
Que toJSON/desdeJSON conservan los campos Integración (08-05) No necesita navegador
Cada mensaje de error de validación Integración Uno en E2E basta para verificar que el mecanismo funciona
Todos los caminos de pedirJson Unitaria con dobles (08-04) Siete escenarios que no requieren interfaz
Estilos concretos: colores, márgenes Pruebas visuales de regresión Otra herramienta, otro problema
Que un texto diga exactamente esta frase En ningún sitio Se rompe con cada retoque de redacción
Combinaciones exhaustivas de filtros Integración En E2E, una representativa

La regla de decisión:

flowchart TD
    A["¿Necesita un navegador REAL:<br/>CSS, foco, historial, recarga, SW?"] -->|No| B["No es E2E<br/>Unitaria o integración"]
    A -->|Sí| C["¿Es un recorrido que<br/>le importa al negocio?"]
    C -->|No| D["Probablemente sobra"]
    C -->|Sí| E["¿Ya está cubierto por<br/>otro recorrido E2E?"]
    E -->|Sí| F["Amplía el existente"]
    E -->|No| G["✅ Escríbela"]
    style G fill:#bbf7d0,stroke:#15803d
    style B fill:#fde68a,stroke:#b45309

Por eso Nómada Tareas tiene tres recorridos E2E y no treinta: crear, completar y filtrar. Son los tres que, si dejaran de funcionar, harían que Marta no pudiera usar la aplicación. Todo lo demás está cubierto por 124 pruebas que tardan cuatro segundos.

Errores Comunes y Consejos

  • cy.wait(3000) para "asegurar" que algo cargó. Es lento, frágil y tapa el problema real. Espera a la condición (.should()) o al alias de la petición (cy.wait('@listar')).
  • Intentar usar el valor devuelto por cy.get(). Los comandos se encolan y no devuelven elementos: usa .then() o, mejor, .should().
  • Seleccionar por clase CSS o por nth-child. La prueba se rompe con cada retoque de maquetación. Usa data-cy.
  • Escribir treinta pruebas E2E. La suite pasa de tres a treinta minutos, deja de ejecutarse en cada commit y todo el valor se pierde. Pocas y bien elegidas.
  • Depender del estado que dejó la prueba anterior. Cada it debe partir de un punto conocido: siembra en beforeEach y limpia el almacenamiento.
  • Crear los datos por la interfaz antes de cada prueba. Multiplica el tiempo y hace que un fallo del formulario tumbe toda la suite. Siembra por localStorage o por API.
  • Olvidar el service worker. Puede servir una versión antigua y provocar fallos incomprensibles. Desregístralo en las pruebas y dedícale una prueba propia.
  • Probar en E2E lo que ya cubre una unitaria. Las nueve transiciones de R6 en E2E son 45 segundos para saber lo que ya sabías en 20 milisegundos.
  • Subir retries hasta que todo pase. Convierte un problema visible en uno invisible. Dos reintentos en CI son un amortiguador razonable; más es esconder la basura debajo de la alfombra.
  • Consejo: la fecha, congelada. cy.clock(new Date('2026-09-20T09:00:00Z'), ['Date']) mantiene la tarea vencida vencida el día que sea, sin romper animaciones ni debounce.
  • Consejo: escribe los recorridos como los contaría Marta. «Creo una tarea, la veo en pendientes, la marco hecha y el resumen baja de 45 a 39 horas.» Si la prueba no se puede narrar así, probablemente no es un recorrido E2E.
  • Consejo: cuando un recorrido falle, usa el vídeo antes que el código. Ver qué pasó ahorra la mitad del paso «reproducir» del método de 08-01.

Ejercicios

Ejercicio 1 — El cuarto recorrido: trabajar sin conexión. Escribe cypress/e2e/sin-conexion.cy.js que compruebe la PWA de 07-05 de principio a fin: (a) con la aplicación cargada y el service worker activo, poner el navegador sin conexión con cy.intercept y forceNetworkError y comprobar que el tablero sigue visible con las seis tareas y aparece un aviso de modo sin conexión; (b) que un cambio de estado hecho sin conexión se aplica en la interfaz y se encola; (c) que al restaurar la red el cambio encolado se envía y el aviso desaparece. Justifica por qué este recorrido solo puede probarse en E2E y qué precauciones hay que tomar con el service worker entre pruebas.

Ejercicio 2 — Comandos personalizados y aserciones de dominio. Amplía cypress/support/commands.js con cinco comandos que hagan que las pruebas se lean como una descripción de negocio: cy.sembrarBacklog(cambios) (siembra el backlog canónico permitiendo modificar tareas concretas), cy.tarjeta(titulo), cy.avanzar(titulo) (pulsa el botón de avanzar de esa tarjeta y espera a que la petición termine), cy.filtrarPor(responsable) y cy.resumenDebeDecir({ abiertas, horas, vencidas }). Reescribe después el recorrido 2 usándolos y compara la legibilidad de ambas versiones.

Ejercicio 3 — Repartir una suite entre los tres niveles. Para cada una de estas ocho comprobaciones, decide en qué nivel debe vivir (unitaria, integración o E2E), justifícalo en una frase y escribe la primera línea de la prueba correspondiente:

  1. Una tarea con horasEstimadas: 41 lanza ErrorDeValidacion.
  2. El botón «Crear tarea» no está tapado por la barra de filtros en una ventana de 1280×800.
  3. toJSON() incluye el #estado privado.
  4. Al pulsar «Empezar», el resumen pasa de 45 a 45 h y el botón cambia de texto.
  5. Un 500 en listarTareas produce ErrorDeApi con codigo: 'servidor'.
  6. El enlace ?responsable=Iván abierto en frío muestra 3 tarjetas.
  7. reconciliar() reutiliza el nodo existente en lugar de recrearlo.
  8. El debounce del buscador espera 300 ms desde la última pulsación.

Soluciones

Solución 1

// cypress/e2e/sin-conexion.cy.js
describe('Recorrido 4 · Trabajar sin conexión (07-05)', () => {
  beforeEach(() => {
    // Precaución 1: partir siempre de un service worker limpio, o una versión
    // cacheada de una ejecución anterior serviría HTML antiguo y los fallos
    // serían incomprensibles.
    cy.window().then((win) => {
      if (win.navigator.serviceWorker) {
        return win.navigator.serviceWorker.getRegistrations()
          .then((regs) => Promise.all(regs.map((r) => r.unregister())));
      }
    });

    cy.clock(new Date('2026-09-20T09:00:00Z'), ['Date']);
    cy.intercept('GET', '**/tareas', { fixture: 'backlog.json' }).as('listar');
    cy.visit('/');
    cy.wait('@listar');

    // Precaución 2: esperar a que el SW esté ACTIVO antes de cortar la red,
    // o la prueba mediría el caso "sin SW", que es otro escenario distinto.
    cy.window().its('navigator.serviceWorker.ready').should('exist');
    cy.get('[data-cy="tarjeta"]').should('have.length', 6);
  });

  it('(a) sin conexión, el tablero sigue visible y se avisa', () => {
    cy.intercept('GET', '**/tareas', { forceNetworkError: true }).as('caida');

    cy.reload();

    cy.get('[data-cy="tarjeta"]').should('have.length', 6);      // servido por el SW
    cy.get('[data-cy="aviso-offline"]')
      .should('be.visible')
      .and('contain', 'sin conexión');
  });

  it('(b) un cambio sin conexión se aplica en la interfaz y se encola', () => {
    cy.intercept('PATCH', '**/tareas/*', { forceNetworkError: true }).as('patchCaido');

    cy.tarjeta('Cartelería del taller de serigrafía').find('[data-cy="avanzar"]').click();
    cy.wait('@patchCaido');

    // La interfaz refleja el cambio (optimista) y avisa de que está pendiente
    cy.tarjeta('Cartelería del taller de serigrafía')
      .should('have.attr', 'data-estado', 'en-curso')
      .and('have.class', 'tarea--pendiente-de-sincronizar');
    cy.get('[data-cy="cola-pendiente"]').should('contain', '1');
  });

  it('(c) al recuperar la red, lo encolado se envía y el aviso desaparece', () => {
    cy.intercept('PATCH', '**/tareas/*', { forceNetworkError: true }).as('patchCaido');
    cy.tarjeta('Cartelería del taller de serigrafía').find('[data-cy="avanzar"]').click();
    cy.wait('@patchCaido');
    cy.get('[data-cy="cola-pendiente"]').should('contain', '1');

    // La red vuelve
    cy.intercept('PATCH', '**/tareas/*', (p) => p.reply({ statusCode: 200, body: p.body }))
      .as('patchOk');
    cy.window().then((win) => win.dispatchEvent(new Event('online')));

    cy.wait('@patchOk').its('request.body').should('deep.include', { estado: 'en-curso' });
    cy.get('[data-cy="cola-pendiente"]').should('not.exist');
    cy.get('[data-cy="aviso-offline"]').should('not.exist');
  });
});

Por qué solo puede probarse en E2E: jsdom no implementa service workers ni Cache API, así que la parte central del recorrido —que la aplicación se sirva desde la caché tras una recarga sin red— es literalmente inobservable en 08-05. Además hacen falta una recarga real (cy.reload()), el evento online del navegador y el ciclo de vida completo del worker.

Precauciones: desregistrar el SW en cada beforeEach para no arrastrar cachés entre pruebas; esperar a serviceWorker.ready antes de cortar la red; y mantener este fichero separado de los otros tres recorridos, porque un SW activo interfiere con las respuestas de cy.intercept y produce fallos difíciles de diagnosticar.

Solución 2

// cypress/support/commands.js

/**
 * Siembra el backlog canónico en localStorage, permitiendo modificar
 * tareas concretas por id: cy.sembrarBacklog({ 2: { estado: 'en-curso' } })
 */
Cypress.Commands.add('sembrarBacklog', (cambios = {}) => {
  cy.fixture('backlog.json').then((backlog) => {
    const tareas = backlog.map((t) => ({ ...t, ...(cambios[t.id] ?? {}) }));
    cy.window().then((win) => {
      win.localStorage.setItem('nomada:tablero:v1',
        JSON.stringify({ nombre: 'Taller Nómada', version: 1, tareas }));
    });
  });
});

/** La tarjeta cuyo título contiene ese texto. */
Cypress.Commands.add('tarjeta', (titulo) =>
  cy.contains('[data-cy="tarjeta"]', titulo));

/** Pulsa "avanzar" en esa tarjeta y espera a que la petición termine. */
Cypress.Commands.add('avanzar', (titulo) => {
  cy.intercept('PATCH', '**/tareas/*').as('avanzarPeticion');
  cy.tarjeta(titulo).find('[data-cy="avanzar"]').click();
  cy.wait('@avanzarPeticion');                    // nunca cy.wait(ms)
  return cy.tarjeta(titulo);                      // devuelve la tarjeta, encadenable
});

/** Aplica el filtro de una persona y espera a que la lista se estabilice. */
Cypress.Commands.add('filtrarPor', (responsable) => {
  cy.get(`[data-cy="filtro-${responsable}"]`).click()
    .should('have.attr', 'aria-pressed', 'true');
  cy.url().should('include', `responsable=${encodeURIComponent(responsable)}`);
});

/** Aserción de dominio sobre el panel de resumen. */
Cypress.Commands.add('resumenDebeDecir', ({ abiertas, horas, vencidas }) => {
  cy.get('[data-cy="resumen"]').within(() => {
    if (abiertas !== undefined) cy.contains(`${abiertas} abiertas`).should('be.visible');
    if (horas !== undefined) cy.contains(`${horas} h`).should('be.visible');
    if (vencidas !== undefined) cy.contains(`${vencidas} vencida`).should('be.visible');
  });
});
// El recorrido 2, reescrito: se lee como lo contaría Marta
describe('Recorrido 2 · Completar una tarea', () => {
  beforeEach(() => {
    cy.clock(new Date('2026-09-20T09:00:00Z'), ['Date']);
    cy.intercept('GET', '**/tareas', { fixture: 'backlog.json' }).as('listar');
    cy.visit('/');
    cy.wait('@listar');
  });

  it('Marta lleva la cartelería de pendiente a hecha', () => {
    cy.resumenDebeDecir({ abiertas: 5, horas: 45, vencidas: 1 });

    cy.avanzar('Cartelería del taller de serigrafía')
      .should('have.attr', 'data-estado', 'en-curso');
    cy.resumenDebeDecir({ abiertas: 5, horas: 45 });

    cy.avanzar('Cartelería del taller de serigrafía')
      .should('have.attr', 'data-estado', 'hecha');
    cy.resumenDebeDecir({ abiertas: 4, horas: 39 });
  });

  it('una tarea ya hecha no se puede reabrir (R6)', () => {
    cy.tarjeta('Inventario de tintas de serigrafía')
      .find('[data-cy="avanzar"]').should('be.disabled');
  });
});

La versión con comandos ocupa la mitad, no repite un solo selector y se puede leer en voz alta a Marta para validar que la prueba comprueba lo que ella espera. Ese último punto es el argumento decisivo: una prueba E2E que un no programador puede entender es una prueba que verifica el negocio, no la implementación.

Solución 3

# Nivel Justificación Primera línea
1 Unitaria Lógica pura del modelo, sin DOM ni red; es un valor frontera de una tabla expect(() => unaTarea({ horasEstimadas: 41 })).toThrow(ErrorDeValidacion);
2 E2E Depende del pintado y de posiciones reales: jsdom no puede saberlo cy.get('[data-cy="crear"]').should('be.visible').click();
3 Unitaria Serialización pura, sin colaboradores expect(unaTarea({ estado: 'en-curso' }).toJSON()).toStrictEqual({ … });
4 Integración Vista + modelo + controlador colaborando; no necesita navegador real await usuario.click(within(tarjeta).getByRole('button', { name: /empezar/i }));
5 Unitaria con dobles Un caso de la tabla de errores de red; se resuelve con un fetch doble (08-04) red.mockResolvedValue(respuestaJson({ mensaje: 'Error' }, { status: 500 }));
6 E2E Exige una URL real, una carga en frío y el enrutador del navegador cy.visit('/?responsable=Iv%C3%A1n');
7 Integración Necesita un DOM, pero no un navegador: toBe sobre la identidad del nodo expect(document.querySelector('[data-id="2"]')).toBe(nodoAntes);
8 Unitaria Es lógica de temporización pura, con temporizadores falsos (08-04) jest.advanceTimersByTime(299); expect(buscar).not.toHaveBeenCalled();

Observaciones sobre el reparto: solo dos de las ocho son E2E, y las dos por el mismo motivo —requieren un navegador de verdad para el pintado o para el historial—. Cuatro son unitarias porque son lógica pura, y dos son de integración porque necesitan un DOM pero no un navegador. Ese reparto, aplicado sistemáticamente, es lo que mantiene una suite rápida y fiable.

Conclusión

Nómada Tareas tiene la red de seguridad completa. Sabes qué valida una prueba de extremo a extremo y ninguna otra: que index.html carga, que los módulos ES se resuelven en el navegador de verdad, que el CSS no tapa el botón, que el foco va donde debe, que una recarga conserva los datos, que el historial funciona y que el service worker sirve lo correcto. Y sabes lo que cuestan —segundos por prueba, minutos por suite, alta fragilidad, poca precisión al fallar—, de donde sale la regla que gobierna todo el nivel: pocas y muy bien elegidas.

Conoces Cypress a fondo: su arquitectura dentro del navegador, con las virtudes que le da (espera automática real, acceso al window de la aplicación, viaje en el tiempo) y los límites que le impone (una pestaña, un origen, cy.task para lo de Node); el ejecutor interactivo para escribir y el modo run con start-server-and-test para CI; la anatomía de una prueba con cy.visit, cy.get, cy.contains, .click(), .type() y .should(); y los selectores estables con data-cy, reservando las aserciones sobre texto y rol para verificar lo que ve el usuario. Has interiorizado el modelo de cola de comandos —que explica por qué const x = cy.get(...) no funciona— y la espera automática, incluida esa comprobación de «el elemento no está tapado por otro» que es justo lo que jsdom no puede ver. Y sabes por qué cy.wait(3000) es un antipatrón mientras cy.wait('@listar') es correcto: uno cuenta el reloj a ciegas, el otro espera a un suceso.

Dominas cy.intercept en sus tres usos —observar, fijar respuestas y provocar fallos imposibles de reproducir contra un servidor real— con la política de un recorrido feliz contra el servidor de verdad y todo lo demás con datos fijos. Siembras el estado inicial por localStorage o con cy.session en lugar de crearlo por la interfaz, desregistras el service worker para que no sirva versiones antiguas, y escribes comandos personalizados y fixtures que hacen que una prueba se pueda leer en voz alta a Marta. Sabes depurar un fallo con capturas, vídeo, viaje en el tiempo y cy.window() para inspeccionar el modelo vivo; ejecutar en GitHub Actions con needs para que lo barato vaya primero y las capturas se suban solo al fallar; añadir una auditoría de accesibilidad con axe sabiendo que cubre alrededor de un tercio de los problemas reales; y elegir entre Cypress y Playwright con criterio, teniendo claro que los conceptos se transfieren enteros.

Y sobre todo has escrito los tres recorridos críticos: Marta crea una tarea y la ve aparecer en pendientes con el resumen subiendo a 47 h, con su comprobación de que sobrevive a una recarga y su ciclo error 500 → reintento → éxito; lleva la cartelería de pendiente a en-curso a hecha viendo el resumen bajar de 45 a 39 h y la carga de Iván de 25 a 13, con la reversión optimista cuando el servidor rechaza; e Iván filtra por su nombre con la URL reflejando el filtro, el botón atrás restaurando el estado anterior y el enlace ?responsable=Iván funcionando abierto en frío. Tres recorridos, no treinta, porque sabes qué no probar en E2E: las nueve transiciones de R6 son unitarias, el viaje de toJSON es de integración, los siete escenarios de pedirJson se cubren con dobles, y en E2E solo va lo que necesita un navegador de verdad y le importa al negocio.

Con esto se cierra el Módulo 8. Nómada Tareas ha pasado de no tener ninguna comprobación automática a tener cuatro capas de defensa: el análisis estático de ESLint y Prettier con sus hooks de Git, que caza los fallos de forma antes de ejecutar nada; 48 pruebas unitarias que blindan el modelo entero —validaciones, transiciones, vencimientos, serialización y los números canónicos: 6 tareas, 48 h totales, 45 h abiertas, esfuerzo 124—; pruebas con dobles que cubren red, reloj, almacenamiento y aleatoriedad sin salir de la máquina; pruebas de integración que verifican las costuras entre modelo, datos y vista en un DOM sin navegador; y tres recorridos E2E que confirman que la aplicación entera funciona como la usaría una persona. Ciento veinticuatro pruebas en cuatro segundos, más tres recorridos en un minuto. Y tienes el método de depuración para cuando algo falle igualmente: reproducir, aislar, formular una hipótesis falsable, comprobarla con el instrumento adecuado, corregir la causa y prevenir con una prueba.

Fíjate en lo que ha cambiado de verdad. Al empezar el módulo, cambiar una línea daba miedo. Ahora puedes reescribir Tablero entero, sustituir el sistema de render, cambiar la capa de datos… y saber en cuatro segundos si algo se ha roto. Eso es lo que hace posible lo siguiente. Porque Nómada Tareas ya funciona y ya es correcta, pero nadie ha preguntado todavía si es rápida: cuánto tarda en pintarse con seiscientas tareas en lugar de seis, cuántos fotogramas pierde al desplazarse, cuánta memoria retiene, cuánto JavaScript descarga antes de mostrar la primera tarjeta. Y optimizar sin red de seguridad es la forma más eficaz de romper cosas sutilmente: una caché mal invalidada, una comparación que se salta un caso, un cálculo que se hace fuera de sitio. Con 124 pruebas detrás, se puede tocar el rendimiento sin miedo… pero hay una condición previa que casi todo el mundo se salta, y es exactamente la misma disciplina que has aprendido al depurar: primero medir, después actuar. Optimizar por intuición produce el mismo resultado que depurar por intuición —cambios que no convergen, complejidad añadida y ninguna mejora comprobable—. Ese es el Módulo 9: Rendimiento, que empieza precisamente por ahí: Medir Antes de Optimizar.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados