La lección anterior terminó con una frontera clara: ESLint sabe decirte que una variable no se usa, pero no sabe que el resumen del backlog debe dar 45 horas abiertas de 48. Eso es comportamiento, y el comportamiento solo se comprueba de una forma: ejecutando el código con entradas conocidas y comparando la salida con lo esperado. Automatizado, repetible, en segundos. Esta lección es donde el Módulo 8 salda sus deudas: las tres pruebas que dejaste apuntadas al depurar, y la promesa que se hizo en 03-03 sobre por qué las funciones puras eran fáciles de probar. Vas a montar Jest sobre Nómada Tareas, aprender la anatomía de una prueba y el patrón Preparar-Actuar-Comprobar, dominar los matchers que de verdad se usan, cubrir la tabla de transiciones R6 con pruebas parametrizadas, probar código asíncrono, medir la cobertura sin caer en su trampa, y escribir tu primera regla con el ciclo rojo-verde-refactor. Y todo ello sin abrir el navegador ni una sola vez.

Contenido

  1. Qué es una prueba automatizada
  2. Qué gana el equipo: tres beneficios y ninguno es «detectar bugs»
  3. La pirámide de pruebas
  4. Jest: instalación y primera ejecución
  5. Configuración para módulos ES
  6. Anatomía de una prueba: describe, test y el patrón AAA
  7. Los matchers que se usan de verdad
  8. toBe, toEqual y toStrictEqual: la comparación por referencia
  9. Comprobar que algo lanza un error
  10. Ganchos: beforeEach y compañía
  11. El estado compartido, fuente de fallos intermitentes
  12. Pruebas parametrizadas con test.each
  13. Probar código asíncrono
  14. Qué hace testeable un módulo
  15. Cobertura: qué mide de verdad
  16. Nombrar una prueba y la regla del motivo único
  17. TDD: rojo, verde, refactor
  18. Nómada Tareas: la batería completa del modelo
  19. Errores Comunes y Consejos
  20. Ejercicios
  21. Conclusión

  1. Qué es una prueba automatizada

Una prueba automatizada es un programa que ejecuta otro programa y comprueba que hace lo que debe. Nada más. Todo lo demás —Jest, los matchers, la cobertura— es infraestructura alrededor de esa idea.

De hecho, ya has escrito pruebas sin saberlo. Esto, en la consola, es una prueba manual:

const tablero = new Tablero('Taller Nómada', crearBacklog());
tablero.resumen('2026-09-20');
// { total: 6, abiertas: 5, horasTotales: 48, horasAbiertas: 45, vencidas: 1, esfuerzo: 124 }

Miras el resultado, compruebas que sale 45 y sigues. El problema no es la comprobación: es que eres quien la ejecuta, quien la recuerda y quien decide si el número está bien. Una semana después, nadie vuelve a mirarlo. La versión automatizada dice lo mismo, pero lo dice sola:

test('el resumen del backlog canónico da 45 h abiertas de 48', () => {
  const tablero = new Tablero('Taller Nómada', crearBacklog());
  expect(tablero.resumen('2026-09-20').horasAbiertas).toBe(45);
});

Esas tres líneas, ejecutadas en cada commit, habrían detectado el caso 3 de 08-01 —el horasAbiertas que sumaba 48— en el mismo instante en que se introdujo, no semanas después por un aviso de Lucía.

flowchart LR
    A["Preparar<br/>entrada conocida"] --> B["Actuar<br/>ejecutar el código"]
    B --> C["Comprobar<br/>salida esperada"]
    C -->|coinciden| D["✓ verde"]
    C -->|difieren| E["✗ rojo<br/>con el detalle"]

  1. Qué gana el equipo: tres beneficios y ninguno es «detectar bugs»

Suena contraintuitivo, pero las pruebas no son especialmente buenas encontrando fallos nuevos. Un fallo que no se te ocurrió no tendrá prueba. Lo que las pruebas hacen extraordinariamente bien es otra cosa:

1 · Red de seguridad para refactorizar. Este es el beneficio grande. En 08-01 alguien "optimizó" un getter y rompió el recuento de horas sin que nadie se enterase. Con una batería de pruebas, ese cambio se pone rojo en dos segundos. La consecuencia es enorme: puedes cambiar el código sin miedo. Sin pruebas, todo refactor es una apuesta, y la reacción racional es no tocar nada; el código se pudre porque nadie se atreve a mejorarlo.

2 · Documentación ejecutable. Un documento que dice «no se puede pasar de hecha a en curso» puede quedar obsoleto y nadie lo nota. Una prueba que lo afirma falla el día en que deja de ser cierto. Lee este bloque:

describe('Tarea · transiciones de estado (R6)', () => {
  test('pendiente → en-curso está permitido', () => { … });
  test('en-curso → hecha está permitido', () => { … });
  test('en-curso → pendiente está permitido (se puede devolver una tarea)', () => { … });
  test('hecha → en-curso lanza ErrorDeValidacion: una tarea hecha no se reabre', () => { … });
  test('pendiente → hecha lanza ErrorDeValidacion: hay que empezarla antes', () => { … });
});

Sin leer una línea de implementación ya sabes cuáles son las reglas del dominio. Y esa documentación no puede mentir, porque se ejecuta.

3 · Presión hacia un diseño mejor. Este es el más sutil y el más valioso a largo plazo. Cuando una función es difícil de probar, casi siempre es porque hace demasiadas cosas, depende de demasiado, o mezcla decisión con efecto. La dificultad de probar es un detector de mal diseño. Lo verás con claridad en el apartado 14: Tablero.resumen() se prueba en tres líneas porque recibe datos y devuelve datos; guardarYRenderizar() no se puede probar sin un navegador porque mezcla tres responsabilidades.

Un cuarto beneficio, más prosaico pero decisivo en el día a día: el ciclo de retroalimentación. Comprobar a mano que las seis reglas de estado siguen funcionando exige abrir la aplicación, crear tareas y pulsar botones: cinco minutos si te concentras. La misma comprobación automatizada tarda 200 milisegundos y se ejecuta veinte veces por hora sin que lo pienses.

  1. La pirámide de pruebas

No todas las pruebas cuestan ni valen lo mismo. La pirámide de pruebas es el modelo clásico para repartirlas:

flowchart TD
    E["Extremo a extremo (E2E)<br/>La app entera en un navegador real<br/>~5 %"]
    I["Integración<br/>Varias piezas juntas: modelo + datos, vista + DOM<br/>~20 %"]
    U["Unitarias<br/>Una unidad aislada: una clase, una función<br/>~75 %"]
    E --> I --> U
    style E fill:#fecaca,stroke:#b91c1c
    style I fill:#fde68a,stroke:#b45309
    style U fill:#bbf7d0,stroke:#15803d
Nivel Qué prueba Velocidad Fragilidad Qué falla te dice
Unitaria Una función o clase, aislada Milisegundos Muy baja Exactamente qué línea está mal
Integración Varias piezas colaborando Décimas de segundo Media Que dos piezas no se entienden
E2E La aplicación entera, como la usa Marta Segundos Alta Que algo del recorrido no funciona

La forma de pirámide se justifica por dos ejes que van en direcciones opuestas: al subir aumenta la confianza (una prueba E2E verde significa que la aplicación funciona de verdad) pero también el coste y la fragilidad (tarda segundos, se rompe al cambiar un texto, y cuando falla no dice dónde).

Dos advertencias sobre este modelo:

  • La pirámide es una guía, no una ley. Los porcentajes dependen del proyecto. En Pruebas de Integración conocerás el testing trophy, una variante que da más peso a la integración y que encaja mejor con aplicaciones de interfaz.
  • El antipatrón que hay que evitar tiene nombre: el cono de helado. Muchas pruebas E2E lentas encima, pocas unitarias debajo. La suite tarda cuarenta minutos, falla de forma intermitente, nadie sabe por qué, y acaba desactivada.

En este módulo construyes los tres niveles sobre el mismo proyecto: aquí las unitarias del modelo, en 08-05 la integración, y en 08-06 tres recorridos E2E bien elegidos.

  1. Jest: instalación y primera ejecución

Jest es un ejecutor de pruebas completo: encuentra los ficheros, los ejecuta en paralelo, ofrece las aserciones, mide la cobertura y trae dobles de prueba incorporados (eso último, en 08-04).

npm install --save-dev jest

Jest descubre las pruebas por convención. Cualquiera de estas ubicaciones vale:

Patrón Ejemplo Cuándo conviene
*.test.js junto al código js/modelo/tarea.test.js Proyectos pequeños: la prueba está al lado
__tests__/ js/modelo/__tests__/tarea.js Convención por defecto de Jest
Carpeta pruebas/ espejo pruebas/modelo/tarea.test.js La que usaremos: separa código de pruebas

Para Nómada Tareas usamos una carpeta espejo, porque mantiene limpia la carpeta js/ que se sirve al navegador:

nomada-tareas/
  js/                       ← la aplicación (se despliega)
  pruebas/                  ← las pruebas (nunca se despliegan)
    modelo/
      tarea.test.js
      tablero.test.js
    util/
      fechas.test.js
    ayudas/
      backlog-de-prueba.js  ← utilidades compartidas, sin pruebas dentro

Los scripts:

{
  "scripts": {
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
    "test:watch": "npm test -- --watch",
    "test:cov": "npm test -- --coverage"
  }
}

Y la ejecución:

$ npm test

 PASS  pruebas/modelo/tarea.test.js
 PASS  pruebas/modelo/tablero.test.js

Test Suites: 2 passed, 2 total
Tests:       31 passed, 31 total
Snapshots:   0 total
Time:        0.842 s

El modo watch es donde Jest cambia la forma de trabajar:

npm run test:watch

Se queda ejecutándose y, en cada guardado, vuelve a lanzar solo las pruebas afectadas por lo que has cambiado (usa el grafo de dependencias de tus importaciones para saberlo). Con el editor a la izquierda y el resultado a la derecha, el ciclo escribir → guardar → ver verde baja de un segundo. Sus atajos interactivos:

Tecla Qué hace
a Ejecutar todas las pruebas
f Solo las que fallaron la última vez
p Filtrar por patrón de nombre de fichero
t Filtrar por nombre de prueba
o Solo lo relacionado con ficheros modificados en Git

  1. Configuración para módulos ES

