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
- Qué valida una prueba de extremo a extremo
- El coste: lentas, frágiles y caras de mantener
- Cypress: instalación y estructura del proyecto
- La arquitectura: dentro del navegador
- El ejecutor interactivo frente al modo
run - Anatomía de una prueba
- Selectores estables:
data-cyy por qué no la clase CSS - Encadenamiento y espera automática
- Por qué
cy.wait(3000)es un antipatrón - Interceptar la red con
cy.intercept - Sembrar el estado inicial
- Comandos personalizados y fixtures
- Recorrido 1: crear una tarea y verla en el tablero
- Recorrido 2: completarla y comprobar el resumen
- Recorrido 3: filtrar por responsable con la URL compartible
- Depurar un fallo: capturas, vídeos y viaje en el tiempo
- Ejecutar en integración continua
- Accesibilidad automatizada con axe
- Cypress frente a Playwright
- Qué NO probar en E2E
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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
- 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.
- Cypress: instalación y estructura del proyecto
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:
- 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, interceptarfetchdesde 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.
- El ejecutor interactivo frente al modo
run
runCypress 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 capturasopen (interactivo) |
run (consola) |
|
|---|---|---|
| Navegador | Visible, se puede inspeccionar | Sin interfaz (headless) |
| Al guardar el fichero | Recarga y reejecuta | — |
| Viaje en el tiempo | Sí | 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.
- 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.
- Selectores estables:
data-cy y por qué no la clase CSS
data-cy y por qué no la clase CSSEn 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 usuarioY un detalle de higiene: añade data-cy solo donde una prueba lo necesite. Sembrar el HTML de atributos que nadie usa es ruido.
- 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 (nodisplay:none, novisibility: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>
- Por qué
cy.wait(3000) es un antipatrón
cy.wait(3000) es un antipatrónCon 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:
- 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.
- Si tarda 100 ms, has perdido 2,9 s. Multiplicado por veinte esperas, son casi un minuto por ejecución.
- Oculta un problema real. ¿Por qué tarda? Puede ser una petición innecesaria o una carrera, y la espera fija lo tapa.
- 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.
- Interceptar la red con
cy.intercept
cy.interceptcy.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:
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 }); // lentitudLa 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 | Sí | 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.
- 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.
- 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" }
]
- 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.
- 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.
- 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.
- 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.
- 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: 3Tres decisiones de este flujo:
needs: verificarencadena los trabajos: si el lint falla, no se gastan minutos en E2E. Lo barato primero.wait-onespera 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 |
- 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:
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.
- 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 | Sí |
| 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 | Sí | Sí |
| 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.
- 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. Usadata-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
itdebe partir de un punto conocido: siembra enbeforeEachy 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
localStorageo 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
retrieshasta 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 nidebounce. - 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:
- Una tarea con
horasEstimadas: 41lanzaErrorDeValidacion. - El botón «Crear tarea» no está tapado por la barra de filtros en una ventana de 1280×800.
toJSON()incluye el#estadoprivado.- Al pulsar «Empezar», el resumen pasa de 45 a 45 h y el botón cambia de texto.
- Un
500enlistarTareasproduceErrorDeApiconcodigo: 'servidor'. - El enlace
?responsable=Ivánabierto en frío muestra 3 tarjetas. reconciliar()reutiliza el nodo existente en lugar de recrearlo.- El
debouncedel 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
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
