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

  1. Qué significa integrar
  2. Los cinco fallos que solo aparecen al juntar piezas
  3. Dónde encaja la integración: pirámide y testing trophy
  4. Integrar modelo y datos: Tablero + RepositorioLocal
  5. El viaje de ida y vuelta: toJSON → cadena → desdeJSON
  6. Migraciones de versión
  7. jsdom: un DOM sin navegador
  8. Montar el HTML mínimo y renderizar
  9. Testing Library: consultar como lo haría una persona
  10. Las consultas y cuándo usar cada una
  11. user-event: interactuar de verdad
  12. Probar la delegación de eventos
  13. El flujo completo: formulario → modelo → render
  14. Probar los errores de red en la interfaz
  15. MSW: simular el servidor en el nivel correcto
  16. Cobertura combinada: qué añade la integración
  17. Pruebas intermitentes y cómo hacerlas deterministas
  18. La suite de integración de Nómada Tareas
  19. Errores Comunes y Consejos
  20. Ejercicios
  21. Conclusión

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

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

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

  1. Integrar modelo y datos: Tablero + RepositorioLocal

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

  1. El viaje de ida y vuelta: toJSON → cadena → desdeJSON

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

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

  1. jsdom: un DOM sin navegador

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

npm install --save-dev jest-environment-jsdom

Se puede activar globalmente o —mejor— fichero a fichero, con un comentario en la cabecera:

/**
 * @jest-environment jsdom
 */
import { TableroVista } from '../../js/vista/tablero-vista.js';

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

  1. 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 por getByLabelText, 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.

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

npm install --save-dev @testing-library/dom @testing-library/user-event @testing-library/jest-dom
import { screen, within } from '@testing-library/dom';
import '@testing-library/jest-dom';        // matchers como toBeVisible, toHaveTextContent

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

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

Y 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');

  1. user-event: interactuar de verdad

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

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

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

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

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

  1. Cobertura combinada: qué añade la integración

npm test -- --coverage
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.

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

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

Ciento 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:

      - name: Ejecutar las pruebas
        run: npm test -- --coverage --ci

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.html entero 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 await con user-event o con findBy…. Ambos devuelven promesas. Sin await, la aserción se ejecuta antes de que ocurra nada.
  • Combinar jest.useFakeTimers() con user-event sin advanceTimers. La prueba se cuelga hasta agotar el tiempo límite, y el mensaje de error no ayuda nada.
  • Esperar con setTimeout en lugar de con waitFor/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 getComputedStyle es limitado. Eso va a E2E, en 08-06.
  • No limpiar el DOM ni el almacenamiento entre pruebas. jsdom comparte document dentro 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 en getByRole('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 --randomize de 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 toJSONstringifysetItemgetItemparsedesdeJSON 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

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

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

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

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

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados