La lección anterior dejó las cuatro capas de Nómada Tareas cubiertas por pruebas unitarias, cada una aislada de todas las demás. Y ahí está el punto ciego: todas esas pruebas dan por supuesto un contrato que nadie ha comprobado. Que el JSON que produce toJSON() es exactamente el que desdeJSON sabe leer. Que lo que guarda RepositorioLocal es lo que Tablero.importar espera recibir. Que el data-id que escribe pintarTarjeta es el que el controlador delegado de 06-04 sabe interpretar. Cada pieza cumple su parte con dobles que responden justo lo que la prueba les dijo, y las piezas reales nunca se han mirado a la cara. Esta lección monta las costuras: Tablero con RepositorioLocal de verdad, la vista completa renderizada en un DOM sin navegador con jsdom, consultas por rol accesible con Testing Library, clics y escritura reales con user-event, y el recorrido entero formulario → modelo → render. Y terminarás sabiendo por qué las pruebas de integración son las que más fallos encuentran por línea escrita… y también las que con más facilidad se vuelven intermitentes.
Contenido
- Qué significa integrar
- Los cinco fallos que solo aparecen al juntar piezas
- Dónde encaja la integración: pirámide y testing trophy
- Integrar modelo y datos:
Tablero+RepositorioLocal - El viaje de ida y vuelta:
toJSON→ cadena →desdeJSON - Migraciones de versión
jsdom: un DOM sin navegador- Montar el HTML mínimo y renderizar
- Testing Library: consultar como lo haría una persona
- Las consultas y cuándo usar cada una
user-event: interactuar de verdad- Probar la delegación de eventos
- El flujo completo: formulario → modelo → render
- Probar los errores de red en la interfaz
- MSW: simular el servidor en el nivel correcto
- Cobertura combinada: qué añade la integración
- Pruebas intermitentes y cómo hacerlas deterministas
- La suite de integración de Nómada Tareas
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Qué significa integrar
Una prueba unitaria comprueba una pieza aislada, sustituyendo todo lo que la rodea. Una prueba de integración comprueba varias piezas reales trabajando juntas, sustituyendo solo lo que está fuera del sistema: la red, el reloj, el disco.
flowchart TD
subgraph U["Prueba UNITARIA de Tablero"]
T1["Tablero real"] --> D1["Tarea real"]
T1 -.-> R1["RepositorioLocal<br/>DOBLE"]
style R1 fill:#fecaca,stroke:#b91c1c
end
subgraph I["Prueba de INTEGRACIÓN"]
T2["Tablero real"] --> D2["Tarea real"]
T2 --> R2["RepositorioLocal REAL"]
R2 --> A2["almacén en memoria<br/>DOBLE (está fuera)"]
style R2 fill:#bbf7d0,stroke:#15803d
style A2 fill:#fde68a,stroke:#b45309
end
La frontera se traza así: dentro del sistema, todo real; fuera del sistema, dobles. RepositorioLocal es código tuyo, así que en integración va el de verdad. localStorage es del navegador, así que se sustituye. La API de tareas vive en otro servidor, así que se simula.
Lo que se gana es exactamente lo que las unitarias no pueden dar: la comprobación de que los contratos entre tus módulos se cumplen.
- Los cinco fallos que solo aparecen al juntar piezas
No son fallos hipotéticos: son las cinco familias que aparecen una y otra vez en cualquier proyecto.
| Familia | Qué ocurre | Ejemplo en Nómada Tareas |
|---|---|---|
| Contrato mal entendido | A produce una forma, B espera otra | toJSON() emite horasEstimadas; importar lee horas |
| Tipos en la costura | El dato cruza una frontera y cambia de tipo | El id que sale como cadena de dataset o de la API (caso 1 de 08-01) |
| Formatos de fecha | Cada capa asume el suyo | El modelo usa '2026-09-20'; alguien guarda un Date que se serializa con hora y zona |
| Errores que nadie captura | Cada capa cree que la otra lo maneja | El repositorio lanza y la vista no lo espera: pantalla en blanco |
| Orden y ciclo de vida | Se usa algo antes de que exista | El controlador se conecta antes de que la vista haya renderizado los nodos |
Las cinco tienen algo en común: cada pieza, por separado, funciona perfectamente. Sus pruebas unitarias están en verde. El fallo vive en el espacio entre ellas, y ese espacio no lo cubre ninguna prueba unitaria porque el doble siempre responde lo que la prueba espera.
El ejemplo del formato de fecha es especialmente instructivo:
// La vista guarda un Date porque el <input type="date"> se lo dio así
tarea.fechaLimite = new Date('2026-09-30');
// El modelo compara cadenas ISO
estaVencida(fechaLimite, estado, hoy) { return fechaLimite < hoy && estado !== 'hecha'; }
// new Date(...) < '2026-09-20' → comparación entre objeto y cadena: siempre false
// Y al serializar
JSON.stringify(tarea) // "fechaLimite": "2026-09-30T00:00:00.000Z" ← ya no casa con 'yyyy-MM-dd'Ninguna prueba unitaria lo detecta: la de la vista comprueba que guarda lo que recibe, la del modelo comprueba con cadenas, y la del repositorio serializa lo que le den. Solo al juntarlas se ve que la tarea nunca aparece vencida y que al recargar la fecha cambia de formato.
- Dónde encaja la integración: pirámide y testing trophy
En la pirámide de 08-03, la integración es el nivel intermedio: más lenta y menos precisa que la unitaria, más rápida y estable que la de extremo a extremo.
Pero hay un modelo alternativo que encaja mejor con aplicaciones de interfaz: el testing trophy.
flowchart TD
E["E2E · pocas<br/>recorridos críticos"]
I["INTEGRACIÓN · la mayoría<br/>el mejor equilibrio confianza/coste"]
U["Unitarias · las justas<br/>lógica con muchos casos"]
S["Estáticas · ESLint, @ts-check<br/>la base, gratis y siempre activa"]
E --> I --> U --> S
style I fill:#bbf7d0,stroke:#15803d
style S fill:#e0e7ff,stroke:#4338ca
Su argumento: la base del trofeo son las comprobaciones estáticas de 08-02, que cuestan casi cero y se ejecutan continuamente; y el cuerpo ancho es la integración, porque en una aplicación de interfaz la mayoría de los fallos reales viven en las costuras, no dentro de una función.
Las dos formas conviven bien si se entiende la regla que hay debajo:
| Tipo de código | Nivel que más rinde |
|---|---|
| Lógica pura con muchos casos (validaciones, cálculos, transiciones) | Unitario: nueve combinaciones de R6 en nueve líneas |
| Colaboración entre módulos propios (modelo + datos, vista + modelo) | Integración |
| Recorridos completos que importan al negocio | E2E, pocos y bien elegidos |
Nómada Tareas encaja perfectamente: el modelo, lleno de reglas, se cubrió en 08-03 con 48 pruebas unitarias; la vista y la persistencia se cubren aquí; y en 08-06 quedarán tres recorridos E2E.
- Integrar modelo y datos:
Tablero + RepositorioLocal
Tablero + RepositorioLocalPrimera costura. Piezas reales: Tarea, Tablero, RepositorioLocal. Doble: solo el almacén, porque localStorage es del navegador.
// pruebas/integracion/persistencia.test.js
import { Tablero } from '../../js/modelo/tablero.js';
import { RepositorioLocal } from '../../js/datos/repositorio-local.js';
import { almacenFalso } from '../ayudas/almacen-falso.js';
import { unTablero, HOY } from '../ayudas/backlog-de-prueba.js';
describe('Integración · Tablero ↔ RepositorioLocal', () => {
let almacen, repositorio;
beforeEach(() => {
almacen = almacenFalso();
repositorio = new RepositorioLocal({ almacen }); // ← el repositorio REAL
});
test('el tablero guardado y recuperado conserva los números canónicos', () => {
repositorio.guardar(unTablero());
const recuperado = repositorio.cargar();
expect(recuperado).toBeInstanceOf(Tablero);
expect(recuperado.resumen(HOY)).toStrictEqual({
total: 6, abiertas: 5, horasTotales: 48,
horasAbiertas: 45, vencidas: 1, esfuerzo: 124
});
});
test('los cambios de estado sobreviven al viaje completo', () => {
const tablero = unTablero();
tablero.cambiarEstado(2, 'en-curso');
tablero.cambiarEstado(1, 'hecha');
repositorio.guardar(tablero);
const recuperado = repositorio.cargar();
expect(recuperado.buscarPorId(2).estado).toBe('en-curso');
expect(recuperado.buscarPorId(1).estado).toBe('hecha');
expect(recuperado.horasAbiertas).toBe(33); // 45 − 12
});
test('lo recuperado son instancias de Tarea con todos sus métodos', () => {
repositorio.guardar(unTablero());
const tarea = repositorio.cargar().buscarPorId(6);
// No basta con que los datos estén: tienen que ser objetos vivos
expect(tarea.estaVencida(HOY)).toBe(true); // el método existe y funciona
expect(tarea.esfuerzo).toBe(15); // 5 h × peso 3 de prioridad alta
expect(() => tarea.cambiarEstado('hecha')).toThrow(); // R6 sigue vigente
});
test('lo guardado se revalida al cargar: un dato que viola R3 se rechaza', () => {
const avisos = jest.spyOn(console, 'warn').mockImplementation(() => {});
almacen.setItem('nomada:tablero:v1', JSON.stringify({
nombre: 'Taller Nómada', version: 1,
tareas: [{ id: 1, titulo: 'Manipulada', horasEstimadas: 999, fechaLimite: '2026-10-01' }]
}));
expect(repositorio.cargar()).toBeNull(); // ← nunca se confía en el almacén
expect(avisos).toHaveBeenCalled();
avisos.mockRestore();
});
});La tercera prueba es la que justifica el nivel de integración. Comprobar que los datos están sería una prueba unitaria del repositorio; comprobar que lo recuperado tiene estaVencida(), calcula esfuerzo y sigue aplicando R6 es comprobar que el contrato entre las dos capas está intacto. Si alguien "optimizara" cargar() devolviendo objetos planos en lugar de instancias, los datos seguirían siendo correctos y la aplicación se rompería entera. Esta prueba lo impide.
La cuarta es igual de importante y expresa una política de seguridad: localStorage es editable por el usuario desde el panel Application de 08-01. Nunca se confía en lo que sale del almacén, y esta prueba blinda esa decisión.
- El viaje de ida y vuelta:
toJSON → cadena → desdeJSON
toJSON → cadena → desdeJSONLa costura más sutil, y la que retoma directamente 04-08 y 07-01. El recorrido tiene cinco saltos y en cada uno se puede perder algo:
flowchart LR
A["Tarea<br/>con #estado privado"] -->|toJSON| B["objeto plano"]
B -->|JSON.stringify| C["cadena de texto"]
C -->|setItem| D["localStorage"]
D -->|getItem + JSON.parse| E["objeto plano"]
E -->|desdeJSON| F["Tarea<br/>reconstruida"]
Qué se pierde en cada salto si algo no está bien:
| Salto | Qué se puede perder | Cómo se detecta |
|---|---|---|
toJSON |
Los campos privados (#estado, #horas): no aparecen solos |
La tarea vuelve siempre en 'pendiente' |
JSON.stringify |
Los undefined, las funciones, los Map/Set |
Un campo desaparece sin aviso |
setItem |
Nada, pero todo se convierte en cadena | Un número guardado vuelve como texto |
JSON.parse |
Las clases: todo vuelve como objeto plano | instanceof Tarea da false |
desdeJSON |
Nada, si revalida | Datos inválidos entran al modelo |
Las pruebas que blindan el recorrido completo:
describe('Integración · el viaje de ida y vuelta', () => {
test('el estado privado sobrevive al viaje entero', () => {
const tablero = unTablero();
tablero.cambiarEstado(2, 'en-curso');
// El viaje completo, salto a salto, escrito a mano para verlo
const objeto = tablero.toJSON();
const cadena = JSON.stringify(objeto);
almacen.setItem('nomada:tablero:v1', cadena);
const recuperado = Tablero.importar(JSON.parse(almacen.getItem('nomada:tablero:v1')));
expect(recuperado.buscarPorId(2).estado).toBe('en-curso');
});
test('ningún campo se pierde por el camino', () => {
const original = unTablero();
repositorio.guardar(original);
const recuperado = repositorio.cargar();
// Comparamos las representaciones completas: si falta un campo, salta aquí
expect(recuperado.toJSON()).toStrictEqual(original.toJSON());
});
test('las fechas siguen siendo cadenas ISO de 10 caracteres, no objetos Date', () => {
repositorio.guardar(unTablero());
for (const tarea of repositorio.cargar()) {
expect(typeof tarea.fechaLimite).toBe('string');
expect(tarea.fechaLimite).toMatch(/^\d{4}-\d{2}-\d{2}$/);
}
});
test('un revisor null se conserva como null, no se convierte en undefined (R8)', () => {
repositorio.guardar(unTablero());
const tarea = repositorio.cargar().buscarPorId(2); // la 2 no tiene revisor
expect(tarea.revisor).toBeNull();
expect('revisor' in tarea.toJSON()).toBe(true); // la clave EXISTE
});
test('las etiquetas se conservan como array, no como cadena', () => {
repositorio.guardar(unTablero());
expect(repositorio.cargar().buscarPorId(1).etiquetas).toEqual(['espacio', 'diseño']);
});
});La prueba del revisor es un caso real que muerde a mucha gente. Si toJSON devolviera revisor: undefined en lugar de null, JSON.stringify eliminaría la clave entera (04-08). Al reconstruir, datos.revisor sería undefined, el ?? null del constructor lo salvaría por casualidad, y todo parecería funcionar… hasta que alguien compare 'revisor' in datos y obtenga false. La prueba fija el contrato de forma explícita.
Y la de las fechas es exactamente el fallo del apartado 2: una cadena de diez caracteres, no un Date.
- Migraciones de versión
RepositorioLocal usa la clave nomada:tablero:v1. El día en que el formato cambie —porque se añade un campo obligatorio, o se renombra otro— habrá usuarios con datos del formato antiguo en su navegador. Ignorarlos significa perder su trabajo.
Supongamos que la versión 2 renombra horasEstimadas a horas y añade creada:
// js/datos/migraciones.js
const MIGRACIONES = {
1: (datos) => ({
...datos,
version: 2,
tareas: datos.tareas.map((t) => ({
...t,
horas: t.horasEstimadas, // renombrado
creada: t.creada ?? '2026-01-01', // campo nuevo, con valor razonable
horasEstimadas: undefined
}))
})
};
/** Aplica las migraciones necesarias hasta llegar a la versión actual. */
export function migrar(datos, versionActual = 2) {
let actuales = datos;
while ((actuales.version ?? 1) < versionActual) {
const migracion = MIGRACIONES[actuales.version ?? 1];
if (!migracion) throw new ErrorDeDatos(`No hay migración desde la versión ${actuales.version}`);
actuales = migracion(actuales);
}
return actuales;
}Y las pruebas de integración, que son de las más valiosas que existen porque protegen datos de personas reales:
describe('Integración · migraciones de formato', () => {
test('un tablero en formato v1 se migra y conserva las horas', () => {
almacen.setItem('nomada:tablero:v1', JSON.stringify({
nombre: 'Taller Nómada', version: 1,
tareas: [{ id: 1, titulo: 'Rediseñar la sala', responsable: 'Iván', prioridad: 'alta',
estado: 'en-curso', etiquetas: ['espacio'], horasEstimadas: 12,
fechaLimite: '2026-09-30', revisor: 'Marta' }]
}));
const tablero = repositorio.cargar();
expect(tablero.total).toBe(1);
expect(tablero.buscarPorId(1).horas).toBe(12); // migrado
expect(tablero.buscarPorId(1).creada).toBe('2026-01-01'); // rellenado
});
test('un tablero ya en v2 se carga sin tocar', () => {
const original = unTablero();
repositorio.guardar(original);
expect(repositorio.cargar().toJSON()).toStrictEqual(original.toJSON());
});
test('un formato futuro desconocido no destruye los datos', () => {
almacen.setItem('nomada:tablero:v1', JSON.stringify({ version: 99, tareas: [] }));
expect(repositorio.cargar()).toBeNull(); // se descarta con cuidado…
expect(almacen.getItem('nomada:tablero:v1')).not.toBeNull(); // …pero NO se borra
});
});Esa última prueba codifica una decisión importante: ante datos que no se entienden, descartar en memoria pero no borrar del almacén. Si el usuario abrió por error una versión antigua de la aplicación, sus datos siguen ahí cuando vuelva a la nueva.
jsdom: un DOM sin navegador
jsdom: un DOM sin navegadorSegunda costura: la vista. Todo el Módulo 6 vive sobre document, que en Node no existe. jsdom es una implementación de los estándares del DOM y HTML escrita en JavaScript puro: crea document, window, Element, Event, localStorage y cientos de APIs más, en memoria y sin ventana.
Se puede activar globalmente o —mejor— fichero a fichero, con un comentario en la cabecera:
Así las pruebas del modelo siguen en el entorno node, que es más rápido, y solo las que necesitan DOM pagan el coste de montarlo.
O por patrón de ficheros, que es lo más cómodo cuando hay muchas:
// jest.config.js
export default {
projects: [
{
displayName: 'unitarias',
testEnvironment: 'node',
testMatch: ['**/pruebas/{modelo,util,datos}/**/*.test.js'],
transform: {}
},
{
displayName: 'integracion',
testEnvironment: 'jsdom',
testMatch: ['**/pruebas/integracion/**/*.test.js'],
transform: {}
}
]
};Qué sí y qué no ofrece jsdom. Es importante saberlo para no perseguir fallos inexistentes:
| jsdom sí implementa | jsdom no implementa |
|---|---|
El árbol del DOM, querySelector, classList, dataset |
Diseño y pintado: todas las medidas son 0 |
Eventos, propagación, delegación, CustomEvent |
getBoundingClientRect() devuelve ceros |
Formularios, FormData, validación de restricciones |
scrollIntoView, IntersectionObserver (hay que simularlos) |
localStorage, sessionStorage |
Service workers y Cache API |
fetch (en versiones recientes) y AbortController |
Renderizado de CSS: getComputedStyle es limitado |
Accesibilidad básica: roles implícitos, aria-* |
Comportamientos reales del navegador (foco entre ventanas, historial completo) |
De esa lista se deriva la regla del módulo: lo que dependa de píxeles, de pintado o de un service worker no se prueba en jsdom. Va a las pruebas de extremo a extremo de 08-06, en un navegador de verdad.
- Montar el HTML mínimo y renderizar
TableroVista espera unos contenedores y unas <template> que en la aplicación viven en index.html. En la prueba se montan a mano, con lo mínimo imprescindible:
// pruebas/ayudas/montar-dom.js
/** Estructura mínima que TableroVista necesita para funcionar. */
const HTML = `
<main>
<form id="form-tarea" novalidate>
<label for="titulo">Título</label>
<input id="titulo" name="titulo" required minlength="3">
<label for="responsable">Responsable</label>
<select id="responsable" name="responsable">
<option value="">Sin asignar</option>
<option value="Iván">Iván</option>
<option value="Lucía">Lucía</option>
<option value="Marta">Marta</option>
</select>
<label for="horasEstimadas">Horas estimadas</label>
<input id="horasEstimadas" name="horasEstimadas" type="number" min="1" max="40" required>
<label for="fechaLimite">Fecha límite</label>
<input id="fechaLimite" name="fechaLimite" type="date" required>
<label for="etiquetas">Etiquetas</label>
<input id="etiquetas" name="etiquetas">
<button type="submit">Crear tarea</button>
<div id="errores-form" role="alert" hidden></div>
</form>
<div id="tablero"></div>
<output id="resumen" aria-live="polite"></output>
</main>
<template id="plantilla-columna">
<section class="columna"><h2 class="columna__titulo"></h2><ul class="columna__lista"></ul></section>
</template>
<template id="plantilla-tarea">
<li class="tarea">
<h3 class="tarea__titulo"></h3>
<p class="tarea__meta"></p>
<ul class="tarea__etiquetas"></ul>
<button data-accion="avanzar"></button>
<button data-accion="reabrir">Devolver a pendiente</button>
</li>
</template>
`;
export function montarDom() {
document.body.innerHTML = HTML;
return {
contenedor: document.querySelector('#tablero'),
resumen: document.querySelector('#resumen'),
formulario: document.querySelector('#form-tarea')
};
}
export function limpiarDom() {
document.body.innerHTML = '';
}Dos decisiones de esa ayuda merecen explicación:
- Cada
<input>tiene su<label for>. No es decoración: es lo que permitirá consultar porgetByLabelText, y de paso obliga a que el formulario sea accesible. Volveremos sobre ello en el apartado 10. - El HTML es el mínimo, no una copia de
index.html. Copiar el HTML real ata la prueba a cada cambio de maquetación. Se monta lo que la vista necesita, y nada más.
Y la primera prueba de renderizado:
/**
* @jest-environment jsdom
*/
import { TableroVista } from '../../js/vista/tablero-vista.js';
import { montarDom, limpiarDom } from '../ayudas/montar-dom.js';
import { unTablero, HOY } from '../ayudas/backlog-de-prueba.js';
describe('Integración · TableroVista sobre el DOM', () => {
let dom, tablero, vista;
beforeEach(() => {
dom = montarDom();
tablero = unTablero();
vista = new TableroVista({ contenedor: dom.contenedor, resumen: dom.resumen, tablero, hoy: HOY });
vista.render();
});
afterEach(() => { limpiarDom(); });
test('pinta las seis tareas repartidas en sus tres columnas', () => {
expect(document.querySelectorAll('[data-id]')).toHaveLength(6);
expect(document.querySelectorAll('[data-estado="pendiente"]')).toHaveLength(3);
expect(document.querySelectorAll('[data-estado="en-curso"]')).toHaveLength(2);
expect(document.querySelectorAll('[data-estado="hecha"]')).toHaveLength(1);
});
test('marca visualmente la única tarea vencida (R10)', () => {
const vencidas = document.querySelectorAll('.tarea--vencida');
expect(vencidas).toHaveLength(1);
expect(vencidas[0].dataset.id).toBe('6');
expect(vencidas[0].textContent).toContain('Presupuesto de la carpintería');
});
test('el resumen muestra las 45 horas abiertas', () => {
expect(dom.resumen.textContent).toContain('45');
});
test('reconciliar reutiliza los nodos en lugar de recrearlos', () => {
const nodoAntes = document.querySelector('[data-id="2"]');
tablero.cambiarEstado(2, 'en-curso');
vista.actualizar();
const nodoDespues = document.querySelector('[data-id="2"]');
expect(nodoDespues).toBe(nodoAntes); // ← el MISMO nodo (toBe, identidad)
expect(nodoDespues.dataset.estado).toBe('en-curso'); // …actualizado
});
});La última prueba es de las más valiosas del fichero, y solo es posible en integración. reconciliar() existe precisamente para no destruir nodos y así conservar el foco y las transiciones (06-06). Comprobarlo requiere un DOM real y la vista real, y se hace con toBe sobre nodos: identidad, no contenido. Es el uso deliberado de toBe con objetos del que hablaba 08-03.
- Testing Library: consultar como lo haría una persona
querySelectorAll('.tarea--vencida') funciona, pero tiene un problema de fondo: prueba la implementación. El día que alguien renombre la clase a .tarea--atrasada, la prueba se pone roja sin que nada esté roto.
Testing Library propone otra cosa: consultar el DOM como lo haría una persona —o un lector de pantalla—. Por su rol, por su texto, por su etiqueta.
import { screen, within } from '@testing-library/dom';
import '@testing-library/jest-dom'; // matchers como toBeVisible, toHaveTextContentLa diferencia, con el mismo objetivo:
// ❌ Acoplada a la implementación: se rompe al renombrar una clase
expect(document.querySelector('.tarea__titulo').textContent).toBe('Rediseñar la sala polivalente');
const boton = document.querySelector('[data-accion="avanzar"]');
// ✅ Acoplada a lo que percibe el usuario: sobrevive a cualquier refactor de CSS
expect(screen.getByRole('heading', { name: 'Rediseñar la sala polivalente' })).toBeVisible();
const boton = screen.getByRole('button', { name: 'Empezar: Rediseñar la sala polivalente' });Y el argumento decisivo, que casi nadie menciona la primera vez: si no puedes consultar un elemento por su rol o su texto accesible, probablemente un lector de pantalla tampoco pueda encontrarlo. La prueba se vuelve una auditoría de accesibilidad continua. Un <div onclick> no tiene rol de botón: no aparece en getByRole('button') y tampoco es alcanzable con el teclado. La prueba te obliga a arreglarlo, y arreglarlo mejora la aplicación de verdad.
| Consultar por… | Robustez ante refactors | Relación con la accesibilidad |
|---|---|---|
| Clase CSS | Muy baja | Ninguna |
id o selector de estructura |
Baja | Ninguna |
data-testid |
Alta | Ninguna |
| Texto accesible | Alta | Directa |
| Rol + nombre accesible | Alta | Directa |
data-testid es el recurso para lo que no tiene rol ni texto —un contenedor decorativo, una región sin nombre—. Es legítimo, pero debe ser la excepción: cada data-testid es una consulta que no comprueba nada sobre la experiencia real.
- Las consultas y cuándo usar cada una
Testing Library tiene tres familias de consultas, y elegir mal produce pruebas que fallan sin motivo:
| Prefijo | Si no encuentra | Si encuentra varios | Espera | Cuándo usarlo |
|---|---|---|---|---|
getBy… |
Lanza error | Lanza error | No | El elemento debe estar ya |
queryBy… |
Devuelve null |
Lanza error | No | Comprobar que no está |
findBy… |
Lanza tras el tiempo límite | Lanza error | Sí (devuelve promesa) | El elemento aparecerá tras un await |
…AllBy… |
Variante que devuelve array | — | — | Varios elementos |
// getBy: ya está renderizado
const titulo = screen.getByRole('heading', { name: 'Actualizar la web de reservas' });
// queryBy: la única forma correcta de afirmar una ausencia
expect(screen.queryByText('Cargando…')).not.toBeInTheDocument();
// findBy: espera a que aparezca (tras una petición, un render asíncrono…)
const aviso = await screen.findByRole('alert');
// AllBy: varios
expect(screen.getAllByRole('listitem')).toHaveLength(6);El error clásico: usar getBy para comprobar que algo no está. getByText('Cargando…') lanza si no lo encuentra, así que la prueba falla precisamente cuando el comportamiento es correcto. Para ausencias, siempre queryBy.
Las consultas por rol, ordenadas por frecuencia de uso en un proyecto como este:
screen.getByRole('button', { name: 'Crear tarea' });
screen.getByRole('heading', { name: /rediseñar la sala/i }); // regex: tolerante a mayúsculas
screen.getByRole('textbox', { name: 'Título' }); // input de texto, por su <label>
screen.getByRole('combobox', { name: 'Responsable' }); // <select>
screen.getByRole('spinbutton', { name: 'Horas estimadas' }); // input type=number
screen.getByRole('list'); // <ul> / <ol>
screen.getByRole('listitem'); // <li>
screen.getByRole('alert'); // role="alert" o aria-live=assertive
screen.getByRole('status'); // aria-live="polite"
screen.getByLabelText('Fecha límite'); // por su etiqueta
screen.getByText(/45 h abiertas/); // por su textoY within para acotar la búsqueda a una región, que es imprescindible cuando el mismo texto aparece en varias columnas:
const columnaPendientes = screen.getByRole('region', { name: 'Pendientes' });
const tareas = within(columnaPendientes).getAllByRole('listitem');
expect(tareas).toHaveLength(3);
expect(within(tareas[0]).getByRole('button')).toHaveTextContent('Empezar');Los matchers de @testing-library/jest-dom que más se usan:
expect(elemento).toBeInTheDocument();
expect(elemento).toBeVisible(); // considera hidden, display:none, visibility
expect(boton).toBeDisabled();
expect(campo).toHaveValue('Revisar extintores');
expect(campo).toBeInvalid(); // aria-invalid o validación nativa
expect(elemento).toHaveClass('tarea--vencida');
expect(elemento).toHaveTextContent(/45/);
expect(campo).toHaveFocus();
expect(campo).toHaveAccessibleName('Horas estimadas');
user-event: interactuar de verdad
user-event: interactuar de verdadDisparar elemento.dispatchEvent(new MouseEvent('click')) produce un evento. Cuando una persona pulsa un botón, el navegador produce una secuencia: pointerdown, mousedown, focus, pointerup, mouseup, click. Si tu código escucha mousedown o depende del foco, la prueba con un solo evento pasa y la aplicación real falla.
user-event simula la secuencia completa:
import userEvent from '@testing-library/user-event';
test('escribir en el formulario dispara la validación tras el retardo', async () => {
const usuario = userEvent.setup(); // ← una vez por prueba
await usuario.type(screen.getByLabelText('Título'), 'Revisar extintores');
await usuario.selectOptions(screen.getByLabelText('Responsable'), 'Marta');
await usuario.click(screen.getByRole('button', { name: 'Crear tarea' }));
});Las acciones más útiles:
| Acción | Qué simula |
|---|---|
click(el) |
La secuencia completa de pulsación, con foco |
dblClick(el) |
Doble pulsación |
type(el, texto) |
Tecla a tecla, con keydown/keypress/input/keyup por carácter |
clear(el) |
Seleccionar todo y borrar |
selectOptions(el, valor) |
Elegir en un <select> |
tab() |
Mover el foco con el tabulador |
keyboard('{Enter}') |
Pulsaciones concretas: {Enter}, {Escape}, {ArrowDown} |
hover(el) / unhover(el) |
Entrada y salida del puntero |
type escribiendo tecla a tecla es lo que permite probar de verdad el debounce de 06-07 en su contexto, no aisladamente como en 08-04.
Y un aviso importante sobre temporizadores falsos. user-event usa temporizadores internamente para simular el ritmo de escritura. Si combinas jest.useFakeTimers() con user-event, hay que avisarle:
beforeEach(() => { jest.useFakeTimers(); });
test('el buscador filtra 300 ms después de dejar de teclear', async () => {
const usuario = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await usuario.type(screen.getByLabelText('Buscar'), 'carpint');
expect(screen.getAllByRole('listitem')).toHaveLength(6); // aún sin filtrar
jest.advanceTimersByTime(300);
expect(screen.getAllByRole('listitem')).toHaveLength(1); // ya filtrado
});Sin la opción advanceTimers, user-event se queda esperando un reloj que no avanza y la prueba se cuelga hasta agotar el tiempo límite. Es uno de los atascos más frecuentes al empezar, y ahora ya sabes qué mirar.
- Probar la delegación de eventos
Aquí se comprueba de verdad el mecanismo de 06-04. conectarAcciones pone un solo oyente en el contenedor y usa closest('[data-accion]') para identificar el botón. Esa arquitectura tiene una virtud que solo se demuestra en integración: funciona con tarjetas que no existían cuando se conectó el oyente.
describe('Integración · delegación de eventos (06-04)', () => {
let usuario, dom, tablero, vista;
beforeEach(() => {
usuario = userEvent.setup();
dom = montarDom();
tablero = unTablero();
vista = new TableroVista({ contenedor: dom.contenedor, resumen: dom.resumen, tablero, hoy: HOY });
vista.render();
conectarAcciones({ contenedor: dom.contenedor, tablero, vista });
});
afterEach(() => { limpiarDom(); });
test('pulsar Empezar cambia el estado en el modelo y en la vista', async () => {
const tarjeta = screen.getByRole('listitem', { name: /cartelería del taller/i });
await usuario.click(within(tarjeta).getByRole('button', { name: /empezar/i }));
expect(tablero.buscarPorId(2).estado).toBe('en-curso'); // el MODELO
expect(tarjeta.dataset.estado).toBe('en-curso'); // la VISTA
expect(within(tarjeta).getByRole('button', { name: /marcar hecha/i })).toBeInTheDocument();
});
test('el resumen se recalcula tras cada acción', async () => {
expect(dom.resumen).toHaveTextContent(/45/);
const tarjeta = screen.getByRole('listitem', { name: /rediseñar la sala/i });
await usuario.click(within(tarjeta).getByRole('button', { name: /marcar hecha/i }));
expect(dom.resumen).toHaveTextContent(/33/); // 45 − 12
});
test('el botón de la tarea hecha está desactivado y no hace nada', async () => {
const tarjeta = screen.getByRole('listitem', { name: /inventario de tintas/i });
const boton = within(tarjeta).getByRole('button', { name: /completada/i });
expect(boton).toBeDisabled();
await usuario.click(boton);
expect(tablero.buscarPorId(4).estado).toBe('hecha'); // sin cambios
});
test('la delegación funciona con tarjetas creadas DESPUÉS de conectar el oyente', async () => {
tablero.agregar(unaTarea({ id: 7, titulo: 'Revisar extintores', estado: 'pendiente',
horasEstimadas: 2, responsable: 'Marta' }));
vista.actualizar();
const nueva = screen.getByRole('listitem', { name: /revisar extintores/i });
await usuario.click(within(nueva).getByRole('button', { name: /empezar/i }));
expect(tablero.buscarPorId(7).estado).toBe('en-curso');
});
test('un clic en el hueco entre tarjetas no rompe nada', async () => {
await usuario.click(dom.contenedor);
expect(tablero.resumen(HOY).horasAbiertas).toBe(45);
});
});La cuarta prueba es la que demuestra el valor de la delegación: con oyentes individuales por botón, esa tarjeta nueva no tendría ninguno y el clic no haría nada. La quinta protege el closest() que devuelve null cuando se pulsa fuera de un botón: sin la comprobación, sería un TypeError.
- El flujo completo: formulario → modelo → render
La costura más larga de la aplicación, y la que más contratos atraviesa: FormData → normalización → validación de la vista → validación del modelo (reglas R2, R3, R6…) → Tablero.agregar → evento → render → persistencia.
flowchart LR
A["Usuario escribe<br/>y envía"] --> B["FormData<br/>+ normalizar"]
B --> C["Validación<br/>de la vista"]
C -->|errores| D["aria-invalid<br/>+ foco al campo"]
C -->|ok| E["new Tarea()<br/>reglas R2/R3/R8/R9"]
E -->|ErrorDeValidacion| D
E --> F["tablero.agregar<br/>R1: id único"]
F --> G["evento TAREA_CREADA"]
G --> H["render"]
G --> I["repositorio.guardar"]
describe('Integración · formulario → modelo → render (06-07)', () => {
let usuario, dom, tablero, vista, repositorio;
beforeEach(() => {
usuario = userEvent.setup();
dom = montarDom();
tablero = unTablero();
repositorio = new RepositorioLocal({ almacen: almacenFalso() });
vista = new TableroVista({ contenedor: dom.contenedor, resumen: dom.resumen, tablero, hoy: HOY });
vista.render();
conectarFormulario({ formulario: dom.formulario, tablero, siguienteId: () => 7,
alCrear: () => { vista.actualizar(); repositorio.guardar(tablero); }, hoy: HOY });
});
afterEach(() => { limpiarDom(); });
async function rellenar({ titulo = 'Revisar extintores', responsable = 'Marta',
horas = '2', fecha = '2026-10-20', etiquetas = 'seguridad' } = {}) {
await usuario.type(screen.getByLabelText('Título'), titulo);
if (responsable) await usuario.selectOptions(screen.getByLabelText('Responsable'), responsable);
await usuario.type(screen.getByLabelText('Horas estimadas'), horas);
await usuario.type(screen.getByLabelText('Fecha límite'), fecha);
if (etiquetas) await usuario.type(screen.getByLabelText('Etiquetas'), etiquetas);
}
test('una tarea válida llega al modelo, a la vista y al almacén', async () => {
await rellenar();
await usuario.click(screen.getByRole('button', { name: 'Crear tarea' }));
// 1 · El modelo
expect(tablero.total).toBe(7);
expect(tablero.buscarPorId(7)).toMatchObject({
titulo: 'Revisar extintores', responsable: 'Marta',
horasEstimadas: 2, estado: 'pendiente' // R5
});
// 2 · La vista
expect(screen.getByRole('listitem', { name: /revisar extintores/i })).toBeVisible();
// 3 · El almacén
expect(repositorio.cargar().total).toBe(7);
// 4 · El formulario se ha vaciado
expect(screen.getByLabelText('Título')).toHaveValue('');
});
test('las etiquetas se normalizan según R9 en todo el recorrido', async () => {
await rellenar({ etiquetas: 'Seguridad, SEGURIDAD , extintores' });
await usuario.click(screen.getByRole('button', { name: 'Crear tarea' }));
expect(tablero.buscarPorId(7).etiquetas).toEqual(['seguridad', 'extintores']);
expect(repositorio.cargar().buscarPorId(7).etiquetas).toEqual(['seguridad', 'extintores']);
});
test('un título vacío muestra el error accesible y NO toca el modelo', async () => {
await rellenar({ titulo: '' });
await usuario.click(screen.getByRole('button', { name: 'Crear tarea' }));
expect(tablero.total).toBe(6); // el modelo intacto
expect(screen.getByRole('alert')).toBeVisible();
expect(screen.getByLabelText('Título')).toBeInvalid(); // aria-invalid="true"
expect(screen.getByLabelText('Título')).toHaveFocus(); // el foco va al campo
});
test('99 horas incumple R3 y el error identifica el campo correcto', async () => {
await rellenar({ horas: '99' });
await usuario.click(screen.getByRole('button', { name: 'Crear tarea' }));
expect(tablero.total).toBe(6);
expect(screen.getByRole('alert')).toHaveTextContent(/40/);
expect(screen.getByLabelText('Horas estimadas')).toBeInvalid();
});
test('una fecha límite anterior a hoy se rechaza (R4)', async () => {
await rellenar({ fecha: '2026-09-01' });
await usuario.click(screen.getByRole('button', { name: 'Crear tarea' }));
expect(tablero.total).toBe(6);
expect(screen.getByLabelText('Fecha límite')).toBeInvalid();
});
test('tras corregir el error, el segundo envío funciona y los avisos desaparecen', async () => {
await rellenar({ titulo: '' });
await usuario.click(screen.getByRole('button', { name: 'Crear tarea' }));
await usuario.type(screen.getByLabelText('Título'), 'Revisar extintores');
await usuario.click(screen.getByRole('button', { name: 'Crear tarea' }));
expect(tablero.total).toBe(7);
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); // ← queryBy para la ausencia
});
test('el formulario se puede completar y enviar solo con el teclado', async () => {
await usuario.tab(); // Título
await usuario.keyboard('Revisar extintores');
await usuario.tab(); // Responsable
await usuario.keyboard('Marta');
await usuario.tab(); // Horas
await usuario.keyboard('2');
await usuario.tab(); // Fecha
await usuario.keyboard('2026-10-20');
await usuario.tab(); // Etiquetas
await usuario.tab(); // Botón
await usuario.keyboard('{Enter}');
expect(tablero.total).toBe(7);
});
});Siete pruebas que cubren el recorrido entero, incluida la que casi nadie escribe: el segundo envío tras corregir un error. Limpiar el estado de error es de las cosas que más a menudo se olvidan, y produce formularios que muestran un aviso rojo eternamente. Y la última —completar el formulario solo con el teclado— comprueba de una vez el orden de tabulación, el foco y el envío con Enter: tres propiedades de accesibilidad que ninguna prueba unitaria toca.
- Probar los errores de red en la interfaz
Última costura: qué ve el usuario cuando la red falla. En 08-04 comprobaste que api-tareas.js produce el ErrorDeApi correcto; aquí compruebas que la máquina de estados de interfaz de 07-03 reacciona como debe.
describe('Integración · estados de interfaz ante fallos de red (07-03)', () => {
let usuario, dom, red;
beforeEach(() => {
usuario = userEvent.setup();
dom = montarDom();
red = instalarFetchFalso();
});
afterEach(() => { limpiarDom(); jest.restoreAllMocks(); });
test('muestra "cargando", luego las seis tareas', async () => {
let resolver;
red.mockReturnValue(new Promise((r) => { resolver = r; }));
const carga = arrancarAplicacion({ dom });
expect(screen.getByRole('status')).toHaveTextContent(/cargando/i);
resolver(respuestaJson(datosBacklog));
await carga;
expect(await screen.findAllByRole('listitem')).toHaveLength(6);
expect(screen.queryByText(/cargando/i)).not.toBeInTheDocument();
});
test('un 500 muestra un mensaje accesible y un botón de reintentar', async () => {
red.mockResolvedValue(respuestaJson({ mensaje: 'Error interno' }, { status: 500 }));
await arrancarAplicacion({ dom });
const aviso = await screen.findByRole('alert');
expect(aviso).toHaveTextContent(/servidor/i);
expect(screen.getByRole('button', { name: /reintentar/i })).toBeVisible();
});
test('el botón de reintentar vuelve a pedir y muestra las tareas', async () => {
red.mockResolvedValueOnce(respuestaJson({ mensaje: 'Error' }, { status: 500 }))
.mockResolvedValueOnce(respuestaJson(datosBacklog));
await arrancarAplicacion({ dom });
await usuario.click(await screen.findByRole('button', { name: /reintentar/i }));
expect(await screen.findAllByRole('listitem')).toHaveLength(6);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(red).toHaveBeenCalledTimes(2);
});
test('una lista vacía muestra el estado vacío, no un error', async () => {
red.mockResolvedValue(respuestaJson([]));
await arrancarAplicacion({ dom });
expect(await screen.findByText(/no hay tareas/i)).toBeVisible();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
test('sin conexión, se avisa y se conserva lo que hubiera en el almacén', async () => {
const almacen = almacenFalso();
new RepositorioLocal({ almacen }).guardar(unTablero());
red.mockRejectedValue(new TypeError('Failed to fetch'));
await arrancarAplicacion({ dom, almacen });
expect(await screen.findByRole('alert')).toHaveTextContent(/conexión/i);
expect(screen.getAllByRole('listitem')).toHaveLength(6); // ← lo local sigue visible
});
});La primera prueba usa una técnica que conviene conocer: una promesa cuya resolución se controla desde la prueba (let resolver). Es la única forma de observar el estado intermedio «cargando», que con un mockResolvedValue normal desaparecería antes de poder comprobarlo.
Y la última prueba comprueba la degradación elegante: sin red, la aplicación no se queda en blanco, muestra lo que tiene guardado y avisa. Ese comportamiento atraviesa tres capas y no lo cubre ninguna prueba unitaria.
- MSW: simular el servidor en el nivel correcto
Sustituir globalThis.fetch funciona, pero tiene un inconveniente: estás simulando la herramienta, no el servidor. Si mañana una parte del código usa XMLHttpRequest, o navigator.sendBeacon, o un cliente HTTP distinto, tu doble no la cubre. Y las pruebas se llenan de detalles de fetch que no tienen que ver con el negocio.
MSW (Mock Service Worker) resuelve esto interceptando en la capa de red: en el navegador con un service worker (los de 07-05), y en Node con un interceptor de peticiones. El código bajo prueba hace peticiones de verdad; simplemente nunca salen de la máquina.
// pruebas/ayudas/servidor-falso.js
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { datosBacklog } from '../../js/datos/backlog.js';
const BASE = 'https://api.tallernomada.example/v1';
export const manejadores = [
http.get(`${BASE}/tareas`, ({ request }) => {
const responsable = new URL(request.url).searchParams.get('responsable');
const tareas = responsable
? datosBacklog.filter((t) => t.responsable === responsable)
: datosBacklog;
return HttpResponse.json(tareas);
}),
http.post(`${BASE}/tareas`, async ({ request }) => {
const datos = await request.json();
if (!datos.titulo?.trim()) {
return HttpResponse.json({ mensaje: 'El título es obligatorio' }, { status: 422 });
}
return HttpResponse.json({ ...datos, id: 7, estado: 'pendiente' }, { status: 201 });
}),
http.patch(`${BASE}/tareas/:id`, async ({ params, request }) =>
HttpResponse.json({ ...datosBacklog.find((t) => t.id === Number(params.id)),
...(await request.json()) }))
];
export const servidor = setupServer(...manejadores);// Uso en las pruebas
beforeAll(() => servidor.listen({ onUnhandledRequest: 'error' }));
afterEach(() => servidor.resetHandlers()); // deshace las sobrescrituras por prueba
afterAll(() => servidor.close());
test('un 503 puntual activa el estado de error', async () => {
// Sobrescribir SOLO para esta prueba
servidor.use(http.get(`${BASE}/tareas`, () =>
HttpResponse.json({ mensaje: 'No disponible' }, { status: 503 })));
await arrancarAplicacion({ dom });
expect(await screen.findByRole('alert')).toHaveTextContent(/servidor/i);
});Sustituir fetch |
MSW | |
|---|---|---|
| Nivel de intercepción | La función fetch |
La petición de red |
| Cubre otros clientes HTTP | No | Sí |
| Se reutiliza en E2E (08-06) | No | Sí, con el mismo handlers |
| Configuración | Cero | Un paquete y un setupServer |
| Legibilidad | Media | Alta: se lee como una API |
| Cuándo elegirlo | Pruebas puntuales de un módulo | Suites de integración completas |
onUnhandledRequest: 'error' merece un comentario: hace fallar cualquier petición que no tenga manejador. Es una garantía valiosísima —si un módulo empieza a llamar a un endpoint nuevo, te enteras—, y evita el escenario silencioso en que una prueba se conecta a internet de verdad sin que nadie lo note.
- Cobertura combinada: qué añade la integración
File | % Stmts | % Branch | Δ vs. solo unitarias -------------------------|---------|----------|--------------------- js/modelo/tarea.js | 98.2 | 96.1 | +1.8 (poco: ya estaba) js/modelo/tablero.js | 100.0 | 100.0 | 0.0 js/datos/repositorio… | 94.7 | 89.5 | +63.4 ← el salto grande js/vista/tablero-vista | 88.3 | 76.2 | +88.3 ← de cero js/vista/tarjeta.js | 95.1 | 88.9 | +95.1 ← de cero js/vista/controlador.js | 91.4 | 80.0 | +91.4 ← de cero js/vista/formulario.js | 86.9 | 79.3 | +86.9 ← de cero
Dos lecturas de esta tabla:
- La integración apenas mueve la cobertura del modelo. Es lógico: ya estaba cubierto por las unitarias, que además lo hacen mejor (nueve combinaciones de R6 en nueve líneas). Duplicar esa cobertura en integración sería puro coste.
- Toda la capa de vista pasa de cero a casi noventa. Es donde la integración aporta valor de forma exclusiva, porque probar un render, una delegación o un formulario requiere un DOM.
Y una advertencia que ya conoces de 08-03, ahora con un matiz nuevo: la cobertura de integración es especialmente engañosa. Una sola prueba que arranque la aplicación entera ejecuta cientos de líneas de golpe y dispara los porcentajes sin comprobar casi nada. Los números suben; la confianza, no necesariamente. Mira la columna branch y, sobre todo, cuenta las aserciones.
- Pruebas intermitentes y cómo hacerlas deterministas
Una prueba flaky es la que a veces pasa y a veces falla sin que el código cambie. Es más dañina que una prueba que falla siempre, porque enseña al equipo a ignorar el rojo: «vuelve a lanzarla, seguro que pasa». En cuanto eso arraiga, la suite ha dejado de servir.
Las causas, en orden de frecuencia en pruebas de integración:
| Causa | Síntoma | Solución |
|---|---|---|
| Esperas por tiempo | Falla en una máquina lenta o en CI | findBy… / waitFor, nunca un sleep |
| Estado compartido | Falla solo si otra prueba corrió antes | beforeEach que reconstruye todo, afterEach que limpia |
| Orden de ejecución | Falla al ejecutar en paralelo o al reordenar | Independencia total; nunca depender del orden |
| Fechas reales | Falla un martes, o el día 1 de mes | HOY como dato; temporizadores falsos |
| Aleatoriedad | Falla una de cada veinte veces | Fijar Math.random (08-04) |
| Promesas sin esperar | Falla de forma impredecible | await en todo; findBy… para lo que aparece después |
La primera es la reina, y su antídoto conviene verlo con detalle:
// ❌ Frágil: 100 ms puede bastar hoy en tu portátil y no bastar en CI
await new Promise((r) => setTimeout(r, 100));
expect(screen.getByRole('listitem')).toBeInTheDocument();
// ✅ Espera a la CONDICIÓN, no a un tiempo. Reintenta hasta que se cumpla
expect(await screen.findByRole('listitem')).toBeInTheDocument();
// ✅ Para condiciones que no son "un elemento aparece"
await waitFor(() => {
expect(tablero.total).toBe(7);
});
// ✅ Para esperar a que algo DESAPAREZCA
await waitForElementToBeRemoved(() => screen.queryByText(/cargando/i));findBy y waitFor reintentan la comprobación cada pocos milisegundos hasta que pasa o hasta agotar el tiempo límite. En una máquina rápida terminan en 5 ms; en una CI saturada, en 400. Un setTimeout fijo, en cambio, o sobra tiempo (y la suite es lenta) o falta (y falla). Nunca esperes un tiempo: espera una condición.
Cuatro medidas más que estabilizan una suite de integración:
// 1 · Detectar dependencias de orden ejecutando en orden aleatorio
// jest --randomize
// 2 · Limpiar SIEMPRE el DOM y los dobles entre pruebas
afterEach(() => {
document.body.innerHTML = '';
jest.restoreAllMocks();
localStorage.clear(); // jsdom sí lo implementa
});
// 3 · Fijar el reloj cuando el código lo lea directamente
beforeEach(() => { jest.useFakeTimers({ now: new Date('2026-09-20T09:00:00Z') }); });
// 4 · Hacer fallar cualquier petición no prevista
beforeAll(() => servidor.listen({ onUnhandledRequest: 'error' }));Y una política que conviene acordar por escrito: una prueba intermitente se arregla o se borra; nunca se reintenta a ciegas. La opción de reintentar automáticamente los fallos existe en algunos ejecutores y es tentadora, pero convierte un problema visible en uno invisible: la prueba sigue delatando una carrera real en tu código, y ahora ya nadie la ve.
- La suite de integración de Nómada Tareas
El mapa completo de lo escrito en esta lección:
pruebas/
integracion/
persistencia.test.js modelo + RepositorioLocal + migraciones (node)
serializacion.test.js el viaje toJSON → cadena → desdeJSON (node)
render.test.js TableroVista + tarjeta + reconciliar (jsdom)
delegacion.test.js controlador + eventos + modelo + vista (jsdom)
formulario.test.js formulario → modelo → render → almacén (jsdom)
red.test.js estados de interfaz ante fallos de red (jsdom)
ayudas/
montar-dom.js el HTML mínimo
almacen-falso.js el fake de Web Storage (08-04)
red-falsa.js respuestas de fetch (08-04)
servidor-falso.js los manejadores de MSW
backlog-de-prueba.js HOY, unaTarea, unTablero, capturar$ npm test
PASS unitarias pruebas/modelo/tarea.test.js
PASS unitarias pruebas/modelo/tablero.test.js
PASS unitarias pruebas/util/fechas.test.js
PASS unitarias pruebas/util/tiempo.test.js
PASS unitarias pruebas/datos/api-tareas.test.js
PASS unitarias pruebas/datos/http.test.js
PASS unitarias pruebas/datos/repositorio-local.test.js
PASS integracion pruebas/integracion/persistencia.test.js
PASS integracion pruebas/integracion/serializacion.test.js
PASS integracion pruebas/integracion/render.test.js
PASS integracion pruebas/integracion/delegacion.test.js
PASS integracion pruebas/integracion/formulario.test.js
PASS integracion pruebas/integracion/red.test.js
Test Suites: 13 passed, 13 total
Tests: 124 passed, 124 total
Time: 3.71 sCiento veinticuatro comprobaciones en menos de cuatro segundos, sin abrir un navegador. Y el paso a integración continua es una línea del flujo de 08-02, que ya estaba preparado:
Errores Comunes y Consejos
- Convertir una prueba de integración en una unitaria con dobles de más. Si sustituyes
RepositorioLocal, ya no estás probando la costura: solo la pieza. Real todo lo tuyo; doble solo lo externo. - Copiar
index.htmlentero en la prueba. Ata la prueba a cada cambio de maquetación. Monta el mínimo que la vista necesita. - Consultar por clase CSS. Se rompe al renombrar una clase, sin que nada esté roto. Consulta por rol y nombre accesible.
- Usar
getBy…para comprobar una ausencia. Lanza si no encuentra, así que falla justo cuando el comportamiento es correcto. Para ausencias,queryBy…. - Olvidar
awaitconuser-evento confindBy…. Ambos devuelven promesas. Sinawait, la aserción se ejecuta antes de que ocurra nada. - Combinar
jest.useFakeTimers()conuser-eventsinadvanceTimers. La prueba se cuelga hasta agotar el tiempo límite, y el mensaje de error no ayuda nada. - Esperar con
setTimeouten lugar de conwaitFor/findBy. Es la causa número uno de pruebas intermitentes: el tiempo que basta en tu portátil no basta en CI. - Buscar fallos de diseño visual en jsdom. No hay pintado: todas las medidas son cero y
getComputedStylees limitado. Eso va a E2E, en 08-06. - No limpiar el DOM ni el almacenamiento entre pruebas. jsdom comparte
documentdentro de un mismo fichero: la tarjeta de la prueba anterior sigue ahí. - Consejo: si consultar por rol es imposible, arregla el HTML. Un
<div onclick>que no aparece engetByRole('button')tampoco es alcanzable con el teclado. La prueba te está señalando un problema real. - Consejo: escribe una prueba de integración por cada fallo de costura que encuentres. Son las más rentables: cubren muchas líneas y detectan la clase de fallo que más caro sale en producción.
- Consejo: usa
jest --randomizede vez en cuando. Es la forma más rápida de descubrir dependencias ocultas de orden entre pruebas.
Ejercicios
Ejercicio 1 — La integración de los filtros con la URL.
Escribe pruebas/integracion/filtros.test.js que pruebe la costura completa entre enrutador.js (07-06), TableroVista y el controlador, en jsdom. Debe comprobar: (a) arrancar con ?responsable=Iván en la URL muestra exactamente 3 tarjetas y el resumen sigue diciendo 45 h abiertas —porque filtrar es presentación y no toca el modelo—; (b) pulsar el filtro de Lucía actualiza la URL con pushState y deja 1 tarjeta visible; (c) escribir en el buscador usa replaceState y filtra 300 ms después de dejar de teclear; (d) popstate (el botón atrás) restaura el filtro anterior. Recuerda el detalle de los temporizadores falsos con user-event.
Ejercicio 2 — Migración de formato v1 → v2 con datos reales.
La versión 2 del formato de RepositorioLocal añade un campo obligatorio creada (fecha ISO) y convierte revisor de cadena a un objeto { nombre, notificado }. Escribe la función migrar y su suite de integración, cubriendo: un tablero v1 completo con las 6 tareas del backlog que tras migrar conserva los números canónicos; una tarea con revisor: null que se convierte en revisor: null y no en { nombre: null }; un tablero ya en v2 que no se toca; un formato de versión desconocida que se descarta sin borrar el original; y la comprobación de que tras migrar y volver a guardar, la clave del almacén es nomada:tablero:v2.
Ejercicio 3 — Diagnosticar tres pruebas intermitentes. Estas tres pruebas fallan «a veces» en la integración continua y nunca en local. Identifica la causa de cada una, explica en qué condiciones falla y reescríbela para que sea determinista.
// A
test('las tareas aparecen tras cargar', async () => {
arrancarAplicacion({ dom });
await new Promise((r) => setTimeout(r, 200));
expect(screen.getAllByRole('listitem')).toHaveLength(6);
});
// B
const repositorio = new RepositorioLocal({ almacen: almacenFalso() });
test('guarda el tablero', () => {
repositorio.guardar(unTablero());
expect(repositorio.cargar().total).toBe(6);
});
test('limpiar deja el almacén vacío', () => {
repositorio.limpiar();
expect(repositorio.cargar()).toBeNull();
});
// C
test('la tarea vencida se marca', () => {
const vista = new TableroVista({ contenedor: dom.contenedor, tablero: unTablero() });
vista.render();
expect(document.querySelectorAll('.tarea--vencida')).toHaveLength(1);
});Soluciones
Solución 1
/**
* @jest-environment jsdom
*/
import { jest } from '@jest/globals';
import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { TableroVista } from '../../js/vista/tablero-vista.js';
import { conectarEnrutador, leerEstadoDeUrl } from '../../js/vista/enrutador.js';
import { conectarFiltros } from '../../js/vista/controlador.js';
import { montarDom, limpiarDom } from '../ayudas/montar-dom.js';
import { unTablero, HOY } from '../ayudas/backlog-de-prueba.js';
describe('Integración · filtros ↔ URL (07-06)', () => {
let usuario, dom, tablero, vista;
function arrancar(busqueda = '') {
// jsdom permite reescribir la URL sin recargar
history.replaceState(null, '', `/${busqueda}`);
dom = montarDom();
tablero = unTablero();
vista = new TableroVista({ contenedor: dom.contenedor, resumen: dom.resumen, tablero, hoy: HOY });
vista.actualizar({ filtros: leerEstadoDeUrl() });
conectarFiltros({ contenedor: document.body, vista });
conectarEnrutador(vista);
}
beforeEach(() => {
jest.useFakeTimers();
usuario = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); // ← imprescindible
});
afterEach(() => {
jest.useRealTimers();
limpiarDom();
history.replaceState(null, '', '/');
});
test('(a) arrancar con ?responsable=Iván muestra 3 tarjetas sin tocar el modelo', () => {
arrancar('?responsable=Iván');
expect(screen.getAllByRole('listitem')).toHaveLength(3);
expect(tablero.total).toBe(6); // el MODELO no se filtra
expect(dom.resumen).toHaveTextContent(/45/); // el resumen es del tablero completo
});
test('(b) pulsar el filtro de Lucía añade una entrada al historial', async () => {
arrancar();
const antes = history.length;
await usuario.click(screen.getByRole('button', { name: 'Lucía' }));
expect(new URL(location.href).searchParams.get('responsable')).toBe('Lucía');
expect(history.length).toBe(antes + 1); // pushState, no replaceState
expect(screen.getAllByRole('listitem')).toHaveLength(1);
});
test('(c) el buscador usa replaceState y filtra tras 300 ms', async () => {
arrancar();
const antes = history.length;
await usuario.type(screen.getByLabelText('Buscar'), 'carpint');
expect(screen.getAllByRole('listitem')).toHaveLength(6); // aún sin filtrar
jest.advanceTimersByTime(300);
expect(screen.getAllByRole('listitem')).toHaveLength(1);
expect(new URL(location.href).searchParams.get('q')).toBe('carpint');
expect(history.length).toBe(antes); // replaceState: no crece
});
test('(d) popstate restaura el filtro anterior', async () => {
arrancar();
await usuario.click(screen.getByRole('button', { name: 'Iván' }));
expect(screen.getAllByRole('listitem')).toHaveLength(3);
// jsdom no navega solo: se simula el evento con el estado que habría
history.replaceState({ responsable: null, texto: '', orden: 'prioridad' }, '', '/');
window.dispatchEvent(new PopStateEvent('popstate', { state: { responsable: null } }));
expect(screen.getAllByRole('listitem')).toHaveLength(6);
});
});Solución 2
// js/datos/migraciones.js
import { ErrorDeDatos } from '../modelo/errores.js';
export const VERSION_ACTUAL = 2;
const MIGRACIONES = {
1: (datos) => ({
nombre: datos.nombre,
version: 2,
tareas: datos.tareas.map((t) => ({
...t,
creada: t.creada ?? '2026-01-01',
// null se conserva como null: NO se envuelve en un objeto vacío
revisor: t.revisor == null ? null : { nombre: t.revisor, notificado: false }
}))
})
};
export function migrar(datos) {
let actuales = datos;
let vueltas = 0;
while ((actuales.version ?? 1) < VERSION_ACTUAL) {
if (vueltas++ > 10) throw new ErrorDeDatos('Bucle de migración detectado');
const paso = MIGRACIONES[actuales.version ?? 1];
if (!paso) throw new ErrorDeDatos(`Sin migración desde v${actuales.version}`);
actuales = paso(actuales);
}
if ((actuales.version ?? 1) > VERSION_ACTUAL) {
throw new ErrorDeDatos(`Formato v${actuales.version} más reciente que la aplicación`);
}
return actuales;
}// pruebas/integracion/migraciones.test.js
import { jest } from '@jest/globals';
import { RepositorioLocal } from '../../js/datos/repositorio-local.js';
import { almacenFalso } from '../ayudas/almacen-falso.js';
import { unTablero, HOY } from '../ayudas/backlog-de-prueba.js';
import { datosBacklog } from '../../js/datos/backlog.js';
const v1 = () => ({ nombre: 'Taller Nómada', version: 1, tareas: datosBacklog });
describe('Integración · migración v1 → v2', () => {
let almacen, repositorio, avisos;
beforeEach(() => {
almacen = almacenFalso();
repositorio = new RepositorioLocal({ almacen });
avisos = jest.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => { avisos.mockRestore(); });
test('un tablero v1 completo migra conservando los números canónicos', () => {
almacen.setItem('nomada:tablero:v1', JSON.stringify(v1()));
const tablero = repositorio.cargar();
expect(tablero.total).toBe(6);
expect(tablero.resumen(HOY)).toMatchObject({
horasAbiertas: 45, horasTotales: 48, vencidas: 1, esfuerzo: 124
});
expect(tablero.buscarPorId(1).creada).toBe('2026-01-01');
});
test('un revisor con nombre se convierte en objeto', () => {
almacen.setItem('nomada:tablero:v1', JSON.stringify(v1()));
expect(repositorio.cargar().buscarPorId(1).revisor)
.toStrictEqual({ nombre: 'Marta', notificado: false });
});
test('un revisor null sigue siendo null, no un objeto con nombre null', () => {
almacen.setItem('nomada:tablero:v1', JSON.stringify(v1()));
const tarea = repositorio.cargar().buscarPorId(2); // la 2 no tiene revisor
expect(tarea.revisor).toBeNull();
expect(tarea.revisor).not.toStrictEqual({ nombre: null, notificado: false });
});
test('un tablero ya en v2 no se toca', () => {
const original = unTablero();
repositorio.guardar(original);
expect(repositorio.cargar().toJSON()).toStrictEqual(original.toJSON());
});
test('una versión futura desconocida se descarta SIN borrar el original', () => {
const futuro = JSON.stringify({ nombre: 'X', version: 99, tareas: [] });
almacen.setItem('nomada:tablero:v2', futuro);
expect(repositorio.cargar()).toBeNull();
expect(almacen.getItem('nomada:tablero:v2')).toBe(futuro); // intacto
expect(avisos).toHaveBeenCalled();
});
test('tras migrar y guardar, los datos viven bajo la clave v2', () => {
almacen.setItem('nomada:tablero:v1', JSON.stringify(v1()));
repositorio.guardar(repositorio.cargar());
expect(almacen.getItem('nomada:tablero:v2')).not.toBeNull();
expect(JSON.parse(almacen.getItem('nomada:tablero:v2')).version).toBe(2);
});
});Solución 3
| Prueba | Causa | Cuándo falla |
|---|---|---|
| A | Espera por tiempo fijo (200 ms) y además no espera a arrancarAplicacion |
En CI, con la máquina cargada, la carga tarda 250 ms y getAllByRole lanza porque aún no hay <li> |
| B | Estado compartido entre pruebas: un solo repositorio de módulo |
La segunda prueba depende de que la primera haya guardado. Si se ejecuta sola, o el orden cambia (--randomize), falla |
| C | Fecha real: el TableroVista se crea sin hoy, así que usa la del sistema |
Pasa hoy y falla el 1 de octubre de 2026, cuando la tarea 3 también estará vencida y habrá 2 |
// A · corregida: esperar a la CONDICIÓN, no a un tiempo
test('las tareas aparecen tras cargar', async () => {
await arrancarAplicacion({ dom }); // ← esperar la promesa
expect(await screen.findAllByRole('listitem')).toHaveLength(6); // ← findBy reintenta
});
// B · corregida: estado nuevo en cada prueba
describe('RepositorioLocal', () => {
let almacen, repositorio;
beforeEach(() => {
almacen = almacenFalso();
repositorio = new RepositorioLocal({ almacen });
});
test('guardar y cargar devuelve las seis tareas', () => {
repositorio.guardar(unTablero());
expect(repositorio.cargar().total).toBe(6);
});
test('limpiar deja el almacén vacío', () => {
repositorio.guardar(unTablero()); // ← prepara SU propio estado
repositorio.limpiar();
expect(repositorio.cargar()).toBeNull();
expect(almacen.length).toBe(0);
});
});
// C · corregida: la fecha entra como dato, y se consulta por rol
test('la tarea vencida se marca visualmente (R10)', () => {
const vista = new TableroVista({
contenedor: dom.contenedor, resumen: dom.resumen,
tablero: unTablero(), hoy: HOY // ← '2026-09-20', fijo
});
vista.render();
const vencida = screen.getByRole('listitem', { name: /presupuesto de la carpintería/i });
expect(vencida).toHaveClass('tarea--vencida');
expect(document.querySelectorAll('.tarea--vencida')).toHaveLength(1);
});Conclusión
Nómada Tareas ya no solo tiene piezas probadas: tiene costuras probadas. Sabes qué distingue una prueba de integración de una unitaria —dentro del sistema todo real, fuera del sistema dobles— y conoces las cinco familias de fallos que solo aparecen al juntar: contratos mal entendidos, tipos que cambian al cruzar una frontera, formatos de fecha divergentes, errores que cada capa cree que maneja la otra, y problemas de orden y ciclo de vida. Las cinco tienen en común que cada pieza, por separado, está en verde. Sitúas la integración en la pirámide y conoces el testing trophy, que ensancha ese nivel para aplicaciones de interfaz y pone las comprobaciones estáticas de 08-02 como base gratuita de todo.
Has montado la costura de modelo y datos: Tablero con RepositorioLocal real sobre un almacén en memoria, comprobando no solo que los datos vuelven, sino que vuelven como instancias vivas con estaVencida(), esfuerzo y R6 vigente; que lo que sale del almacén se revalida siempre, porque cualquiera puede editarlo desde el panel Application; y que el viaje completo toJSON → stringify → setItem → getItem → parse → desdeJSON no pierde el #estado privado, no convierte las fechas en objetos Date, conserva el revisor: null como clave presente en lugar de dejar que stringify la borre, y mantiene las etiquetas como array. Y has escrito las migraciones de versión, esas pruebas poco glamurosas que protegen datos de personas reales, con la política de descartar en memoria sin borrar del almacén lo que no se entiende.
Has montado la costura de la vista sin abrir un navegador: jsdom como entorno, con sus límites bien delimitados —hay árbol, eventos, formularios y localStorage; no hay pintado, ni medidas, ni service workers—, un HTML mínimo montado a mano en lugar de una copia de index.html, y la comprobación de que reconciliar() reutiliza el mismo nodo con toBe, que es el único sitio donde comparar identidad de objetos es exactamente lo que quieres. Con Testing Library consultas como lo haría una persona: getByRole con su nombre accesible, getByLabelText, queryBy… para las ausencias —nunca getBy, que lanza justo cuando el comportamiento es correcto— y findBy… para lo que aparece tras un await. Y entiendes el argumento decisivo: si no puedes encontrar un elemento por su rol, un lector de pantalla tampoco; la prueba se convierte en una auditoría de accesibilidad continua. Con user-event simulas secuencias completas de interacción —tecla a tecla, con foco, con tabulación— y sabes que combinarlo con temporizadores falsos exige pasarle advanceTimers o la prueba se cuelga.
Con eso has probado la delegación de eventos de 06-04, incluida la propiedad que la justifica —que funciona con tarjetas creadas después de conectar el oyente— y el clic en el hueco que devuelve null en closest. Has probado el recorrido completo formulario → validación de vista → reglas del modelo → tablero → render → almacén, con los casos que casi nadie escribe: el segundo envío tras corregir un error, y el formulario completado solo con el teclado. Y has probado los estados de interfaz ante fallos de red, con la técnica de la promesa controlada desde la prueba para poder observar el «cargando», el botón de reintentar de 07-03 y la degradación elegante que muestra lo guardado cuando no hay conexión. Conoces MSW y por qué interceptar la petición es un nivel más correcto que sustituir fetch, con onUnhandledRequest: 'error' como red de seguridad frente a llamadas imprevistas. Sabes leer la cobertura combinada —la integración apenas mueve el modelo y levanta la vista de cero— y sabes que un solo arranque de la aplicación infla los porcentajes sin comprobar casi nada. Y tienes el catálogo de causas de las pruebas intermitentes con su antídoto central: nunca esperes un tiempo, espera una condición, con findBy y waitFor en lugar de setTimeout; más --randomize para descubrir dependencias de orden y la política de arreglar o borrar, nunca reintentar a ciegas.
Ciento veinticuatro pruebas en menos de cuatro segundos, trece suites, y toda la aplicación cubierta… salvo una cosa. Todo esto se ejecuta en jsdom, que es una imitación del navegador: no pinta nada, todas las medidas valen cero, getComputedStyle apenas funciona, 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 z-index que hace inaccesible el formulario, un CSS que oculta la columna de tareas hechas, un módulo que no carga porque falta la extensión en un import, o un service worker que sirve una versión antigua de la aplicación: nada de eso aparece en las 124 pruebas y todo eso deja a Marta mirando una pantalla que no funciona. Para verlo hace falta abrir la aplicación de verdad, en un navegador de verdad, y usarla como la usaría ella. Eso es Pruebas de Extremo a Extremo con Cypress, donde escribirás los tres recorridos críticos de Nómada Tareas —crear una tarea, completarla y comprobar el resumen, y filtrar por responsable con la URL compartible— y aprenderás por qué precisamente estas pruebas, las más convincentes de todas, deben ser pocas y estar muy bien elegidas.
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