Aquí hay un escollo real que conviene entender, porque es la primera piedra con la que tropieza todo el mundo.

Jest nació cuando Node usaba CommonJS (require). Tu proyecto usa módulos ES (import/export), que es lo que estudiaste en 05-04 y lo que el navegador ejecuta de forma nativa. Hay dos caminos para conciliarlos:

Camino A · Módulos ES nativos (el que usamos). Node ejecuta los import de verdad; no hay ninguna transformación por el medio, así que lo que se prueba es exactamente lo que se despliega.

// package.json
{
  "type": "module",
  "scripts": {
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
  }
}
// jest.config.js
export default {
  // 'node' basta para el modelo: no toca DOM. En 08-05 cambiará a 'jsdom'.
  testEnvironment: 'node',

  // Sin transformaciones: Node entiende los import tal cual.
  transform: {},

  testMatch: ['**/pruebas/**/*.test.js'],

  // Ficheros de la aplicación que cuentan para la cobertura
  collectCoverageFrom: ['js/**/*.js', '!js/app.js'],

  // Salida más legible cuando algo falla
  verbose: false
};

La bandera --experimental-vm-modules de Node es lo que habilita los módulos ES dentro del entorno aislado de Jest. El nombre asusta más de lo que debería: es el camino oficialmente documentado y funciona de forma estable. Como no se puede pasar por jest.config.js, va en el script (o en NODE_OPTIONS).

Camino B · Transformar con Babel. Se instala babel-jest con un preajuste que convierte los import a require antes de ejecutar. Ventaja: todo funciona sin banderas y con soporte pleno de las funciones de simulación de módulos. Inconveniente: pruebas un código transformado, no el que despliegas, y hay una capa más que configurar y que puede fallar.

// babel.config.js — solo si eliges el camino B
export default {
  presets: [['@babel/preset-env', { targets: { node: 'current' } }]]
};
Camino A · ESM nativo Camino B · Babel
Fidelidad con producción Alta: mismo código Media: transformado
Configuración Una bandera de Node Babel + preajuste
jest.mock de módulos Requiere la API de ESM (08-04) Directo
Recomendación aquí Este Alternativa válida

Elegimos el camino A porque encaja con el espíritu del proyecto: Nómada Tareas se sirve sin empaquetador, y probar el código real evita la clase entera de fallos «funcionaba en las pruebas y no en el navegador».

  1. Anatomía de una prueba: describe, test y el patrón AAA

// pruebas/modelo/tarea.test.js
import { Tarea } from '../../js/modelo/tarea.js';
import { ErrorDeValidacion } from '../../js/modelo/errores.js';

const DATOS_VALIDOS = {
  id: 1,
  titulo: 'Rediseñar la sala polivalente',
  responsable: 'Iván',
  prioridad: 'alta',
  estado: 'en-curso',
  etiquetas: ['espacio', 'diseño'],
  horasEstimadas: 12,
  fechaLimite: '2026-09-30',
  revisor: 'Marta'
};

describe('Tarea', () => {
  describe('construcción', () => {
    test('conserva el título sin espacios sobrantes', () => {
      // Preparar (Arrange)
      const datos = { ...DATOS_VALIDOS, titulo: '  Rediseñar la sala  ' };

      // Actuar (Act)
      const tarea = new Tarea(datos);

      // Comprobar (Assert)
      expect(tarea.titulo).toBe('Rediseñar la sala');
    });
  });
});

Las tres piezas:

  • describe(nombre, fn) agrupa pruebas relacionadas. Se puede anidar, y anidarlo bien produce una salida que se lee como un índice del comportamiento. No es obligatorio, pero en cuanto pasas de diez pruebas por fichero, es lo que las hace navegables.
  • test(nombre, fn) es una prueba. it es exactamente lo mismo, un alias que viene de la tradición de escribir it('should trim the title'). Usa el que prefieras, pero usa siempre el mismo en todo el proyecto.
  • expect(valor).matcher(esperado) es la aserción. Si se cumple, no pasa nada; si no, Jest lanza un error con la diferencia detallada.

El patrón AAA (Arrange-Act-Assert, o Preparar-Actuar-Comprobar) es la estructura que debe tener el cuerpo de toda prueba:

Fase Qué haces Señal de alarma
Preparar Construyes los datos y el objeto bajo prueba Si ocupa 30 líneas, el objeto es demasiado complicado
Actuar Una sola llamada: lo que estás probando Si hay tres llamadas, probablemente son tres pruebas
Comprobar Las aserciones sobre el resultado Si compruebas cinco cosas sin relación, divide

Separar las tres fases con líneas en blanco (o con comentarios al principio, mientras te acostumbras) es una de esas costumbres pequeñas que hacen que las pruebas ajenas se lean en cinco segundos.

  1. Los matchers que se usan de verdad

Jest trae decenas de matchers. Estos son los que cubren el 95 % de los casos:

Matcher Comprueba Ejemplo
toBe(v) Igualdad por identidad (Object.is) expect(t.horasEstimadas).toBe(12)
toEqual(v) Igualdad estructural, recursiva expect(t.etiquetas).toEqual(['espacio', 'diseño'])
toStrictEqual(v) Estructural + tipo de clase + claves undefined expect(t.toJSON()).toStrictEqual({ … })
toContain(v) Un array contiene un valor (por identidad) expect(t.etiquetas).toContain('diseño')
toContainEqual(v) Un array contiene un objeto equivalente expect(tareas).toContainEqual({ id: 3, … })
toHaveLength(n) .length de array o cadena expect(tablero.abiertas).toHaveLength(5)
toMatchObject(v) El objeto contiene al menos estas propiedades expect(r).toMatchObject({ horasAbiertas: 45 })
toThrow(v) La función lanza (opcionalmente, algo concreto) expect(() => new Tarea({})).toThrow(ErrorDeValidacion)
toBeCloseTo(n, d) Números decimales, con tolerancia expect(media).toBeCloseTo(8.166, 2)
toBeNull() Exactamente null expect(tablero.buscarPorId(99)).toBeNull()
toBeUndefined() Exactamente undefined expect(t.revisor).toBeUndefined()
toBeTruthy() / toBeFalsy() Veracidad (01-07) expect(t.abierta).toBeTruthy()
toBeGreaterThan(n) Comparación numérica expect(r.esfuerzo).toBeGreaterThan(100)
not Niega cualquier matcher expect(t.etiquetas).not.toContain('web')

Dos consejos sobre su elección:

  • Prefiere el matcher más específico. expect(tablero.abiertas).toHaveLength(5) falla diciendo «esperaba longitud 5, recibí 6»; expect(tablero.abiertas.length === 5).toBe(true) falla diciendo «esperaba true, recibí false», que no ayuda en absoluto. El mensaje de fallo es parte del valor de la prueba.
  • Desconfía de toBeTruthy. expect(t.abierta).toBeTruthy() pasa también si abierta devuelve 'sí', 1 o []. Si esperas un booleano, escribe toBe(true).

toMatchObject merece su propio comentario porque es el que mejor equilibra precisión y mantenimiento en objetos grandes:

// Comprueba SOLO lo que importa a esta prueba; el resto puede cambiar sin romperla
expect(tablero.resumen(HOY)).toMatchObject({ horasAbiertas: 45, vencidas: 1 });

// Frente a esto, que se rompe si mañana el resumen añade un campo nuevo:
expect(tablero.resumen(HOY)).toStrictEqual({
  total: 6, abiertas: 5, horasTotales: 48, horasAbiertas: 45, vencidas: 1, esfuerzo: 124
});

Ambas versiones tienen su sitio. La segunda es apropiada precisamente para el resumen canónico, donde quieres que añadir un campo obligue a revisar la prueba. La primera, para todo lo demás.

  1. toBe, toEqual y toStrictEqual: la comparación por referencia

Este es el punto donde más se tropieza, y es exactamente el tema de 01-07 y 04-08 aplicado a las pruebas.

const a = { id: 1, titulo: 'Rediseñar la sala' };
const b = { id: 1, titulo: 'Rediseñar la sala' };

expect(a).toBe(b);       // ✗ FALLA: son dos objetos distintos en memoria
expect(a).toEqual(b);    // ✓ pasa: tienen el mismo contenido
expect(a).toBe(a);       // ✓ pasa: es literalmente el mismo objeto

toBe usa Object.is, que para objetos es comparación por referencia: dos objetos con el mismo contenido son distintos. Es la trampa de 04-08, ahora con consecuencias en las pruebas.

La regla operativa:

Qué comparas Matcher
Números, cadenas, booleanos, null, undefined toBe
Objetos, arrays, mapas, conjuntos toEqual o toStrictEqual
Que sea literalmente el mismo objeto (identidad) toBe — a propósito

Y la diferencia entre toEqual y toStrictEqual, que son tres diferencias concretas:

// 1 · Propiedades con valor undefined
expect({ id: 1, revisor: undefined }).toEqual({ id: 1 });        // ✓ pasa: toEqual las ignora
expect({ id: 1, revisor: undefined }).toStrictEqual({ id: 1 });  // ✗ falla: son distintos

// 2 · Tipo de clase
class Tarea { constructor() { this.id = 1; } }
expect(new Tarea()).toEqual({ id: 1 });          // ✓ pasa: mismo contenido
expect(new Tarea()).toStrictEqual({ id: 1 });    // ✗ falla: una es Tarea, el otro objeto plano

// 3 · Huecos en arrays
expect([, 1]).toEqual([undefined, 1]);           // ✓ pasa
expect([, 1]).toStrictEqual([undefined, 1]);     // ✗ falla

La primera diferencia es la que importa en Nómada Tareas. R8 dice que una tarea sin responsable tiene responsable: null, nunca '' ni undefined. Con toEqual, un toJSON() que devolviera { …, revisor: undefined } pasaría la prueba y luego produciría un JSON sin el campo revisor al serializar —porque JSON.stringify elimina los undefined (04-08)— y desdeJSON reconstruiría algo distinto. Con toStrictEqual, la prueba falla y el fallo se ve antes de llegar al almacenamiento.

Regla para este proyecto: usa toStrictEqual para comprobar la salida de toJSON(), y toEqual para el resto.

Y un uso de toBe sobre objetos que es deliberado, comprobando las copias defensivas de 05-03:

test('el getter tareas devuelve una copia, no el array interno', () => {
  const tablero = new Tablero('Taller Nómada', crearBacklog());

  expect(tablero.tareas).not.toBe(tablero.tareas);   // dos llamadas, dos arrays distintos
  expect(tablero.tareas).toEqual(tablero.tareas);    // pero con el mismo contenido
});

Esa prueba protege una decisión de diseño concreta: si alguien "optimizara" el getter devolviendo this.#tareas directamente, un .sort() de la vista reordenaría el modelo. La prueba se pondría roja al instante.

  1. Comprobar que algo lanza un error

Las reglas de negocio de Nómada Tareas se expresan lanzando ErrorDeValidacion. Probar que se lanza es tan importante como probar que algo funciona.

El matcher es toThrow, y hay un detalle que hace fallar a todo el mundo la primera vez:

// ❌ MAL: la excepción se lanza al evaluar el argumento, antes de llegar a expect
expect(new Tarea({ titulo: '' })).toThrow(ErrorDeValidacion);

// ✅ BIEN: se pasa una FUNCIÓN que Jest ejecutará dentro de un try/catch
expect(() => new Tarea({ titulo: '' })).toThrow(ErrorDeValidacion);

toThrow admite cuatro formas de argumento, de menos a más específica:

const crear = () => new Tarea({ id: 1, titulo: '   ', horasEstimadas: 4 });

expect(crear).toThrow();                              // 1 · lanza algo
expect(crear).toThrow(ErrorDeValidacion);             // 2 · lanza ESE tipo
expect(crear).toThrow('El título no puede estar vacío.');  // 3 · el mensaje CONTIENE ese texto
expect(crear).toThrow(/título/i);                     // 4 · el mensaje casa con la expresión regular

Recomendación: usa la forma 2, el tipo. El tipo es estable; el texto del mensaje cambia cuando alguien mejora la redacción, y no quieres que mejorar un mensaje ponga en rojo diez pruebas.

Pero hay algo que toThrow no comprueba y que en este proyecto es esencial: el campo campo del error. Ese campo es el que en 06-07 permitía al formulario mover el foco al <input> correcto. Para comprobarlo hay dos patrones:

// Patrón A · try/catch explícito con expect.assertions
test('el error de horas identifica el campo horasEstimadas', () => {
  expect.assertions(3);            // ← garantiza que las 3 aserciones se ejecutan

  try {
    new Tarea({ id: 1, titulo: 'Prueba', horasEstimadas: 99 });
  } catch (error) {
    expect(error).toBeInstanceOf(ErrorDeValidacion);
    expect(error.campo).toBe('horasEstimadas');
    expect(error.valorRecibido).toBe(99);
  }
});

// Patrón B · capturar con una función auxiliar (más legible cuando se repite)
function capturar(fn) {
  try { fn(); return null; } catch (error) { return error; }
}

test('el error de horas identifica el campo horasEstimadas', () => {
  const error = capturar(() => new Tarea({ id: 1, titulo: 'Prueba', horasEstimadas: 99 }));

  expect(error).toBeInstanceOf(ErrorDeValidacion);
  expect(error).toMatchObject({ campo: 'horasEstimadas', valorRecibido: 99 });
});

expect.assertions(n) del patrón A es imprescindible y merece explicación. Sin esa línea, si el código dejara de lanzar, el try terminaría sin error, el catch no se ejecutaría, la prueba no comprobaría nada… y pasaría en verde. Una prueba que pasa cuando el comportamiento se ha roto es peor que no tener prueba. expect.assertions(3) obliga a que se ejecuten exactamente tres aserciones; si no, Jest falla.

  1. Ganchos: beforeEach y compañía

Las pruebas de un mismo describe suelen necesitar la misma preparación. Los ganchos (hooks) la centralizan:

Gancho Cuándo se ejecuta Uso típico
beforeEach(fn) Antes de cada prueba Crear un tablero limpio. El más usado con diferencia
afterEach(fn) Después de cada prueba Limpiar: restaurar dobles, vaciar almacenamiento
beforeAll(fn) Una vez, antes de todo el bloque Preparación cara y de solo lectura
afterAll(fn) Una vez, al final Cerrar recursos
describe('Tablero', () => {
  let tablero;

  beforeEach(() => {
    // Un tablero NUEVO para cada prueba: nadie hereda los cambios del vecino
    tablero = new Tablero('Taller Nómada', crearBacklog());
  });

  test('agrega las seis tareas del backlog', () => {
    expect(tablero.total).toBe(6);
  });

  test('cambiar de estado no altera el total', () => {
    tablero.cambiarEstado(2, 'en-curso');
    expect(tablero.total).toBe(6);
  });
});

Aquí se cobra el diseño de 05-04: crearBacklog() es una función que devuelve instancias nuevas, no un array exportado ya construido. Si fuera un array compartido, el cambiarEstado(2, …) de la segunda prueba contaminaría a la siguiente. Aquella decisión, tomada tres módulos antes por motivos de encapsulación, es hoy lo que hace que las pruebas sean independientes. Es un buen ejemplo de cómo el buen diseño paga intereses en sitios inesperados.

El orden de ejecución con describe anidados sigue una regla clara: todos los beforeEach de fuera adentro, la prueba, y todos los afterEach de dentro afuera.

describe('Tablero', () => {
  beforeEach(() => console.log('1 · fuera'));

  describe('con una tarea cerrada', () => {
    beforeEach(() => console.log('2 · dentro'));

    test('cuenta 4 abiertas', () => console.log('3 · prueba'));
  });
});
// Salida: 1 · fuera → 2 · dentro → 3 · prueba

  1. El estado compartido, fuente de fallos intermitentes

Este apartado es corto y crítico. La regla: cada prueba debe poder ejecutarse sola, en cualquier orden, y dar el mismo resultado.

Cuando eso se incumple, aparece el fallo más desesperante de todos: la prueba que pasa sola y falla en la suite, o al revés. Mira el error:

// ❌ MAL: el tablero se crea UNA vez y todas las pruebas lo comparten
const tablero = new Tablero('Taller Nómada', crearBacklog());

test('la tarea 2 se puede empezar', () => {
  tablero.cambiarEstado(2, 'en-curso');
  expect(tablero.buscarPorId(2).estado).toBe('en-curso');
});

test('el backlog tiene 5 tareas abiertas', () => {
  expect(tablero.abiertas).toHaveLength(5);      // pasa… hasta que alguien cierre una arriba
});

test('la tarea 2 empieza en pendiente', () => {
  expect(tablero.buscarPorId(2).estado).toBe('pendiente');   // ✗ FALLA por culpa de la primera
});

La tercera prueba es correcta, y falla. Y falla solo si la primera se ejecutó antes, lo que hace que ejecutarla aislada dé verde y sea imposible de entender.

// ✅ BIEN: beforeEach reconstruye el estado
let tablero;
beforeEach(() => { tablero = new Tablero('Taller Nómada', crearBacklog()); });

Cuatro focos de estado compartido que hay que vigilar:

Foco Cómo se manifiesta Solución
Objeto creado en el ámbito del módulo Como el ejemplo de arriba beforeEach
Un array o mapa const mutado por las pruebas Se llena poco a poco Recrear en beforeEach
localStorage / sessionStorage Datos de la prueba anterior Limpiar en afterEach (08-04)
Módulos con estado interno Un contador de ids que no se reinicia Reiniciar explícitamente, o inyectarlo

Ese último caso tiene un ejemplo directo en el proyecto: crearGeneradorDeIds de 03-04 mantiene un contador en su closure. Si el módulo lo instancia al importarse, la primera prueba obtendrá el id 7 y la segunda el 8; si alguien reordena las pruebas, ambas fallan. La solución es la misma decisión de siempre: exportar la fábrica, no la instancia, y que cada prueba cree la suya.

Jest, además, ejecuta cada fichero de pruebas en un entorno aislado con su propio registro de módulos. Eso protege entre ficheros, pero no dentro de un mismo fichero. La disciplina del beforeEach sigue siendo tuya.

  1. Pruebas parametrizadas con test.each

La tabla de transiciones R6 tiene nueve combinaciones posibles (tres estados de origen × tres de destino). Escribir nueve pruebas casi idénticas es tedioso y, sobre todo, hace que añadir un estado nuevo signifique escribir tres pruebas más a mano.

test.each recibe una tabla y genera una prueba por fila:

describe('Tarea · transiciones de estado (R6)', () => {
  // ── Las permitidas ──────────────────────────────────────────────────
  test.each([
    ['pendiente', 'en-curso'],
    ['en-curso',  'hecha'],
    ['en-curso',  'pendiente']
  ])('permite pasar de %s a %s', (desde, hasta) => {
    const tarea = new Tarea({ ...DATOS_VALIDOS, estado: desde });

    tarea.cambiarEstado(hasta);

    expect(tarea.estado).toBe(hasta);
  });

  // ── Las prohibidas ──────────────────────────────────────────────────
  test.each([
    ['pendiente', 'pendiente'],
    ['pendiente', 'hecha'],       // hay que empezarla antes de terminarla
    ['en-curso',  'en-curso'],
    ['hecha',     'pendiente'],   // una tarea hecha no se reabre
    ['hecha',     'en-curso'],
    ['hecha',     'hecha']
  ])('prohíbe pasar de %s a %s', (desde, hasta) => {
    const tarea = new Tarea({ ...DATOS_VALIDOS, estado: desde });

    expect(() => tarea.cambiarEstado(hasta)).toThrow(ErrorDeValidacion);
  });
});

Salida:

  Tarea · transiciones de estado (R6)
    ✓ permite pasar de pendiente a en-curso
    ✓ permite pasar de en-curso a hecha
    ✓ permite pasar de en-curso a pendiente
    ✓ prohíbe pasar de pendiente a pendiente
    ✓ prohíbe pasar de pendiente a hecha
    ✓ prohíbe pasar de en-curso a en-curso
    ✓ prohíbe pasar de hecha a pendiente
    ✓ prohíbe pasar de hecha a en-curso
    ✓ prohíbe pasar de hecha a hecha

Nueve pruebas independientes, cada una con su nombre legible, y la tabla completa cubierta sin huecos. Fíjate en que las dos tablas juntas suman exactamente las nueve combinaciones: eso es una comprobación de exhaustividad que se ve de un vistazo.

Los marcadores del nombre son %s (cadena), %d (número), %o (objeto) y %p (con formato). Y existe una segunda sintaxis, con plantilla etiquetada, que se lee todavía mejor cuando hay más de dos columnas:

describe('Tarea · validación de horas (R3)', () => {
  test.each`
    horas   | valido   | motivo
    ${0}    | ${false} | ${'cero no es un valor válido'}
    ${-3}   | ${false} | ${'negativo'}
    ${0.5}  | ${true}  | ${'media hora es admisible'}
    ${1}    | ${true}  | ${'límite inferior'}
    ${40}   | ${true}  | ${'límite superior exacto'}
    ${41}   | ${false} | ${'supera el máximo semanal'}
    ${NaN}  | ${false} | ${'NaN no es comparable'}
    ${'12'} | ${false} | ${'una cadena no es un número'}
  `('horasEstimadas $horas → $valido ($motivo)', ({ horas, valido }) => {
    const crear = () => new Tarea({ ...DATOS_VALIDOS, horasEstimadas: horas });

    if (valido) expect(crear().horasEstimadas).toBe(horas);
    else expect(crear).toThrow(ErrorDeValidacion);
  });
});

Esta tabla es especialmente valiosa porque recoge los valores frontera: 0, 1, 40, 41. La mayoría de los fallos de validación viven exactamente en esos bordes (un > donde debía ir un >=), y una tabla los hace explícitos. Las dos últimas filas —NaN y la cadena '12'— documentan además dos decisiones de diseño que no son obvias: el setter exige typeof valor === 'number', así que '12' se rechaza aunque parezca un número, y NaN falla porque ninguna comparación con NaN es cierta (01-07).

  1. Probar código asíncrono

Todo lo del Módulo 5 aplicado a las pruebas. La regla fundamental: si la prueba es asíncrona, Jest tiene que saberlo, o terminará antes de que se compruebe nada.

// ❌ MAL: la prueba termina antes de que la promesa se resuelva. Pasa siempre.
test('importa las tareas', () => {
  cargarTablero().then((tablero) => {
    expect(tablero.total).toBe(6);       // ← esto se ejecuta cuando nadie mira
  });
});

// ✅ BIEN: la función es async y se espera
test('importa las tareas', async () => {
  const tablero = await cargarTablero();
  expect(tablero.total).toBe(6);
});

// ✅ TAMBIÉN BIEN: devolver la promesa
test('importa las tareas', () => {
  return cargarTablero().then((tablero) => {
    expect(tablero.total).toBe(6);
  });
});

La primera versión es un falso verde, el peor resultado posible: la prueba está en la suite, se ve en verde y no comprueba nada. La regla jest/valid-expect que configuraste en 08-02 detecta algunas variantes de este fallo; la disciplina de escribir siempre async/await lo evita del todo.

Para promesas, Jest ofrece además dos modificadores que ahorran ceremonia:

// resolves: aplica el matcher al valor RESUELTO
await expect(cargarTablero()).resolves.toMatchObject({ total: 6 });

// rejects: aplica el matcher al motivo del RECHAZO
await expect(cargarTablero('roto')).rejects.toThrow(ErrorDeDatos);
await expect(cargarTablero('roto')).rejects.toBeInstanceOf(ErrorDeDatos);

No olvides el await delante de expect al usar resolves o rejects: sin él, la aserción devuelve una promesa que nadie espera y vuelves al falso verde.

Y el patrón para comprobar propiedades del error rechazado, que en este proyecto hace falta a menudo:

test('importar datos corruptos lanza ErrorDeDatos con la causa dentro', async () => {
  expect.assertions(2);

  try {
    await repositorio.cargarDesde('{{{ esto no es JSON');
  } catch (error) {
    expect(error).toBeInstanceOf(ErrorDeDatos);
    expect(error.causa).toBeInstanceOf(SyntaxError);
  }
});

Un aviso sobre los temporizadores: si tu código usa setTimeout —como el dormir() de conReintentos en 07-03—, una prueba que lo ejecute de verdad tardará segundos y hará lenta la suite. La solución son los temporizadores falsos, y es tema de la lección siguiente, Dobles de Prueba. Aquí nos quedamos en las pruebas del modelo, que son puras y no esperan a nada.

  1. Qué hace testeable un módulo

Aquí se cierra la promesa de 03-03. Compara estas dos funciones:

// ── A · Función pura: entra un dato, sale un dato ─────────────────────
export function estaVencida(fechaLimite, estado, hoy = HOY) {
  return fechaLimite < hoy && estado !== 'hecha';
}

// ── B · Función impura: depende del mundo y lo modifica ───────────────
export function marcarVencidas() {
  const hoy = new Date().toISOString().slice(0, 10);        // ① el reloj real
  const guardado = JSON.parse(localStorage.getItem('nomada:tablero:v1'));   // ② el almacén
  for (const t of guardado.tareas) {
    if (t.fechaLimite < hoy && t.estado !== 'hecha') {
      document.querySelector(`[data-id="${t.id}"]`).classList.add('vencida');  // ③ el DOM
    }
  }
}

Probar A:

test('una tarea con fecha pasada y sin terminar está vencida (R10)', () => {
  expect(estaVencida('2026-09-05', 'pendiente', '2026-09-20')).toBe(true);
});

Una línea. Sin preparación, sin limpieza, sin navegador, en menos de un milisegundo, y con el mismo resultado hoy, mañana y dentro de tres años.

Probar B exige un DOM, un localStorage, y congelar el reloj del sistema, porque el día que se ejecute cambia el resultado. Es posible —lo harás en 08-04 y 08-05— pero cuesta veinte veces más y la prueba resultante es frágil.

Las tres propiedades de una función pura (03-03) explican la diferencia:

Propiedad Consecuencia para la prueba
El resultado depende solo de los argumentos No hay que preparar ningún entorno
No modifica nada fuera de sí misma No hay que limpiar después
Con la misma entrada, siempre la misma salida Nunca es intermitente

La segunda herramienta es la inyección de dependencias, y en Nómada Tareas ya está aplicada en varios sitios sin que lo llamáramos así:

// js/util/fechas.js — la fecha ENTRA como parámetro, con un valor por defecto
export function estaVencida(fechaLimite, estado, hoy = HOY) { … }

// js/modelo/tarea.js
estaVencida(hoy = HOY) { return fechaVencida(this.fechaLimite, this.#estado, hoy); }

// js/datos/repositorio-local.js — el almacén ENTRA por el constructor
constructor({ clave = CLAVE, almacen } = {}) {
  this.#almacen = almacen ?? (this.#persistente ? window.localStorage : almacenEnMemoria());
}

Ese parámetro hoy con valor por defecto es lo que permite escribir:

test('el presupuesto de la carpintería está vencido el 20 de septiembre', () => {
  const tablero = new Tablero('Taller Nómada', crearBacklog());

  expect(tablero.vencidas('2026-09-20')).toHaveLength(1);
  expect(tablero.vencidas('2026-09-20')[0].titulo).toBe('Presupuesto de la carpintería');
});

La prueba dará el mismo resultado el día que se ejecute, porque el tiempo entra como dato. Sin ese parámetro, la prueba pasaría hoy y fallaría el 1 de octubre, cuando la tarea 3 también estuviera vencida. Ese tipo de fallo —una suite que se rompe sola un martes por la mañana sin que nadie haya tocado nada— es de los que más confianza destruyen.

Y el mismo constructor de RepositorioLocal que acepta un almacen es lo que permitirá probarlo sin navegador en 08-05. La lección de fondo: una dependencia que entra por parámetro es una dependencia sustituible en la prueba; una que se toma del ámbito global, no.

flowchart TD
    A["¿La función depende<br/>solo de sus argumentos?"] -->|Sí| B["Función pura<br/>prueba trivial"]
    A -->|No| C["¿La dependencia<br/>entra por parámetro?"]
    C -->|Sí| D["Inyectable<br/>prueba fácil (08-04)"]
    C -->|No| E["Acoplada al entorno<br/>hay que rediseñar<br/>o subir a integración (08-05)"]
    style B fill:#bbf7d0,stroke:#15803d
    style D fill:#fde68a,stroke:#b45309
    style E fill:#fecaca,stroke:#b91c1c

  1. Cobertura: qué mide de verdad

La cobertura mide qué porcentaje de tu código se ejecuta al pasar las pruebas.

npm run test:cov
-------------------------|---------|----------|---------|---------|-------------------
File                     | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------------|---------|----------|---------|---------|-------------------
All files                |   84.21 |    78.94 |   88.23 |   84.05 |
 js/modelo               |   97.14 |    95.45 |     100 |   97.10 |
  errores.js             |     100 |      100 |     100 |     100 |
  tarea.js               |   96.42 |    94.11 |     100 |   96.36 | 88
  tablero.js             |     100 |      100 |     100 |     100 |
 js/util                 |     100 |      100 |     100 |     100 |
  fechas.js              |     100 |      100 |     100 |     100 |
  formato.js             |     100 |      100 |     100 |     100 |
 js/datos                |   42.85 |    28.57 |   50.00 |   42.30 | 34-58,71-88
  backlog.js             |     100 |      100 |     100 |     100 |
  repositorio-local.js   |   31.25 |    20.00 |   33.33 |   30.76 | 34-58,71-88
-------------------------|---------|----------|---------|---------|-------------------

Las cuatro columnas miden cosas distintas:

Métrica Qué cuenta Por qué importa
Statements Sentencias ejecutadas La medida más gruesa
Branch Ramas de cada condición (if/else, &&, ?:, ??) La más informativa: revela caminos no probados
Functions Funciones invocadas al menos una vez Detecta código muerto o sin probar
Lines Líneas ejecutadas Casi igual a statements

Branch es la que hay que mirar. Una condición como fechaLimite < hoy && estado !== 'hecha' tiene cuatro combinaciones; recorrer una sola da 100 % de líneas y 25 % de ramas. El informe HTML (coverage/lcov-report/index.html) las pinta en amarillo, y navegarlo es la mejor forma de encontrar huecos reales.

Y ahora la parte importante: qué NO mide la cobertura.

// Esta "prueba" da 100 % de cobertura de la función… y no comprueba nada
test('resumen', () => {
  const tablero = new Tablero('Taller Nómada', crearBacklog());
  tablero.resumen('2026-09-20');     // se ejecuta entera. Cero aserciones.
});

La cobertura mide ejecución, no verificación. Un 100 % con pruebas sin aserciones es un 100 % vacío. Por eso configuraste jest/expect-expect en la lección anterior.

Tres afirmaciones que conviene tener claras:

  • Cobertura baja es una señal fiable de problema. Si repositorio-local.js está al 31 %, hay caminos importantes sin probar (aquí, precisamente, el manejo de datos corruptos y el respaldo en memoria).
  • Cobertura alta no es señal de nada. No dice que las aserciones sean correctas, ni que hayas probado los casos límite, ni que la lógica sea la que el negocio quiere.
  • Perseguir el 100 % es contraproducente. Los últimos puntos porcentuales suelen estar en ramas defensivas que casi nunca ocurren, y se "cubren" con pruebas artificiales que no aportan y hay que mantener. El coste crece exponencialmente y el valor decrece.

Una política razonable, en jest.config.js:

export default {
  // …
  coverageThreshold: {
    global:                  { branches: 70, functions: 80, lines: 80 },
    './js/modelo/':          { branches: 95, functions: 100, lines: 95 },   // el corazón: exigente
    './js/vista/':           { branches: 50, functions: 60, lines: 60 }     // el DOM: integración (08-05)
  }
};

Umbrales por carpeta, no uno global: el modelo, que es puro y contiene las reglas de negocio, merece exigencia máxima; la vista se cubre mejor con pruebas de integración. Y los umbrales hacen fallar la construcción si la cobertura baja, que es su verdadera utilidad: evitan que se añada código sin probar poco a poco.

  1. Nombrar una prueba y la regla del motivo único

El nombre de una prueba se lee dos veces: cuando alguien busca qué está cubierto, y —sobre todo— cuando falla en la integración continua. En ese segundo momento, un buen nombre ahorra abrir el fichero.

// ❌ No dicen nada
test('tablero', …);
test('funciona', …);
test('test 3', …);
test('cambiarEstado', …);

// ✅ Sujeto + comportamiento + condición
test('cambiarEstado lanza ErrorDeValidacion si la tarea no existe', …);
test('el resumen del backlog canónico da 45 h abiertas de 48', …);
test('agregar rechaza un id duplicado (R1)', …);
test('las etiquetas se normalizan a minúsculas y sin duplicados (R9)', …);

La plantilla que funciona: <qué> <hace o no hace> <cuándo>. Y una recomendación de este proyecto: cita la regla de negocio entre paréntesis. Cuando una prueba de R6 se pone roja, saber que es R6 lleva directo a la conversación correcta con Marta.

La regla del motivo único. Una prueba debe fallar por un solo motivo. Compara:

// ❌ Si falla, ¿qué está roto? Hay que leer el informe con lupa
test('el tablero funciona', () => {
  const tablero = new Tablero('Taller Nómada', crearBacklog());
  expect(tablero.total).toBe(6);
  tablero.cambiarEstado(2, 'en-curso');
  expect(tablero.buscarPorId(2).estado).toBe('en-curso');
  expect(tablero.resumen(HOY).horasAbiertas).toBe(45);
  expect(() => tablero.agregar(new Tarea(DATOS_VALIDOS))).toThrow();
});

// ✅ Cuatro pruebas, cuatro nombres, cuatro diagnósticos
test('el backlog canónico tiene 6 tareas', …);
test('cambiarEstado aplica la transición a la tarea indicada', …);
test('el resumen del backlog canónico da 45 h abiertas', …);
test('agregar rechaza un id duplicado (R1)', …);

El matiz: «un solo motivo de fallo» no significa «una sola aserción». Comprobar tres propiedades del mismo resultado es un solo motivo:

test('el error de horas identifica el campo y el valor recibido', () => {
  const error = capturar(() => new Tarea({ ...DATOS_VALIDOS, horasEstimadas: 99 }));

  expect(error).toBeInstanceOf(ErrorDeValidacion);   // tres aserciones…
  expect(error.campo).toBe('horasEstimadas');        // …sobre el MISMO error…
  expect(error.valorRecibido).toBe(99);              // …= un solo motivo de fallo
});

  1. TDD: rojo, verde, refactor

El desarrollo dirigido por pruebas (TDD) invierte el orden habitual: primero la prueba, después el código.

flowchart LR
    R["🔴 ROJO<br/>Escribe una prueba<br/>que falla"] --> V["🟢 VERDE<br/>El código MÍNIMO<br/>que la hace pasar"]
    V --> F["🔵 REFACTOR<br/>Mejora el código<br/>con la prueba de red"]
    F --> R

Lo aplicamos a una regla nueva que Marta pide: R11 — una tarea vencida no puede tener prioridad 'baja'; si vence y sigue abierta, se escala automáticamente a 'alta'.

Vuelta 1 · Rojo. La prueba primero, antes de tocar tarea.js:

describe('Tarea · escalado de prioridad (R11)', () => {
  test('una tarea vencida y de prioridad baja se escala a alta', () => {
    const tarea = new Tarea({ ...DATOS_VALIDOS, prioridad: 'baja',
                              fechaLimite: '2026-09-05', estado: 'pendiente' });

    expect(tarea.prioridadEfectiva('2026-09-20')).toBe('alta');
  });
});
● Tarea · escalado de prioridad (R11) › una tarea vencida y de prioridad baja se escala a alta
  TypeError: tarea.prioridadEfectiva is not a function

Rojo, y rojo por el motivo correcto: el método no existe. Este paso no es una formalidad: verificar que la prueba falla demuestra que la prueba de verdad comprueba algo. Una prueba que pasa antes de escribir el código está mal escrita.

Vuelta 1 · Verde. El código mínimo:

// js/modelo/tarea.js
prioridadEfectiva(hoy = HOY) {
  return 'alta';       // sí, así de mínimo. Ya lo corregirán las siguientes pruebas.
}

Verde. Parece una trampa, y es intencionado: obliga a escribir la siguiente prueba, la que distingue los casos.

Vuelta 2 · Rojo. El caso contrario:

test('una tarea no vencida conserva su prioridad', () => {
  const tarea = new Tarea({ ...DATOS_VALIDOS, prioridad: 'baja', fechaLimite: '2026-11-30' });

  expect(tarea.prioridadEfectiva('2026-09-20')).toBe('baja');
});
Expected: "baja"   Received: "alta"

Vuelta 2 · Verde.

prioridadEfectiva(hoy = HOY) {
  return this.estaVencida(hoy) ? 'alta' : this.prioridad;
}

Vuelta 3 · Rojo. El caso límite que revela un matiz del enunciado:

test('una tarea vencida de prioridad media conserva su prioridad', () => {
  const tarea = new Tarea({ ...DATOS_VALIDOS, prioridad: 'media', fechaLimite: '2026-09-05' });

  expect(tarea.prioridadEfectiva('2026-09-20')).toBe('media');
});

Falla: la implementación escala todo lo vencido, y la regla decía «prioridad baja».

Vuelta 3 · Verde.

prioridadEfectiva(hoy = HOY) {
  return this.estaVencida(hoy) && this.prioridad === 'baja' ? 'alta' : this.prioridad;
}

Refactor. Con las tres pruebas en verde, se puede mejorar sin miedo. La expresión ternaria empieza a pedir un nombre:

// js/modelo/tarea.js
/** R11: lo vencido y de baja prioridad se escala; el resto conserva la suya. */
get #vencidaYDeBajaPrioridad() {
  return this.estaVencida() && this.prioridad === 'baja';
}

prioridadEfectiva(hoy = HOY) {
  return this.estaVencida(hoy) && this.prioridad === 'baja' ? 'alta' : this.prioridad;
}

Las tres pruebas siguen verdes tras el cambio: eso es exactamente lo que significa «red de seguridad».

Qué aporta TDD, honestamente:

A favor En contra
Garantiza que todo el código tiene prueba Cuesta más al principio, hasta cogerle el ritmo
Fuerza a diseñar la interfaz antes que la implementación No encaja bien cuando exploras y no sabes qué quieres
Impide escribir código de más («por si acaso») Requiere disciplina sostenida
Documenta cada decisión mientras se toma Con requisitos que cambian cada hora, se rehace mucho

No hace falta hacer TDD siempre. Pero hay un momento en el que es indiscutiblemente la mejor opción: al corregir un fallo. Escribe primero la prueba que lo reproduce —el paso 6 del método de 08-01—, compruébala roja, y arréglalo. Así garantizas que el arreglo funciona y que el fallo no vuelve.

  1. Nómada Tareas: la batería completa del modelo

Juntamos todo. Primero, una ayuda compartida para no repetir datos en cada fichero:

// pruebas/ayudas/backlog-de-prueba.js
import { Tarea } from '../../js/modelo/tarea.js';
import { Tablero } from '../../js/modelo/tablero.js';
import { crearBacklog } from '../../js/datos/backlog.js';

/** Fecha canónica del proyecto: fija, para que las pruebas no dependan del calendario. */
export const HOY = '2026-09-20';

export const DATOS_VALIDOS = Object.freeze({
  id: 1, titulo: 'Rediseñar la sala polivalente', responsable: 'Iván',
  prioridad: 'alta', estado: 'en-curso', etiquetas: ['espacio', 'diseño'],
  horasEstimadas: 12, fechaLimite: '2026-09-30', revisor: 'Marta'
});

/** Una tarea válida a la que se le cambia lo que interese. */
export const unaTarea = (cambios = {}) => new Tarea({ ...DATOS_VALIDOS, ...cambios });

/** Un tablero NUEVO con el backlog canónico. Instancias frescas en cada llamada. */
export const unTablero = () => new Tablero('Taller Nómada', crearBacklog());

/** Captura la excepción de fn, o devuelve null si no lanza. */
export function capturar(fn) {
  try { fn(); return null; } catch (error) { return error; }
}

Ahora las pruebas de Tarea:

// pruebas/modelo/tarea.test.js
import { Tarea } from '../../js/modelo/tarea.js';
import { ErrorDeValidacion } from '../../js/modelo/errores.js';
import { HOY, DATOS_VALIDOS, unaTarea, capturar } from '../ayudas/backlog-de-prueba.js';

describe('Tarea', () => {
  // ── Construcción y validación ─────────────────────────────────────────
  describe('construcción', () => {
    test('conserva los datos válidos que recibe', () => {
      const tarea = unaTarea();

      expect(tarea).toMatchObject({
        id: 1, titulo: 'Rediseñar la sala polivalente',
        responsable: 'Iván', prioridad: 'alta', horasEstimadas: 12
      });
    });

    test('recorta los espacios del título', () => {
      expect(unaTarea({ titulo: '   Cartelería   ' }).titulo).toBe('Cartelería');
    });

    test.each([['', 'cadena vacía'], ['   ', 'solo espacios'], [null, 'null'], [42, 'un número']])(
      'rechaza el título %p (%s) con ErrorDeValidacion (R2)',
      (titulo) => {
        expect(() => unaTarea({ titulo })).toThrow(ErrorDeValidacion);
      }
    );

    test('el error de título identifica el campo y el valor recibido', () => {
      const error = capturar(() => unaTarea({ titulo: '  ' }));

      expect(error).toBeInstanceOf(ErrorDeValidacion);
      expect(error.campo).toBe('titulo');
      expect(error.valorRecibido).toBe('  ');
    });

    test('nace en pendiente si no se indica estado (R5)', () => {
      const { estado, ...sinEstado } = DATOS_VALIDOS;   // desestructuración de 04-07

      expect(new Tarea(sinEstado).estado).toBe('pendiente');
    });

    test('rechaza un estado desconocido', () => {
      expect(() => unaTarea({ estado: 'archivada' })).toThrow(ErrorDeValidacion);
    });

    test('una tarea sin responsable lo guarda como null, nunca como cadena vacía (R8)', () => {
      expect(unaTarea({ responsable: '' }).responsable).toBeNull();
      expect(unaTarea({ responsable: undefined }).responsable).toBeNull();
    });

    test('normaliza las etiquetas a minúsculas y sin duplicados (R9)', () => {
      const tarea = unaTarea({ etiquetas: ['Espacio', ' DISEÑO ', 'espacio', 'diseño'] });

      expect(tarea.etiquetas).toEqual(['espacio', 'diseño']);
    });
  });

  // ── Horas (R3), con los valores frontera ──────────────────────────────
  describe('horasEstimadas (R3)', () => {
    test.each`
      horas   | valido   | motivo
      ${0}    | ${false} | ${'cero'}
      ${-3}   | ${false} | ${'negativo'}
      ${1}    | ${true}  | ${'límite inferior'}
      ${40}   | ${true}  | ${'límite superior exacto'}
      ${41}   | ${false} | ${'supera el máximo'}
      ${NaN}  | ${false} | ${'NaN'}
      ${'12'} | ${false} | ${'cadena, no número'}
    `('$horas → válido: $valido ($motivo)', ({ horas, valido }) => {
      const crear = () => unaTarea({ horasEstimadas: horas });

      if (valido) expect(crear().horasEstimadas).toBe(horas);
      else expect(crear).toThrow(ErrorDeValidacion);
    });

    test('el setter valida igual que el constructor', () => {
      const tarea = unaTarea();

      expect(() => { tarea.horasEstimadas = 99; }).toThrow(ErrorDeValidacion);
      expect(tarea.horasEstimadas).toBe(12);        // y NO se ha modificado
    });
  });

  // ── Transiciones (R6) ─────────────────────────────────────────────────
  describe('transiciones de estado (R6)', () => {
    test.each([
      ['pendiente', 'en-curso'], ['en-curso', 'hecha'], ['en-curso', 'pendiente']
    ])('permite %s → %s', (desde, hasta) => {
      const tarea = unaTarea({ estado: desde });

      expect(tarea.cambiarEstado(hasta).estado).toBe(hasta);   // devuelve this: encadenable
    });

    test.each([
      ['pendiente', 'pendiente'], ['pendiente', 'hecha'], ['en-curso', 'en-curso'],
      ['hecha', 'pendiente'], ['hecha', 'en-curso'], ['hecha', 'hecha']
    ])('prohíbe %s → %s', (desde, hasta) => {
      expect(() => unaTarea({ estado: desde }).cambiarEstado(hasta)).toThrow(ErrorDeValidacion);
    });

    test('una transición inválida no deja la tarea a medias', () => {
      const tarea = unaTarea({ estado: 'hecha' });

      capturar(() => tarea.cambiarEstado('pendiente'));

      expect(tarea.estado).toBe('hecha');           // el estado no se ha corrompido
    });
  });

  // ── Vencimiento (R10) y derivados ─────────────────────────────────────
  describe('vencimiento (R10)', () => {
    test('vencida si la fecha pasó y no está hecha', () => {
      expect(unaTarea({ fechaLimite: '2026-09-05', estado: 'pendiente' }).estaVencida(HOY)).toBe(true);
    });

    test('no vencida si está hecha, aunque la fecha haya pasado', () => {
      expect(unaTarea({ fechaLimite: '2026-09-05', estado: 'hecha' }).estaVencida(HOY)).toBe(false);
    });

    test('no vencida el mismo día del límite', () => {
      expect(unaTarea({ fechaLimite: HOY, estado: 'pendiente' }).estaVencida(HOY)).toBe(false);
    });

    test('abierta es false solo cuando está hecha', () => {
      expect(unaTarea({ estado: 'pendiente' }).abierta).toBe(true);
      expect(unaTarea({ estado: 'en-curso' }).abierta).toBe(true);
      expect(unaTarea({ estado: 'hecha' }).abierta).toBe(false);
    });

    test('el esfuerzo es horas × peso de prioridad', () => {
      expect(unaTarea({ prioridad: 'alta', horasEstimadas: 12 }).esfuerzo).toBe(36);
      expect(unaTarea({ prioridad: 'media', horasEstimadas: 6 }).esfuerzo).toBe(12);
      expect(unaTarea({ prioridad: 'baja', horasEstimadas: 3 }).esfuerzo).toBe(3);
    });
  });

  // ── Serialización (retomando 04-08) ───────────────────────────────────
  describe('serialización', () => {
    test('toJSON incluye el estado privado, que de otro modo se perdería', () => {
      const tarea = unaTarea({ estado: 'en-curso' });

      expect(tarea.toJSON()).toStrictEqual({
        id: 1, titulo: 'Rediseñar la sala polivalente', responsable: 'Iván',
        prioridad: 'alta', estado: 'en-curso', etiquetas: ['espacio', 'diseño'],
        horasEstimadas: 12, fechaLimite: '2026-09-30', revisor: 'Marta'
      });
    });

    test('el viaje de ida y vuelta conserva todos los campos', () => {
      const original = unaTarea({ estado: 'en-curso' });

      const copia = Tarea.desdeJSON(JSON.stringify(original));

      expect(copia.toJSON()).toStrictEqual(original.toJSON());
      expect(copia).not.toBe(original);                  // es OTRA instancia
      expect(copia).toBeInstanceOf(Tarea);               // pero de la misma clase
    });

    test('toJSON devuelve una copia de las etiquetas, no el array interno', () => {
      const tarea = unaTarea();

      tarea.toJSON().etiquetas.push('inyectada');

      expect(tarea.etiquetas).toEqual(['espacio', 'diseño']);   // intacto
    });
  });
});

Y las de Tablero, donde por fin se comprueban los números canónicos:

// pruebas/modelo/tablero.test.js
import { Tarea } from '../../js/modelo/tarea.js';
import { ErrorDeValidacion } from '../../js/modelo/errores.js';
import { HOY, unaTarea, unTablero } from '../ayudas/backlog-de-prueba.js';

describe('Tablero', () => {
  let tablero;

  beforeEach(() => { tablero = unTablero(); });      // ← instancias nuevas: pruebas independientes

  describe('el backlog canónico', () => {
    test('tiene 6 tareas y 5 abiertas', () => {
      expect(tablero.total).toBe(6);
      expect(tablero.abiertas).toHaveLength(5);
    });

    test('suma 48 h totales y 45 h abiertas', () => {
      expect(tablero.horasTotales).toBe(48);
      expect(tablero.horasAbiertas).toBe(45);        // ← la prueba que faltaba en el caso 3 de 08-01
    });

    test('tiene un esfuerzo ponderado de 124', () => {
      expect(tablero.esfuerzo).toBe(124);
    });

    test('tiene exactamente una tarea vencida: el presupuesto de la carpintería (R10)', () => {
      const vencidas = tablero.vencidas(HOY);

      expect(vencidas).toHaveLength(1);
      expect(vencidas[0].titulo).toBe('Presupuesto de la carpintería');
    });

    test('reparte las horas abiertas: Iván 25, Lucía 14, Marta 6', () => {
      expect(tablero.horasPorResponsable()).toEqual({ 'Iván': 25, 'Lucía': 14, 'Marta': 6 });
    });

    test('el resumen completo es exactamente el esperado', () => {
      expect(tablero.resumen(HOY)).toStrictEqual({
        total: 6, abiertas: 5, horasTotales: 48,
        horasAbiertas: 45, vencidas: 1, esfuerzo: 124
      });
    });
  });

  describe('agregar', () => {
    test('añade una tarea nueva y actualiza el total', () => {
      tablero.agregar(unaTarea({ id: 7, titulo: 'Revisar extintores', horasEstimadas: 2 }));

      expect(tablero.total).toBe(7);
      expect(tablero.buscarPorId(7).titulo).toBe('Revisar extintores');
    });

    test('rechaza un id duplicado (R1)', () => {
      expect(() => tablero.agregar(unaTarea({ id: 3 }))).toThrow(ErrorDeValidacion);
      expect(tablero.total).toBe(6);                 // y no ha añadido nada
    });

    test('rechaza cualquier cosa que no sea una Tarea', () => {
      expect(() => tablero.agregar({ id: 9, titulo: 'Objeto plano' })).toThrow(ErrorDeValidacion);
    });
  });

  describe('cambiarEstado', () => {
    test('aplica la transición a la tarea indicada', () => {
      tablero.cambiarEstado(2, 'en-curso');

      expect(tablero.buscarPorId(2).estado).toBe('en-curso');
    });

    test('recalcula las horas abiertas al cerrar una tarea', () => {
      tablero.cambiarEstado(1, 'hecha');             // tarea 1: en-curso, 12 h, de Iván

      expect(tablero.horasAbiertas).toBe(33);        // 45 − 12
      expect(tablero.horasPorResponsable()).toEqual({ 'Iván': 13, 'Lucía': 14, 'Marta': 6 });
    });

    test('lanza ErrorDeValidacion si la tarea no existe', () => {
      expect(() => tablero.cambiarEstado(99, 'en-curso')).toThrow(ErrorDeValidacion);
    });

    test('propaga el error de una transición prohibida (R6)', () => {
      expect(() => tablero.cambiarEstado(4, 'en-curso')).toThrow(ErrorDeValidacion);  // la 4 está hecha
    });
  });

  describe('consultas', () => {
    test('buscarPorId devuelve null si no existe, nunca undefined', () => {
      expect(tablero.buscarPorId(99)).toBeNull();
    });

    test('filtrar devuelve un array nuevo sin tocar el tablero', () => {
      const altas = tablero.filtrar((t) => t.prioridad === 'alta');

      expect(altas).toHaveLength(3);
      expect(tablero.total).toBe(6);
    });

    test('el getter tareas devuelve una copia defensiva (05-03)', () => {
      const copia = tablero.tareas;

      copia.push(unaTarea({ id: 99 }));
      copia.sort((a, b) => a.titulo.localeCompare(b.titulo));

      expect(tablero.total).toBe(6);                 // el push no afectó
      expect(tablero.tareas[0].id).toBe(1);          // ni el sort
    });

    test('es iterable: for…of recorre las seis tareas (05-08)', () => {
      const titulos = [...tablero].map((t) => t.titulo);

      expect(titulos).toHaveLength(6);
      expect(titulos[0]).toBe('Rediseñar la sala polivalente');
    });
  });
});
$ npm test

 PASS  pruebas/modelo/tarea.test.js
 PASS  pruebas/modelo/tablero.test.js

Test Suites: 2 passed, 2 total
Tests:       48 passed, 48 total
Time:        0.913 s

Cuarenta y ocho comprobaciones del comportamiento de todo el modelo, en menos de un segundo, sin navegador. Y la deuda del caso 3 de 08-01 queda formalmente saldada: si alguien vuelve a escribir this.#tareas.reduce en horasAbiertas, dos pruebas se ponen rojas al instante y una de ellas dice literalmente «suma 48 h totales y 45 h abiertas».

El último paso: añadir las pruebas al flujo de integración continua de 08-02.

      # .github/workflows/calidad.yml — un paso más
      - name: Ejecutar las pruebas
        run: npm test -- --coverage --ci

--ci desactiva la escritura automática de instantáneas nuevas; en el servidor, una instantánea que no existe debe fallar, no crearse en silencio.

Errores Comunes y Consejos

  • Usar toBe con objetos o arrays. Compara referencias: expect({a:1}).toBe({a:1}) siempre falla. Para contenido, toEqual o toStrictEqual.
  • Olvidar la función en toThrow. expect(new Tarea({})) lanza antes de que expect haga nada. Siempre expect(() => …).toThrow(…).
  • Pruebas asíncronas sin async/await ni return. La prueba termina antes de que se compruebe nada y pasa siempre. Es un falso verde, el resultado más peligroso de todos.
  • Olvidar el await antes de expect(...).resolves. Mismo problema, mismo falso verde.
  • Compartir estado entre pruebas. Un objeto creado en el ámbito del módulo hace que el orden importe y aparezcan fallos intermitentes. Siempre beforeEach.
  • Dejarse un test.only puesto. La suite pasa en verde ejecutando una sola prueba. Es exactamente lo que caza jest/no-focused-tests, configurado en 08-02.
  • Probar detalles internos en lugar de comportamiento. Una prueba que comprueba tablero.#tareas (si pudiera) se rompería en cada refactor sin que nada esté mal. Prueba lo que el módulo promete, no cómo lo cumple.
  • Perseguir el 100 % de cobertura. Los últimos puntos cuestan mucho y valen poco. Exige mucho en el modelo, sé razonable en la vista.
  • Usar la fecha real en las pruebas. Una prueba que llama a new Date() cambia de resultado el 1 de octubre. Pasa HOY = '2026-09-20' como argumento; para el código que no lo permite, están los temporizadores falsos de 08-04.
  • Consejo: escribe primero la prueba que reproduce el fallo. Es el paso 6 del método de 08-01 y el único caso en que TDD es indiscutible.
  • Consejo: comprueba que la prueba falla antes de arreglar nada. Una prueba que nunca has visto en rojo puede no estar comprobando lo que crees.
  • Consejo: cuando una prueba sea difícil de escribir, sospecha del código, no de la prueba. La dificultad casi siempre señala una función que hace demasiadas cosas.

Ejercicios

Ejercicio 1 — Batería de js/util/fechas.js. Escribe pruebas/util/fechas.test.js con la cobertura completa de las tres funciones del módulo. Para diasEntre, incluye días positivos, negativos, cero y un salto de mes. Para estaVencida, usa test.each con una tabla que cubra las cuatro combinaciones de (fecha pasada/futura) × (estado hecha/no hecha), más el caso del mismo día. Para fechaLegible, comprueba al menos enero, septiembre y diciembre, y el día sin cero a la izquierda. Justifica en un comentario por qué ninguna prueba usa new Date() sin argumentos.

Ejercicio 2 — Regla nueva con TDD: R12, el límite semanal. Marta pide implementar por fin R7 como método del tablero: puedeAsignar(responsable, horas, hoy) debe devolver false si asignar esas horas haría que esa persona superara 40 h abiertas. Desarróllalo con el ciclo rojo-verde-refactor, escribiendo cada prueba antes que el código, y documenta las cuatro vueltas. Cubre: (a) Iván tiene 25 h abiertas, así que aceptar 10 h más está permitido; (b) aceptar 16 h más no lo está; (c) el límite exacto (25 + 15 = 40) sí se permite; (d) las tareas hechas no cuentan; (e) una persona sin tareas puede aceptar hasta 40 h.

Ejercicio 3 — Diagnosticar una suite enferma. Esta suite tiene cinco problemas distintos. Identifícalos todos, explica qué consecuencia tiene cada uno y reescríbela correctamente.

import { Tablero } from '../../js/modelo/tablero.js';
import { crearBacklog } from '../../js/datos/backlog.js';

const tablero = new Tablero('Taller Nómada', crearBacklog());

describe('tablero', () => {
  test('funciona', () => {
    expect(tablero.total).toBe(6);
    tablero.cambiarEstado(2, 'en-curso');
    expect(tablero.buscarPorId(2).estado).toBe('en-curso');
  });

  test('resumen', () => {
    const r = tablero.resumen(new Date().toISOString().slice(0, 10));
    expect(r).toBe({ total: 6, abiertas: 5, horasTotales: 48,
                     horasAbiertas: 45, vencidas: 1, esfuerzo: 124 });
  });

  test('carga', () => {
    cargarDesdeServidor().then((t) => {
      expect(t.total).toBe(6);
    });
  });

  test.only('id duplicado', () => {
    expect(() => tablero.agregar(tablero.tareas[0])).toThrow();
  });
});

Soluciones

Solución 1

// pruebas/util/fechas.test.js
import { HOY, diasEntre, estaVencida, fechaLegible } from '../../js/util/fechas.js';

// Ninguna prueba llama a new Date() sin argumentos: si lo hiciera, el resultado
// dependería del día de ejecución y la suite se rompería sola un martes cualquiera
// sin que nadie hubiera tocado el código. El tiempo entra SIEMPRE como dato.

describe('HOY', () => {
  test('es la fecha canónica del proyecto en formato ISO', () => {
    expect(HOY).toBe('2026-09-20');
    expect(HOY).toMatch(/^\d{4}-\d{2}-\d{2}$/);
  });
});

describe('diasEntre', () => {
  test.each`
    desde             | hasta             | esperado | caso
    ${'2026-09-20'}   | ${'2026-09-30'}   | ${10}    | ${'diez días adelante'}
    ${'2026-09-20'}   | ${'2026-09-05'}   | ${-15}   | ${'quince días atrás'}
    ${'2026-09-20'}   | ${'2026-09-20'}   | ${0}     | ${'el mismo día'}
    ${'2026-09-20'}   | ${'2026-10-02'}   | ${12}    | ${'cambio de mes'}
    ${'2026-12-28'}   | ${'2027-01-03'}   | ${6}     | ${'cambio de año'}
    ${'2028-02-27'}   | ${'2028-03-01'}   | ${3}     | ${'año bisiesto'}
  `('$caso: $desde → $hasta = $esperado', ({ desde, hasta, esperado }) => {
    expect(diasEntre(desde, hasta)).toBe(esperado);
  });
});

describe('estaVencida (R10)', () => {
  test.each`
    fecha             | estado          | esperado | motivo
    ${'2026-09-05'}   | ${'pendiente'}  | ${true}  | ${'pasada y sin empezar'}
    ${'2026-09-05'}   | ${'en-curso'}   | ${true}  | ${'pasada y en curso'}
    ${'2026-09-05'}   | ${'hecha'}      | ${false} | ${'pasada pero terminada'}
    ${'2026-10-30'}   | ${'pendiente'}  | ${false} | ${'futura y sin empezar'}
    ${'2026-10-30'}   | ${'hecha'}      | ${false} | ${'futura y terminada'}
    ${'2026-09-20'}   | ${'pendiente'}  | ${false} | ${'vence hoy: aún hay tiempo'}
  `('$fecha + $estado → $esperado ($motivo)', ({ fecha, estado, esperado }) => {
    expect(estaVencida(fecha, estado, '2026-09-20')).toBe(esperado);
  });

  test('usa HOY por defecto si no se pasa fecha de referencia', () => {
    expect(estaVencida('2026-09-05', 'pendiente')).toBe(true);
    expect(estaVencida('2026-12-01', 'pendiente')).toBe(false);
  });
});

describe('fechaLegible', () => {
  test.each([
    ['2026-01-01', '1 de enero de 2026'],
    ['2026-09-05', '5 de septiembre de 2026'],
    ['2026-09-20', '20 de septiembre de 2026'],
    ['2026-12-31', '31 de diciembre de 2026']
  ])('%s → %s', (iso, esperado) => {
    expect(fechaLegible(iso)).toBe(esperado);
  });

  test('quita el cero a la izquierda del día', () => {
    expect(fechaLegible('2026-03-08')).toBe('8 de marzo de 2026');
    expect(fechaLegible('2026-03-08')).not.toContain('08');
  });
});

Solución 2

VUELTA 1 · ROJO — el caso permitido
test('Iván, con 25 h abiertas, puede aceptar 10 h más (R7)', () => {
  expect(unTablero().puedeAsignar('Iván', 10)).toBe(true);
});
// ✗ TypeError: tablero.puedeAsignar is not a function
VUELTA 1 · VERDE — lo mínimo
puedeAsignar() { return true; }
VUELTA 2 · ROJO — el caso prohibido
test('Iván, con 25 h abiertas, NO puede aceptar 16 h más (25 + 16 = 41 > 40)', () => {
  expect(unTablero().puedeAsignar('Iván', 16)).toBe(false);
});
// ✗ Expected: false   Received: true
VUELTA 2 · VERDE
puedeAsignar(responsable, horas) {
  return (this.horasPorResponsable()[responsable] ?? 0) + horas <= 40;
}
VUELTA 3 · ROJO — el límite exacto y quien no tiene nada asignado
test('el límite exacto de 40 h está permitido (25 + 15)', () => {
  expect(unTablero().puedeAsignar('Iván', 15)).toBe(true);
});

test('una persona sin tareas puede aceptar hasta 40 h', () => {
  const tablero = unTablero();

  expect(tablero.puedeAsignar('Berta', 40)).toBe(true);
  expect(tablero.puedeAsignar('Berta', 41)).toBe(false);
});
// Ambas pasan: el <= y el ?? 0 ya estaban bien. Verde a la primera,
// pero merecía la pena escribirlas: documentan los bordes.

VUELTA 4 · ROJO — las tareas hechas no cuentan
test('las tareas hechas no consumen cupo semanal', () => {
  const tablero = unTablero();

  tablero.cambiarEstado(1, 'hecha');        // Iván cierra 12 h: le quedan 13 abiertas

  expect(tablero.puedeAsignar('Iván', 27)).toBe(true);    // 13 + 27 = 40
  expect(tablero.puedeAsignar('Iván', 28)).toBe(false);   // 13 + 28 = 41
});
// Pasa, porque horasPorResponsable ya filtra por `abiertas`. La prueba
// documenta y protege ese detalle, que es fácil de romper en un refactor.
REFACTOR — con las cinco pruebas en verde
// js/modelo/tablero.js
const MAXIMO_SEMANAL = 40;                      // R7, en una constante con nombre

/**
 * R7: nadie puede superar 40 h estimadas ABIERTAS.
 * Las tareas hechas no consumen cupo.
 *
 * @param {string} responsable
 * @param {number} horas  Horas que se pretenden asignar
 * @returns {boolean}
 */
puedeAsignar(responsable, horas) {
  const asignadas = this.horasPorResponsable()[responsable] ?? 0;
  return asignadas + horas <= MAXIMO_SEMANAL;
}

/** Horas que aún se le pueden asignar a alguien sin incumplir R7. */
cupoDisponible(responsable) {
  return MAXIMO_SEMANAL - (this.horasPorResponsable()[responsable] ?? 0);
}

Solución 3

# Problema Consecuencia
1 Estado compartido: el tablero se crea una vez fuera de los describe La primera prueba cierra la tarea 2 y contamina a las demás; el orden pasa a importar y aparecen fallos intermitentes
2 Nombres inútiles ('tablero', 'funciona', 'resumen', 'carga') y varios motivos de fallo por prueba Cuando CI se pone roja, el nombre no dice nada y hay que abrir el fichero
3 toBe con un objeto en la prueba del resumen Falla siempre: compara referencias. Debe ser toStrictEqual
4 Fecha real con new Date() La prueba cambia de resultado según el día: el 1 de octubre habrá 2 vencidas y fallará sola
5 Prueba asíncrona sin async/await y test.only olvidado La de cargarDesdeServidor pasa siempre sin comprobar nada (falso verde), y el .only deja el resto de la suite sin ejecutar
// Suite corregida
import { Tablero } from '../../js/modelo/tablero.js';
import { ErrorDeValidacion } from '../../js/modelo/errores.js';
import { crearBacklog } from '../../js/datos/backlog.js';
import { cargarDesdeServidor } from '../../js/datos/api-tareas.js';

const HOY = '2026-09-20';                     // ← fecha fija: el tiempo entra como dato

describe('Tablero', () => {
  let tablero;

  beforeEach(() => {                          // ← estado nuevo para cada prueba
    tablero = new Tablero('Taller Nómada', crearBacklog());
  });

  test('el backlog canónico tiene 6 tareas', () => {
    expect(tablero.total).toBe(6);
  });

  test('cambiarEstado aplica la transición a la tarea indicada', () => {
    tablero.cambiarEstado(2, 'en-curso');

    expect(tablero.buscarPorId(2).estado).toBe('en-curso');
  });

  test('el resumen del backlog canónico es exactamente el esperado', () => {
    expect(tablero.resumen(HOY)).toStrictEqual({   // ← toStrictEqual, no toBe
      total: 6, abiertas: 5, horasTotales: 48,
      horasAbiertas: 45, vencidas: 1, esfuerzo: 124
    });
  });

  test('agregar rechaza un id duplicado (R1)', () => {   // ← sin .only
    expect(() => tablero.agregar(tablero.tareas[0])).toThrow(ErrorDeValidacion);
    expect(tablero.total).toBe(6);
  });

  test('cargarDesdeServidor devuelve las 6 tareas del backlog', async () => {
    const cargado = await cargarDesdeServidor();       // ← async + await

    expect(cargado.total).toBe(6);
  });
  // Nota: esta última prueba todavía depende de la red de verdad, lo que la hace
  // lenta y no determinista. En 08-04 se sustituye fetch por un doble y pasa a
  // ejecutarse en milisegundos, sin salir de la máquina.
});

Conclusión

Nómada Tareas tiene por fin una red de seguridad. Sabes qué es una prueba automatizada —un programa que ejecuta otro programa y comprueba el resultado— y qué gana de verdad el equipo con ellas: poder refactorizar sin miedo, una documentación ejecutable que no puede mentir porque falla cuando deja de ser cierta, y una presión constante hacia un diseño mejor, porque lo difícil de probar suele estar mal diseñado. Conoces la pirámide de pruebas con sus tres niveles, el porqué de su forma —la confianza y el coste suben juntos— y el antipatrón del cono de helado.

Tienes Jest funcionando sobre módulos ES nativos, con la bandera de Node en el script y un jest.config.js sin transformaciones, de modo que lo que se prueba es exactamente lo que se despliega; y conoces la alternativa con Babel y sus contrapartidas. Dominas la anatomía de una prueba: describe para agrupar, test para comprobar, y el patrón AAA —Preparar, Actuar una sola vez, Comprobar—. Sabes elegir matcher: toBe para primitivos y para identidad deliberada, toEqual para contenido, toStrictEqual cuando el tipo de clase y los undefined importan —como en la salida de toJSON(), por la trampa de 04-08—, toMatchObject para no atarte a campos que no vienen al caso, y toThrow siempre con una función dentro, preferiblemente comprobando el tipo y no el texto del mensaje, con expect.assertions protegiendo los try/catch para que un fallo no se disfrace de verde.

Sabes usar los ganchos y por qué beforeEach es el que más se usa; entiendes que el estado compartido es la causa número uno de los fallos intermitentes y que aquella decisión de 05-04 de exportar crearBacklog() como función en lugar de un array ya construido es hoy lo que hace independientes tus 48 pruebas. Cubres la tabla completa de transiciones R6 con test.each en sus dos sintaxis, y los valores frontera de R3 —0, 1, 40, 41, NaN, '12'— en una tabla que se lee como una especificación. Sabes probar código asíncrono con async/await, resolves y rejects, y reconocer el falso verde de una prueba asíncrona que nadie espera.

Y sobre todo, se ha cerrado la promesa de 03-03: entiendes qué hace testeable un módulo. Una función pura como estaVencida(fecha, estado, hoy) se prueba en una línea porque el tiempo entra como dato; marcarVencidas(), que lee el reloj, el almacén y el DOM, necesita medio Módulo 8 para probarse. La inyección de dependencias que llevas aplicando sin nombrarla —el hoy = HOY de estaVencida, el almacen del constructor de RepositorioLocal— es exactamente lo que hace sustituible una dependencia en una prueba. Sabes leer la cobertura, mirar la columna branch antes que ninguna, poner umbrales por carpeta (exigentes en el modelo, razonables en la vista) y no confundir ejecutar con verificar. Nombras las pruebas con sujeto, comportamiento y condición, citas la regla de negocio entre paréntesis, y respetas la regla del motivo único de fallo. Y has hecho TDD de verdad en tres vueltas de rojo-verde-refactor sobre R11, comprobando que el rojo llega por el motivo correcto antes de escribir una línea.

El resultado son 48 pruebas en menos de un segundo que blindan Tarea y Tablero completos: las validaciones R2, R3, R5, R8 y R9; las nueve combinaciones de R6; el vencimiento R10 con su caso del mismo día; el viaje de ida y vuelta de toJSON/desdeJSON; las copias defensivas de 05-03; la iterabilidad de 05-08; y los números canónicos del backlog —6 tareas, 48 h totales, 45 h abiertas, esfuerzo 124, una vencida, Iván 25 / Lucía 14 / Marta 6—. El caso 3 de 08-01 no puede volver.

Pero fíjate en qué se ha quedado fuera. js/datos/repositorio-local.js aparece en el informe de cobertura al 31 %, y no es por pereza: para probarlo hace falta un localStorage que en Node no existe. js/datos/api-tareas.js no tiene ni una prueba, porque probarlo de verdad significaría llamar a un servidor —lento, poco fiable y con un TLD .example que jamás resuelve—. El debounce de js/util/tiempo.js esperaría 300 ms reales por prueba, y los reintentos con retroceso exponencial de js/datos/http.js tardarían casi dos segundos cada uno. Y estaVencida() solo es comprobable porque tuvimos la precaución de pasarle hoy como parámetro; el día que alguien llame a new Date() dentro, la suite empezará a fallar sola los martes. Red, reloj, almacenamiento y aleatoriedad: cuatro dependencias que hacen las pruebas lentas y no deterministas, que es la definición exacta de una suite que nadie ejecuta. La solución es sustituirlas por piezas controladas, y eso es Dobles de Prueba: Mocks, Stubs y Spies, donde probarás las cuatro capas restantes sin red, sin esperas y con el calendario congelado en el 20 de septiembre de 2026.

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