La lección anterior dejó 48 pruebas verdes en menos de un segundo… y media aplicación sin cubrir. repositorio-local.js al 31 % porque en Node no existe localStorage; api-tareas.js sin una sola prueba porque probarlo significaría llamar a un servidor que no existe; el debounce esperando 300 milisegundos reales por prueba; los reintentos con retroceso exponencial de http.js tardando casi dos segundos cada uno. Red, reloj, almacenamiento y aleatoriedad: las cuatro dependencias que convierten una suite rápida y fiable en una lenta e intermitente. Esta lección enseña a sustituirlas por piezas controladas. Aprenderás el vocabulario preciso —dummy, stub, spy, mock y fake no son sinónimos—, dominarás jest.fn() y jest.spyOn, simularás módulos completos, probarás api-tareas.js sin red comprobando que ErrorDeApi sale con el status correcto, congelarás el reloj para que estaVencida no dependa del día en que se ejecuten las pruebas, y verás por qué la inyección de dependencias suele ser mejor idea que el mockeo agresivo.

Contenido

  1. Las cuatro dependencias que estropean una prueba
  2. Qué es un doble de prueba
  3. El vocabulario preciso: dummy, stub, spy, mock y fake
  4. jest.fn(): la función espía
  5. Aserciones sobre llamadas
  6. Programar el retorno de un doble
  7. jest.spyOn: observar sin sustituir
  8. Simular módulos completos
  9. Simular solo una parte de un módulo
  10. Simular fetch: probar api-tareas.js sin red
  11. Los cuatro escenarios de red que hay que cubrir
  12. Simular el reloj: temporizadores falsos
  13. Probar el debounce sin esperar
  14. Probar los reintentos con retroceso exponencial
  15. Congelar la fecha: HOY sin sorpresas
  16. Simular localStorage con un doble en memoria
  17. Inyección de dependencias frente al mockeo agresivo
  18. Pruebas acopladas a la implementación
  19. Errores Comunes y Consejos
  20. Ejercicios
  21. Conclusión

  1. Las cuatro dependencias que estropean una prueba

Una buena prueba tiene tres propiedades: es rápida, es determinista (mismo resultado siempre) y es aislada (no depende de nada externo). Estas cuatro dependencias las destruyen las tres:

Dependencia Qué rompe Ejemplo en Nómada Tareas
La red Velocidad, determinismo, aislamiento listarTareas() llama a api.tallernomada.example, que nunca resuelve
El reloj Determinismo y velocidad estaVencida() sin argumento; el dormir() de conReintentos
El almacenamiento Aislamiento (estado que persiste entre pruebas) localStorage, que además no existe en Node
La aleatoriedad Determinismo El jitter con Math.random() de conReintentos

Y el coste real, medido:

Sin dobles:
  api-tareas.test.js       ✗ falla: getaddrinfo ENOTFOUND api.tallernomada.example
  tiempo.test.js           ✓ 4 pruebas · 1.4 s      (4 × 350 ms de espera real)
  http.test.js             ✓ 3 pruebas · 6.8 s      (reintentos con backoff de verdad)
  repositorio.test.js      ✗ falla: localStorage is not defined

Con dobles:
  api-tareas.test.js       ✓ 12 pruebas · 0.09 s
  tiempo.test.js           ✓ 4 pruebas · 0.02 s
  http.test.js             ✓ 8 pruebas · 0.05 s
  repositorio.test.js      ✓ 11 pruebas · 0.03 s

Ocho segundos y dos suites rotas frente a dos décimas y 35 pruebas. Y lo importante no es la velocidad: es que las pruebas con red fallan a veces sin que nada esté mal, y una suite que falla sin motivo deja de mirarse en dos semanas.

  1. Qué es un doble de prueba

Un doble de prueba es un objeto que ocupa el lugar de una dependencia real durante una prueba. El término viene del cine: el doble de acción sustituye al actor en la escena peligrosa.

flowchart LR
    subgraph P["En producción"]
        A1["listarTareas"] --> B1["fetch real"] --> C1["Servidor<br/>api.tallernomada"]
    end
    subgraph T["En la prueba"]
        A2["listarTareas"] --> B2["fetch DOBLE"] --> C2["Respuesta<br/>fabricada aquí"]
    end
    style C2 fill:#bbf7d0,stroke:#15803d
    style C1 fill:#fecaca,stroke:#b91c1c

El código bajo prueba —listarTareasno se entera: llama a fetch como siempre. Lo que cambia es qué hay al otro lado. Eso permite tres cosas imposibles con la dependencia real:

  • Controlar la respuesta: hoy un 200 con seis tareas, ahora un 500, ahora un fallo de red. Provocar un 500 en un servidor real es difícil; en un doble es una línea.
  • Observar las llamadas: ¿se llamó a fetch con la URL correcta? ¿Con el método PATCH? ¿Cuántas veces?
  • Ir a la velocidad que quieras: sin latencia, sin esperas, sin temporizadores reales.

  1. El vocabulario preciso: dummy, stub, spy, mock y fake

En la práctica casi todo el mundo dice «mock» para todo, y Jest lo agrava llamando jest.fn() a un objeto que suele hacer de spy. Pero los cinco términos designan cosas distintas, y conocerlos ayuda a decidir cuál necesitas.

Tipo Qué hace ¿Devuelve valores? ¿Se comprueba sobre él? Ejemplo en Nómada Tareas
Dummy Nada. Solo rellena un hueco obligatorio No No Un signal que se pasa porque la firma lo exige y esta prueba no cancela
Stub Devuelve respuestas fijas No Un fetch que siempre devuelve el backlog canónico
Spy Registra cómo lo llaman, dejando pasar la llamada real Opcionalmente Vigilar que repositorio.guardar se llama al cambiar de estado
Mock Stub + expectativas sobre cómo debe ser llamado Un fetch que devuelve 201 y del que se comprueba método, URL y cuerpo
Fake Implementación real pero simplificada No Un localStorage en memoria con un Map

Un ejemplo de cada uno, sobre el proyecto:

// ── DUMMY · solo ocupa un hueco. No se usa, no se comprueba ────────────
const senalDummy = new AbortController().signal;
await listarTareas({ responsable: 'Iván' }, { signal: senalDummy });

// ── STUB · respuesta fija; nadie mira cómo se le llamó ─────────────────
const fetchStub = jest.fn().mockResolvedValue(
  new Response(JSON.stringify(datosBacklog), { status: 200,
    headers: { 'content-type': 'application/json' } })
);

// ── SPY · observa, y (aquí) deja pasar la llamada real ─────────────────
const espia = jest.spyOn(repositorio, 'guardar');
tablero.cambiarEstado(2, 'en-curso');
expect(espia).toHaveBeenCalledTimes(1);           // ← la comprobación ES el objetivo

// ── MOCK · respuesta programada Y expectativas sobre la llamada ────────
const fetchMock = jest.fn().mockResolvedValue(
  new Response(JSON.stringify({ id: 7 }), { status: 201,
    headers: { 'content-type': 'application/json' } })
);
await crearTarea(datosNuevos);
expect(fetchMock).toHaveBeenCalledWith(
  expect.stringContaining('/tareas'),
  expect.objectContaining({ method: 'POST' })
);

// ── FAKE · implementación real, simplificada ───────────────────────────
function almacenFalso(inicial = {}) {
  const mapa = new Map(Object.entries(inicial));
  return {
    getItem: (k) => (mapa.has(k) ? mapa.get(k) : null),
    setItem: (k, v) => { mapa.set(k, String(v)); },
    removeItem: (k) => { mapa.delete(k); },
    clear: () => mapa.clear(),
    get length() { return mapa.size; }
  };
}

La distinción que más importa en la práctica es la que separa stub de mock, porque marca dónde pones la aserción:

  • Con un stub, la aserción va sobre el resultado: «listarTareas devolvió seis instancias de Tarea».
  • Con un mock, la aserción va sobre la interacción: «se llamó a fetch con PATCH y este cuerpo».

La primera prueba comportamiento; la segunda prueba implementación. La primera sobrevive a un refactor; la segunda no siempre. Es el tema del apartado 18, y conviene tenerlo presente desde ahora: usa stubs por defecto y mocks solo cuando la interacción sea, en sí misma, el comportamiento que quieres garantizar (que se envíe un DELETE y no un PATCH, por ejemplo, sí es comportamiento observable).

  1. jest.fn(): la función espía

jest.fn() crea una función que registra todo lo que le ocurre.

import { jest } from '@jest/globals';        // ← con módulos ES, jest se IMPORTA

const alCrear = jest.fn();

alCrear({ id: 7, titulo: 'Revisar extintores' });
alCrear({ id: 8, titulo: 'Pedir tinta negra' });

console.log(alCrear.mock.calls);
// [ [{ id: 7, titulo: 'Revisar extintores' }],
//   [{ id: 8, titulo: 'Pedir tinta negra' }] ]

Ese import { jest } from '@jest/globals' es obligatorio con el camino de módulos ES nativos que configuraste en 08-03. Con CommonJS, jest es una global inyectada; con ESM no lo es, y olvidar la importación produce un ReferenceError: jest is not defined que confunde bastante la primera vez.

La propiedad .mock es el registro completo:

Propiedad Contiene
.mock.calls Array de arrays: los argumentos de cada llamada
.mock.results Qué devolvió (o lanzó) cada llamada
.mock.instances Los this de cada llamada, si se usó con new
.mock.lastCall Los argumentos de la última llamada
alCrear.mock.calls.length;      // 2  → cuántas veces se llamó
alCrear.mock.calls[0][0];       // { id: 7, … }  → primer argumento de la primera llamada
alCrear.mock.lastCall[0];       // { id: 8, … }  → primer argumento de la última

Puedes darle un cuerpo desde el principio:

const siguienteId = jest.fn(() => 7);         // implementación inicial
const registrar = jest.fn((nivel, evento) => `[${nivel}] ${evento}`);

  1. Aserciones sobre llamadas

Jest trae matchers específicos para dobles, mucho más legibles que inspeccionar .mock.calls a mano:

Matcher Comprueba
toHaveBeenCalled() Se llamó al menos una vez
toHaveBeenCalledTimes(n) Se llamó exactamente n veces
toHaveBeenCalledWith(...args) Alguna llamada tuvo esos argumentos
toHaveBeenLastCalledWith(...args) La última llamada tuvo esos argumentos
toHaveBeenNthCalledWith(n, ...args) La llamada número n tuvo esos argumentos
toHaveReturnedWith(v) Devolvió ese valor alguna vez
not.toHaveBeenCalled() Nunca se llamó
test('al crear una tarea se avisa al oyente con la tarea creada', () => {
  const alCrear = jest.fn();
  const formulario = { titulo: 'Revisar extintores', horasEstimadas: 2 };

  crearDesdeFormulario(formulario, { alCrear });

  expect(alCrear).toHaveBeenCalledTimes(1);
  expect(alCrear).toHaveBeenCalledWith(expect.objectContaining({
    titulo: 'Revisar extintores',
    estado: 'pendiente'                      // R5: nace en pendiente
  }));
});

Las comparaciones asimétricas de ese ejemplo son imprescindibles cuando no quieres atarte al objeto completo:

expect.anything()                    // cualquier cosa salvo null y undefined
expect.any(Number)                   // cualquier número (o String, Function, Tarea…)
expect.objectContaining({ a: 1 })    // un objeto que contiene AL MENOS esa propiedad
expect.arrayContaining([1, 2])       // un array que contiene al menos esos elementos
expect.stringContaining('/tareas')   // una cadena que contiene ese texto
expect.stringMatching(/^https:/)     // una cadena que casa con la expresión regular
expect.closeTo(8.16, 2)              // un número decimal con tolerancia

Sin ellas, comprobar una llamada a fetch obligaría a escribir la URL exacta con todos sus parámetros codificados y el objeto de opciones completo; con ellas, compruebas solo lo que le importa a esta prueba:

expect(fetch).toHaveBeenCalledWith(
  expect.stringContaining('/tareas/3'),
  expect.objectContaining({ method: 'PATCH' })
);

Y un aviso importante: toHaveBeenCalledWith compara los argumentos con la semántica de toEqual (estructural, recursiva). Pero si el argumento es un objeto que el código muta después, estarás comparando su estado actual, no el que tenía en el momento de la llamada. Es la misma trampa que la consola de 08-01: si necesitas la foto, clona en el propio doble.

// Un doble que guarda una COPIA de lo que recibe
const guardar = jest.fn((tablero) => estadosGuardados.push(structuredClone(tablero.toJSON())));

  1. Programar el retorno de un doble

Un jest.fn() recién creado devuelve undefined. Estos métodos le dan comportamiento:

Método Qué hace
mockReturnValue(v) Devuelve v siempre
mockReturnValueOnce(v) Devuelve v solo la próxima vez (encadenable)
mockResolvedValue(v) Devuelve una promesa resuelta con v
mockRejectedValue(e) Devuelve una promesa rechazada con e
mockResolvedValueOnce(v) / mockRejectedValueOnce(e) Las versiones de una sola vez
mockImplementation(fn) Ejecuta fn como cuerpo
mockImplementationOnce(fn) Solo la próxima vez

Las variantes ...Once son las que permiten simular secuencias, y ahí está el 90 % de su valor. Ejemplo real: probar que conReintentos insiste tras dos fallos y acaba teniendo éxito.

const pedir = jest.fn()
  .mockRejectedValueOnce(new ErrorDeApi('503', { status: 503, codigo: 'servidor' }))
  .mockRejectedValueOnce(new ErrorDeApi('503', { status: 503, codigo: 'servidor' }))
  .mockResolvedValue({ tareas: datosBacklog });      // a partir de la tercera, éxito

const resultado = await conReintentos(pedir, { intentos: 3, baseMs: 0 });

expect(pedir).toHaveBeenCalledTimes(3);
expect(resultado.tareas).toHaveLength(6);

Tres fallos programados, un comportamiento verificado, cero milisegundos de espera. Reproducir eso contra un servidor real sería, sencillamente, imposible.

mockImplementation sirve cuando la respuesta depende del argumento:

const buscar = jest.fn((id) => datosBacklog.find((t) => t.id === id) ?? null);

expect(buscar(3).titulo).toBe('Actualizar la web de reservas');
expect(buscar(99)).toBeNull();

Y tres métodos de limpieza que conviene distinguir bien, porque confundirlos causa fallos raros:

Método Borra el registro de llamadas Borra la implementación Restaura la original
mockClear()
mockReset()
mockRestore() ✅ (solo con spyOn)

Y la forma de no tener que acordarse nunca:

// jest.config.js
export default {
  restoreMocks: true,      // mockRestore() automático tras cada prueba
  clearMocks: true         // mockClear() automático antes de cada prueba
};

Con esas dos líneas, el estado compartido entre pruebas —el problema del apartado 11 de 08-03— deja de ser un riesgo también para los dobles.

  1. jest.spyOn: observar sin sustituir

jest.fn() crea una función nueva. jest.spyOn(objeto, 'metodo') envuelve un método existente, y por defecto deja pasar la llamada real.

test('cambiar de estado provoca exactamente un guardado', () => {
  const repositorio = new RepositorioLocal({ almacen: almacenFalso() });
  const tablero = unTablero();
  conectarPersistencia(tablero, repositorio);

  const espia = jest.spyOn(repositorio, 'guardar');   // observa, NO sustituye

  tablero.cambiarEstado(2, 'en-curso');

  expect(espia).toHaveBeenCalledTimes(1);
  expect(espia).toHaveBeenCalledWith(tablero);
  expect(repositorio.cargar().total).toBe(6);          // ← el guardado REAL ocurrió
});

Esa última línea es la diferencia esencial: con spyOn puro, el método original se ejecuta y sus efectos son reales. Estás observando, no reemplazando.

Si además quieres sustituir el comportamiento, se encadena:

// Observar Y sustituir: simulamos que el almacén está lleno
jest.spyOn(repositorio, 'guardar').mockReturnValue(false);

// Observar y silenciar: evitar que la consola ensucie la salida de las pruebas
const avisos = jest.spyOn(console, 'warn').mockImplementation(() => {});

repositorio.guardar(tablero);

expect(avisos).toHaveBeenCalledWith(expect.stringContaining('Sin espacio'));
avisos.mockRestore();                    // ← imprescindible si no usas restoreMocks

Ese patrón sobre console.warn es de los más útiles del día a día: comprueba que el aviso se emite y mantiene limpia la salida de la suite.

mockRestore() solo funciona con spyOn, porque solo entonces hay un original al que volver. Sobre un jest.fn() no hace nada más que mockReset(). Y olvidarlo tiene consecuencias reales: un console.warn silenciado que nunca se restaura deja mudos todos los ficheros de pruebas posteriores.

jest.fn() jest.spyOn(obj, 'm')
Crea una función Nueva, de la nada Envuelve una existente
Comportamiento por defecto Devuelve undefined Ejecuta el original
Se puede restaurar No hay nada que restaurar Sí, con mockRestore()
Uso típico Callbacks, dependencias inyectadas Métodos de objetos que ya existen

  1. Simular módulos completos

A veces la dependencia no entra por parámetro: está importada dentro del módulo bajo prueba. api-tareas.js importa Tarea; app.js importa listarTareas. Para sustituir eso hay que interceptar el propio sistema de módulos.

Con CommonJS, la herramienta es jest.mock('ruta'), que se eleva automáticamente por encima de los require. Con módulos ES nativos —el camino que elegiste en 08-03— no hay elevación, así que la API es distinta y hay que respetar un orden estricto:

// pruebas/vista/controlador.test.js
import { jest } from '@jest/globals';

// 1 · Declarar el doble ANTES de importar nada del módulo real
jest.unstable_mockModule('../../js/datos/api-tareas.js', () => ({
  listarTareas: jest.fn(),
  crearTarea: jest.fn(),
  actualizarTarea: jest.fn(),
  borrarTarea: jest.fn()
}));

// 2 · Importar DESPUÉS, y de forma dinámica (await import)
const { listarTareas, crearTarea } = await import('../../js/datos/api-tareas.js');
const { cargarTablero } = await import('../../js/app-datos.js');

describe('cargarTablero', () => {
  beforeEach(() => { jest.clearAllMocks(); });

  test('construye el tablero con lo que devuelve la API', async () => {
    listarTareas.mockResolvedValue(datosBacklog.map((d) => new Tarea(d)));

    const tablero = await cargarTablero();

    expect(tablero.total).toBe(6);
    expect(listarTareas).toHaveBeenCalledTimes(1);
  });
});

Tres reglas para que esto funcione:

  • jest.unstable_mockModule va antes de cualquier import del módulo real, incluidos los indirectos. Si otro módulo ya lo importó, el doble llega tarde.
  • Las importaciones del módulo simulado deben ser dinámicas (await import(...)), porque los import estáticos se resuelven antes de que se ejecute ninguna línea del fichero.
  • El nombre unstable_ asusta, pero es la API documentada para ESM y funciona; el prefijo refleja que su forma puede cambiar, no que falle.

Y una alternativa que evita todo este baile, disponible siempre que el módulo esté bien diseñado:

// En lugar de simular el módulo, INYECTAR la dependencia
export async function cargarTablero({ listar = listarTareas } = {}) {
  return new Tablero('Taller Nómada', await listar());
}

// La prueba, sin ninguna magia de módulos:
test('construye el tablero con lo que devuelve la API', async () => {
  const listar = jest.fn().mockResolvedValue(crearBacklog());

  const tablero = await cargarTablero({ listar });

  expect(tablero.total).toBe(6);
});

Cuatro líneas frente a quince, y sin dependencias del ejecutor. Vuelve el tema del apartado 17.

  1. Simular solo una parte de un módulo

A menudo quieres sustituir una función de un módulo y conservar el resto. Se resuelve importando el módulo real dentro de la fábrica del doble:

import { jest } from '@jest/globals';

// Sustituimos SOLO `dormir`; pedirJson y conReintentos siguen siendo los de verdad
jest.unstable_mockModule('../../js/util/tiempo.js', async () => {
  const real = await import('../../js/util/tiempo.js');
  return {
    ...real,                                // todo lo original…
    dormir: jest.fn().mockResolvedValue()   // …salvo esto
  };
});

const { debounce, dormir } = await import('../../js/util/tiempo.js');

El patrón se llama simulación parcial, y es casi siempre preferible a sustituir un módulo entero: cuanta menos superficie reemplaces, más cerca está la prueba del código real.

Un aviso sobre js/util/formato.js y otros módulos de utilidad: no los simules. Son puros, rápidos y deterministas. Sustituir una dependencia que ya cumple las tres propiedades de una buena prueba solo añade una capa que puede desincronizarse con la realidad. Se simula lo que estorba, no todo lo que se puede simular.

  1. Simular fetch: probar api-tareas.js sin red

Este es el caso central de la lección. fetch es una global, así que la forma más directa de sustituirla es asignarla:

// pruebas/ayudas/red-falsa.js
import { jest } from '@jest/globals';

/** Construye una Response real, con su status y sus cabeceras. */
export function respuestaJson(cuerpo, { status = 200, cabeceras = {} } = {}) {
  return new Response(JSON.stringify(cuerpo), {
    status,
    headers: { 'content-type': 'application/json', ...cabeceras }
  });
}

/** Una Response sin cuerpo, para 204 No Content. */
export const respuestaVacia = () => new Response(null, { status: 204 });

/** Una Response que NO es JSON: el proxy que devuelve HTML de un login. */
export const respuestaHtml = (status = 200) =>
  new Response('<!doctype html><h1>Inicia sesión</h1>', {
    status, headers: { 'content-type': 'text/html' }
  });

/** Instala un fetch falso y devuelve el doble para programarlo. */
export function instalarFetchFalso() {
  const falso = jest.fn();
  globalThis.fetch = falso;
  return falso;
}

Usar Response de verdad —disponible de forma nativa en Node moderno— en lugar de un objeto inventado { ok: true, json: () => … } es una decisión importante: la Response real tiene ok calculado a partir de status, cabeceras que se consultan igual que en el navegador y un cuerpo que solo se puede leer una vez. Un objeto casero se comporta distinto en los bordes y da falsos verdes.

Y las pruebas:

// pruebas/datos/api-tareas.test.js
import { jest } from '@jest/globals';
import { listarTareas, crearTarea, actualizarTarea, borrarTarea } from '../../js/datos/api-tareas.js';
import { Tarea } from '../../js/modelo/tarea.js';
import { ErrorDeApi } from '../../js/modelo/errores.js';
import { datosBacklog } from '../../js/datos/backlog.js';
import { respuestaJson, respuestaVacia, instalarFetchFalso } from '../ayudas/red-falsa.js';

describe('api-tareas', () => {
  let red;
  const fetchOriginal = globalThis.fetch;

  beforeEach(() => { red = instalarFetchFalso(); });
  afterEach(() => { globalThis.fetch = fetchOriginal; });   // ← devolver el mundo como estaba

  describe('listarTareas', () => {
    test('devuelve instancias de Tarea, no objetos planos', async () => {
      red.mockResolvedValue(respuestaJson(datosBacklog));

      const tareas = await listarTareas();

      expect(tareas).toHaveLength(6);
      expect(tareas[0]).toBeInstanceOf(Tarea);              // ← la frontera hace su trabajo
      expect(tareas[0].titulo).toBe('Rediseñar la sala polivalente');
    });

    test('el tablero construido con la respuesta da los números canónicos', async () => {
      red.mockResolvedValue(respuestaJson(datosBacklog));

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

      expect(tablero.resumen('2026-09-20')).toMatchObject({ horasAbiertas: 45, esfuerzo: 124 });
    });

    test('añade los filtros como parámetros de consulta y omite los vacíos', async () => {
      red.mockResolvedValue(respuestaJson([]));

      await listarTareas({ responsable: 'Iván', estado: 'pendiente', texto: '' });

      const url = new URL(red.mock.calls[0][0]);
      expect(url.pathname).toBe('/v1/tareas');
      expect(url.searchParams.get('responsable')).toBe('Iván');
      expect(url.searchParams.get('estado')).toBe('pendiente');
      expect(url.searchParams.has('texto')).toBe(false);    // los vacíos NO se envían
    });
  });

  describe('crearTarea', () => {
    test('envía POST con el cuerpo serializado y devuelve la Tarea con su id', async () => {
      const nueva = { titulo: 'Revisar extintores', responsable: 'Marta',
                      prioridad: 'media', horasEstimadas: 2,
                      fechaLimite: '2026-10-20', etiquetas: ['seguridad'] };
      red.mockResolvedValue(respuestaJson({ ...nueva, id: 7, estado: 'pendiente' }, { status: 201 }));

      const creada = await crearTarea(nueva);

      // Comportamiento: devuelve una Tarea con el id que asignó el servidor (R1)
      expect(creada).toBeInstanceOf(Tarea);
      expect(creada.id).toBe(7);
      expect(creada.estado).toBe('pendiente');              // R5

      // Interacción: el método y el cuerpo SÍ son comportamiento observable
      const [, opciones] = red.mock.calls[0];
      expect(opciones.method).toBe('POST');
      expect(JSON.parse(opciones.body)).toMatchObject({ titulo: 'Revisar extintores' });
      expect(opciones.headers['Content-Type']).toBe('application/json');
    });
  });

  describe('borrarTarea', () => {
    test('envía DELETE y acepta una respuesta 204 sin cuerpo', async () => {
      red.mockResolvedValue(respuestaVacia());

      await expect(borrarTarea(3)).resolves.toBe(true);

      expect(red).toHaveBeenCalledWith(
        expect.stringContaining('/tareas/3'),
        expect.objectContaining({ method: 'DELETE' })
      );
    });
  });
});

Fíjate en el equilibrio: la mayoría de las aserciones son sobre el resultado (stub), y solo se comprueba la interacción (mock) cuando esa interacción es el comportamiento —que un borrado use DELETE y no PATCH importa de verdad—.

  1. Los cuatro escenarios de red que hay que cubrir

Con la red simulada, provocar fallos es trivial. Y estos son los que hay que probar, porque son los que ocurren:

describe('api-tareas · manejo de errores (07-03)', () => {
  let red;
  beforeEach(() => { red = instalarFetchFalso(); });

  test('un 404 produce ErrorDeApi con status 404 y código de cliente', async () => {
    red.mockResolvedValue(respuestaJson({ mensaje: 'No existe la tarea 99' }, { status: 404 }));

    const error = await capturarAsync(() => obtenerTarea(99));

    expect(error).toBeInstanceOf(ErrorDeApi);
    expect(error.status).toBe(404);
    expect(error.codigo).toBe('cliente');
    expect(error.reintentable).toBe(false);          // ← un 404 NO se reintenta
    expect(error.message).toBe('No existe la tarea 99');
  });

  test('un 500 produce ErrorDeApi reintentable con código de servidor', async () => {
    red.mockResolvedValue(respuestaJson({ mensaje: 'Error interno' }, { status: 500 }));

    const error = await capturarAsync(() => listarTareas());

    expect(error.status).toBe(500);
    expect(error.codigo).toBe('servidor');
    expect(error.reintentable).toBe(true);           // ← un 500 SÍ
  });

  test('un fallo de transporte produce ErrorDeApi de red, sin status', async () => {
    red.mockRejectedValue(new TypeError('Failed to fetch'));   // red caída, DNS o CORS

    const error = await capturarAsync(() => listarTareas());

    expect(error).toBeInstanceOf(ErrorDeApi);
    expect(error.codigo).toBe('red');
    expect(error.status).toBe(0);
    expect(error.causa).toBeInstanceOf(TypeError);   // la causa original se conserva
  });

  test('una respuesta HTML en lugar de JSON produce ErrorDeApi de formato', async () => {
    red.mockResolvedValue(respuestaHtml(200));       // el proxy que devuelve el login

    const error = await capturarAsync(() => listarTareas());

    expect(error.codigo).toBe('formato');
    expect(error.message).toContain('text/html');
  });

  test('una cancelación produce ErrorDeApi con código cancelado', async () => {
    const abortError = new DOMException('The operation was aborted.', 'AbortError');
    red.mockRejectedValue(abortError);

    const error = await capturarAsync(() => listarTareas());

    expect(error.codigo).toBe('cancelado');
  });
});

Con la ayuda correspondiente:

// pruebas/ayudas/backlog-de-prueba.js — versión asíncrona de capturar()
export async function capturarAsync(fn) {
  try { await fn(); return null; } catch (error) { return error; }
}

Estas cinco pruebas cubren la tabla de siete fallos que abría 07-03, y cada una tarda menos de un milisegundo. Provocar un 500 real, un HTML inesperado de un proxy y una caída de DNS a voluntad y en la misma suite solo es posible con dobles.

  1. Simular el reloj: temporizadores falsos

El segundo gran enemigo. jest.useFakeTimers() sustituye setTimeout, setInterval, clearTimeout, Date y performance.now por versiones que controlas.

jest.useFakeTimers();          // a partir de aquí, el tiempo no pasa solo

jest.advanceTimersByTime(300); // avanzar 300 ms virtuales, al instante
jest.runAllTimers();           // ejecutar TODOS los temporizadores pendientes
jest.runOnlyPendingTimers();   // solo los actuales (evita bucles con setInterval)
jest.advanceTimersToNextTimer(); // saltar justo al siguiente

jest.useRealTimers();          // devolver el reloj de verdad
Método Cuándo usarlo
advanceTimersByTime(ms) Control fino: comprobar qué pasa antes y después del umbral
runAllTimers() Cuando solo importa el estado final
runOnlyPendingTimers() Con setInterval o temporizadores que se reprograman
advanceTimersByTimeAsync(ms) Cuando entre medias hay await: el que necesitarás con promesas

Ese último merece atención. Avanzar el reloj ejecuta los callbacks de los temporizadores, pero las microtareas de las promesas encoladas por esos callbacks no se procesan hasta que se ceda el control (05-07). La versión ...Async cede, y es la que evita el clásico «avancé el reloj y la promesa sigue pendiente».

  1. Probar el debounce sin esperar

Con temporizadores falsos, js/util/tiempo.js se prueba en microsegundos:

// pruebas/util/tiempo.test.js
import { jest } from '@jest/globals';
import { debounce } from '../../js/util/tiempo.js';

describe('debounce', () => {
  beforeEach(() => { jest.useFakeTimers(); });
  afterEach(() => { jest.useRealTimers(); });      // ← imprescindible: no dejar el reloj parado

  test('no ejecuta la función antes de que expire la espera', () => {
    const buscar = jest.fn();
    const conRetardo = debounce(buscar, 300);

    conRetardo('car');
    jest.advanceTimersByTime(299);

    expect(buscar).not.toHaveBeenCalled();
  });

  test('ejecuta una sola vez tras la espera completa', () => {
    const buscar = jest.fn();
    const conRetardo = debounce(buscar, 300);

    conRetardo('car');
    jest.advanceTimersByTime(300);

    expect(buscar).toHaveBeenCalledTimes(1);
    expect(buscar).toHaveBeenCalledWith('car');
  });

  test('ocho pulsaciones rápidas producen UNA sola llamada, con el último valor', () => {
    const buscar = jest.fn();
    const conRetardo = debounce(buscar, 300);

    // Iván teclea "carpintería" a 50 ms por letra
    for (const texto of ['c', 'ca', 'car', 'carp', 'carpi', 'carpin', 'carpint', 'carpinte']) {
      conRetardo(texto);
      jest.advanceTimersByTime(50);
    }

    expect(buscar).not.toHaveBeenCalled();          // aún no: cada tecla reinició la cuenta

    jest.advanceTimersByTime(300);                  // Iván para de teclear

    expect(buscar).toHaveBeenCalledTimes(1);
    expect(buscar).toHaveBeenCalledWith('carpinte'); // ← el ÚLTIMO valor, no el primero
  });

  test('una pausa larga entre pulsaciones produce dos llamadas', () => {
    const buscar = jest.fn();
    const conRetardo = debounce(buscar, 300);

    conRetardo('car');
    jest.advanceTimersByTime(400);      // pasa el umbral: primera llamada
    conRetardo('tinta');
    jest.advanceTimersByTime(400);      // segunda

    expect(buscar).toHaveBeenCalledTimes(2);
    expect(buscar).toHaveBeenNthCalledWith(1, 'car');
    expect(buscar).toHaveBeenNthCalledWith(2, 'tinta');
  });
});

Cuatro pruebas que documentan exactamente qué hace un debounce —incluido el caso límite del milisegundo 299— y que en tiempo real habrían tardado 1,4 segundos. Aquí tardan 20 milisegundos.

Fíjate en el afterEach con useRealTimers(). Sin él, el reloj falso se filtra a los ficheros siguientes y cualquier prueba que dependa del tiempo se queda colgada. Es el mismo principio de higiene del beforeEach de 08-03: deja el mundo como lo encontraste.

  1. Probar los reintentos con retroceso exponencial

conReintentos combina las tres dificultades a la vez: red, temporizadores y aleatoriedad (el jitter con Math.random()). Se resuelven las tres.

// pruebas/datos/http.test.js
import { jest } from '@jest/globals';
import { conReintentos } from '../../js/datos/http.js';
import { ErrorDeApi } from '../../js/modelo/errores.js';

const errorServidor = () => new ErrorDeApi('Caído', { status: 503, codigo: 'servidor' });
const errorCliente  = () => new ErrorDeApi('No existe', { status: 404, codigo: 'cliente' });

describe('conReintentos', () => {
  beforeEach(() => {
    jest.useFakeTimers();
    jest.spyOn(Math, 'random').mockReturnValue(0.5);   // jitter DETERMINISTA
  });

  afterEach(() => {
    jest.useRealTimers();
    jest.restoreAllMocks();                            // devuelve Math.random
  });

  test('no reintenta cuando la operación tiene éxito a la primera', async () => {
    const operacion = jest.fn().mockResolvedValue('ok');

    await expect(conReintentos(operacion)).resolves.toBe('ok');

    expect(operacion).toHaveBeenCalledTimes(1);
  });

  test('reintenta un 503 y devuelve el resultado del tercer intento', async () => {
    const operacion = jest.fn()
      .mockRejectedValueOnce(errorServidor())
      .mockRejectedValueOnce(errorServidor())
      .mockResolvedValue('recuperado');

    const promesa = conReintentos(operacion, { intentos: 3, baseMs: 300 });

    await jest.advanceTimersByTimeAsync(300 + 150);    // 1ª espera: 300 + jitter(0.5 × 300 × 0.3)
    await jest.advanceTimersByTimeAsync(600 + 270);    // 2ª espera: exponencial

    await expect(promesa).resolves.toBe('recuperado');
    expect(operacion).toHaveBeenCalledTimes(3);
  });

  test('NO reintenta un 404: no tiene remedio', async () => {
    const operacion = jest.fn().mockRejectedValue(errorCliente());

    await expect(conReintentos(operacion, { intentos: 3 })).rejects.toMatchObject({ status: 404 });

    expect(operacion).toHaveBeenCalledTimes(1);        // ← una sola vez
  });

  test('se rinde tras agotar los intentos y propaga el último error', async () => {
    const operacion = jest.fn().mockRejectedValue(errorServidor());

    const promesa = conReintentos(operacion, { intentos: 3, baseMs: 300 });
    const resultado = promesa.catch((e) => e);         // capturamos ya, para no dejarla colgando

    await jest.runAllTimersAsync();

    await expect(resultado).resolves.toMatchObject({ status: 503 });
    expect(operacion).toHaveBeenCalledTimes(3);
  });

  test('la espera crece de forma exponencial entre intentos', async () => {
    const esperas = [];
    jest.spyOn(globalThis, 'setTimeout').mockImplementation((fn, ms) => { esperas.push(ms); fn(); });

    const operacion = jest.fn().mockRejectedValue(errorServidor());
    await conReintentos(operacion, { intentos: 4, baseMs: 100 }).catch(() => {});

    // 100·1 + jitter, 100·2 + jitter, 100·4 + jitter — cada una mayor que la anterior
    expect(esperas).toHaveLength(3);
    expect(esperas[1]).toBeGreaterThan(esperas[0]);
    expect(esperas[2]).toBeGreaterThan(esperas[1]);
  });
});

Tres técnicas juntas en este bloque, y las tres se reutilizan constantemente:

  • jest.spyOn(Math, 'random').mockReturnValue(0.5) hace determinista el jitter. Cualquier aleatoriedad en el código bajo prueba se doma así.
  • advanceTimersByTimeAsync avanza el reloj y procesa las microtareas pendientes; con la versión síncrona, la promesa del await dormir(...) no llegaría a resolverse.
  • Capturar el rechazo antes de avanzar el reloj (promesa.catch((e) => e)) evita que Node avise de un rechazo sin manejar durante el intervalo en que la promesa aún no se ha esperado.

La última prueba merece un comentario: comprueba que las esperas crecen, no que valgan exactamente 100, 200 y 400. Afirmar los valores exactos ataría la prueba a la fórmula concreta del jitter, que es un detalle de implementación. Comprobar la propiedad —«cada espera es mayor que la anterior»— captura la intención real del retroceso exponencial y sobrevive a un ajuste de la fórmula. Esa distinción, propiedad frente a valor exacto, es una de las decisiones más valiosas al escribir pruebas.

  1. Congelar la fecha: HOY sin sorpresas

El proyecto tuvo la precaución de que hoy entrara como parámetro, así que la mayoría de las pruebas no necesitan tocar el reloj. Pero hay código que no lo permite —cualquier función que llame a new Date() internamente—, y ahí se congela el calendario:

describe('vencimiento con el reloj congelado', () => {
  beforeEach(() => {
    // El sistema entero cree que es el 20 de septiembre de 2026, a las 9:00
    jest.useFakeTimers({ now: new Date('2026-09-20T09:00:00Z') });
  });

  afterEach(() => { jest.useRealTimers(); });

  test('estaVencida usa HOY por defecto y da el mismo resultado cualquier día', () => {
    const tarea = unaTarea({ fechaLimite: '2026-09-05', estado: 'pendiente' });

    expect(tarea.estaVencida()).toBe(true);           // sin pasar fecha
  });

  test('fechaDeHoy() devuelve la fecha congelada', () => {
    expect(new Date().toISOString().slice(0, 10)).toBe('2026-09-20');
  });

  test('el resumen sin fecha explícita da una sola vencida', () => {
    expect(unTablero().resumen()).toMatchObject({ vencidas: 1 });
  });
});

Y una nota de diseño que conviene subrayar: si tu código admite recibir la fecha como parámetro, prefiérelo a congelar el reloj. Congelar el reloj es una intervención global que afecta a todo lo que se ejecute, incluido código de librerías. Pasar hoy como dato es explícito, local y no tiene efectos colaterales. El reloj falso es el plan B para cuando no puedes cambiar el código; el parámetro es el plan A, y es la razón por la que estaVencida(fechaLimite, estado, hoy = HOY) se escribió así en 05-04.

  1. Simular localStorage con un doble en memoria

En Node no existe localStorage. Es el motivo de que repositorio-local.js estuviera al 31 % de cobertura. Y aquí se cobra otra decisión de diseño de 07-01: el constructor acepta un almacen.

// pruebas/ayudas/almacen-falso.js

/**
 * FAKE de Web Storage: implementación real, simplificada, en memoria.
 * Cumple la interfaz que usa RepositorioLocal, más un modo "lleno" para
 * poder provocar el QuotaExceededError sin llenar un disco de verdad.
 */
export function almacenFalso({ inicial = {}, lleno = false } = {}) {
  const mapa = new Map(Object.entries(inicial));

  return {
    getItem: (clave) => (mapa.has(clave) ? mapa.get(clave) : null),

    setItem: (clave, valor) => {
      if (lleno) {
        const error = new Error('Cuota superada');
        error.name = 'QuotaExceededError';           // ← lo que comprueba el repositorio
        throw error;
      }
      mapa.set(clave, String(valor));                // Web Storage SOLO guarda cadenas
    },

    removeItem: (clave) => { mapa.delete(clave); },
    clear: () => mapa.clear(),
    key: (i) => [...mapa.keys()][i] ?? null,
    get length() { return mapa.size; },

    // Utilidades solo para las pruebas
    _volcar: () => Object.fromEntries(mapa)
  };
}

Ese String(valor) es lo que hace que el doble sea fiel: si el falso admitiera objetos, una prueba pasaría con setItem('x', {a: 1}) y en el navegador se guardaría la cadena "[object Object]". Un doble que es más permisivo que el original produce falsos verdes. La fidelidad de un fake es su única virtud; en cuanto se separa del original, miente.

Las pruebas:

// pruebas/datos/repositorio-local.test.js
import { jest } from '@jest/globals';
import { RepositorioLocal } from '../../js/datos/repositorio-local.js';
import { almacenFalso } from '../ayudas/almacen-falso.js';
import { unTablero } from '../ayudas/backlog-de-prueba.js';

describe('RepositorioLocal', () => {
  test('guarda y recupera el tablero completo', () => {
    const repositorio = new RepositorioLocal({ almacen: almacenFalso() });
    const tablero = unTablero();

    expect(repositorio.guardar(tablero)).toBe(true);
    const recuperado = repositorio.cargar();

    expect(recuperado.total).toBe(6);
    expect(recuperado.resumen('2026-09-20')).toMatchObject({ horasAbiertas: 45, esfuerzo: 124 });
  });

  test('guarda bajo la clave versionada del proyecto', () => {
    const almacen = almacenFalso();
    new RepositorioLocal({ almacen }).guardar(unTablero());

    expect(Object.keys(almacen._volcar())).toEqual(['nomada:tablero:v1']);
  });

  test('devuelve null si no hay nada guardado', () => {
    expect(new RepositorioLocal({ almacen: almacenFalso() }).cargar()).toBeNull();
  });

  test('descarta datos corruptos sin lanzar y avisa por consola', () => {
    const avisos = jest.spyOn(console, 'warn').mockImplementation(() => {});
    const almacen = almacenFalso({ inicial: { 'nomada:tablero:v1': '{{{ no es JSON' } });

    expect(new RepositorioLocal({ almacen }).cargar()).toBeNull();
    expect(avisos).toHaveBeenCalledWith(expect.stringContaining('corruptos'));

    avisos.mockRestore();
  });

  test('sin espacio devuelve false, no lanza: perder persistencia no tumba la app', () => {
    const avisos = jest.spyOn(console, 'warn').mockImplementation(() => {});
    const repositorio = new RepositorioLocal({ almacen: almacenFalso({ lleno: true }) });

    expect(repositorio.guardar(unTablero())).toBe(false);   // ← degrada, no explota

    avisos.mockRestore();
  });

  test('limpiar elimina la clave y deja el almacén vacío', () => {
    const almacen = almacenFalso();
    const repositorio = new RepositorioLocal({ almacen });

    repositorio.guardar(unTablero());
    repositorio.limpiar();

    expect(repositorio.cargar()).toBeNull();
    expect(almacen.length).toBe(0);
  });
});

De 31 % a cobertura casi completa, incluidas las dos ramas defensivas —datos corruptos y cuota agotada— que en un navegador real son dificilísimas de provocar a mano.

La alternativa jest-environment-jsdom. Si configuras testEnvironment: 'jsdom', obtienes un localStorage global implementado por jsdom, y el código funciona sin inyectar nada. Es la vía que usarás en 08-05 para las pruebas de vista. Para pruebas unitarias de la capa de datos, el doble inyectado es preferible: es explícito, no arrastra un DOM entero que no necesitas, y sobre todo permite provocar el QuotaExceededError, cosa que jsdom no ofrece.

  1. Inyección de dependencias frente al mockeo agresivo

Llevas toda la lección viendo dos caminos para el mismo problema, y merecen compararse de frente.

// ── Camino A · Mockeo agresivo: la dependencia está importada dentro ──
// js/datos/sincronizador.js
import { listarTareas } from './api-tareas.js';

export async function sincronizar(tablero) {
  const remotas = await listarTareas();
  return tablero.importar(remotas);
}

// La prueba necesita interceptar el sistema de módulos
jest.unstable_mockModule('../../js/datos/api-tareas.js', () => ({ listarTareas: jest.fn() }));
const { listarTareas } = await import('../../js/datos/api-tareas.js');
const { sincronizar } = await import('../../js/datos/sincronizador.js');
listarTareas.mockResolvedValue(crearBacklog());
// ── Camino B · Inyección: la dependencia entra por parámetro ──────────
// js/datos/sincronizador.js
import { listarTareas } from './api-tareas.js';

export async function sincronizar(tablero, { listar = listarTareas } = {}) {
  const remotas = await listar();
  return tablero.importar(remotas);
}

// La prueba, sin magia
const listar = jest.fn().mockResolvedValue(crearBacklog());
await sincronizar(tablero, { listar });

Fíjate en el detalle del camino B: el valor por defecto hace que el código de producción no cambie ni una línea (sincronizar(tablero) sigue funcionando igual), y a la vez abre la puerta a la prueba. Es la misma técnica del hoy = HOY de estaVencida y del almacen de RepositorioLocal.

Inyección de dependencias Mockeo de módulos
Cambios en el código de producción Sí: un parámetro más Ninguno
Complejidad de la prueba Baja: es JavaScript normal Media-alta: orden e importaciones dinámicas
Acoplamiento al ejecutor Ninguno Alto: depende de la API de Jest
Legibilidad de la dependencia Explícita en la firma Oculta en los import
Utilidad fuera de las pruebas Alta: permite variantes reales Nula
Cuando la dependencia es global (fetch) No aplica directamente Necesario
Cuando el módulo es de terceros Difícil Necesario

Recomendación práctica, en orden de preferencia:

  1. Haz la función pura si puedes. Sin dependencia, no hay nada que simular.
  2. Inyecta la dependencia con un valor por defecto. Coste: un parámetro. Beneficio: pruebas triviales y un diseño más flexible.
  3. Sustituye la global (globalThis.fetch) cuando la dependencia sea del entorno.
  4. Simula el módulo solo cuando no controles el código o la dependencia sea profunda.

Y una señal de alarma que conviene reconocer: si una prueba necesita cuatro jest.mock para arrancar, el problema no es la prueba, es el diseño del módulo. Cuatro dependencias simuladas significan cuatro responsabilidades acopladas. La dificultad de probar sigue siendo el detector de mal diseño del que hablaba 08-03.

  1. Pruebas acopladas a la implementación

El peligro final, y el que arruina más suites a medio plazo.

// ❌ Esta prueba no comprueba NADA útil
test('cambiarEstado funciona', () => {
  const tablero = unTablero();
  const buscar = jest.spyOn(tablero, 'buscarPorId');
  const cambiar = jest.spyOn(Tarea.prototype, 'cambiarEstado');

  tablero.cambiarEstado(2, 'en-curso');

  expect(buscar).toHaveBeenCalledWith(2);
  expect(cambiar).toHaveBeenCalledWith('en-curso');
});

Esa prueba afirma que cambiarEstado llama a otros métodos. No comprueba en ningún momento que la tarea 2 acabe en 'en-curso'. Consecuencias:

  • Pasa aunque el resultado esté mal. Si cambiarEstado llamara a los dos métodos y luego revirtiera el cambio, seguiría verde.
  • Falla cuando nada está roto. Si mañana cambiarEstado usa un Map interno en vez de buscarPorId, la prueba se pone roja con el comportamiento intacto.
  • Impide refactorizar, que era precisamente el beneficio número uno de tener pruebas. Una suite así convierte la red de seguridad en una camisa de fuerza.
// ✅ Comprueba el comportamiento observable
test('cambiarEstado deja la tarea en el estado indicado y recalcula las horas', () => {
  const tablero = unTablero();

  tablero.cambiarEstado(1, 'hecha');            // tarea 1: 12 h de Iván

  expect(tablero.buscarPorId(1).estado).toBe('hecha');
  expect(tablero.horasAbiertas).toBe(33);       // 45 − 12
});

La regla: afirma sobre lo que el módulo promete, no sobre cómo lo cumple.

Y la excepción, que también es importante: hay interacciones que son el comportamiento observable, y esas sí se comprueban con mocks.

Comprobar la interacción está justificado Comprobar la interacción es un error
Que un borrado envíe DELETE y no PATCH Que resumen() llame internamente a filter
Que no se llame a la API cuando la validación falla Que un getter use reduce y no un for
Que un AbortController se cancele al desmontar Que se llame a un método privado
Que se emita EVENTOS.TAREA_CAMBIADA con su detail Cuántas veces se llama a un ayudante interno
Que no se persista más de una vez por cambio El orden interno de dos operaciones sin efecto observable

El criterio para distinguirlas: ¿alguien fuera del módulo notaría la diferencia? Si el servidor recibe PATCH en lugar de DELETE, sí. Si resumen() cambia un reduce por un bucle, no.

Errores Comunes y Consejos

  • Olvidar import { jest } from '@jest/globals' con módulos ES. Da ReferenceError: jest is not defined y despista mucho, porque en todos los tutoriales de CommonJS no hace falta.
  • No restaurar los dobles entre pruebas. Un console.warn silenciado o un fetch sustituido que sobrevive contamina toda la suite. Usa restoreMocks: true y clearMocks: true en la configuración.
  • Olvidar jest.useRealTimers() en el afterEach. El reloj falso se filtra al fichero siguiente y una prueba se cuelga sin explicación.
  • Usar advanceTimersByTime cuando hay promesas por medio. Los temporizadores se ejecutan pero las microtareas no; la promesa sigue pendiente. Usa la variante ...Async.
  • Fabricar respuestas caseras del estilo { ok: true, json: () => datos }. Se comportan distinto de una Response real en los bordes (status, ok derivado, cuerpo consumible una vez) y producen falsos verdes. Usa new Response(...).
  • Simular lo que no estorba. Un módulo puro y rápido como util/formato.js no se simula: solo añades una capa que puede desincronizarse con la realidad.
  • Escribir un fake más permisivo que el original. Un localStorage falso que admita objetos deja pasar código que en el navegador guardaría "[object Object]".
  • Comprobar solo que se llamó a un mock. Una prueba que no afirma nada sobre el resultado no protege el comportamiento: pasa aunque el resultado sea incorrecto, y falla en cuanto refactorizas.
  • Dejar una promesa rechazada sin capturar mientras se avanza el reloj. Node avisa de unhandledRejection y ensucia (o rompe) la suite. Captura antes de avanzar.
  • Consejo: si el doble es complicado, mira el diseño. Cuatro jest.mock para arrancar una prueba señalan cuatro responsabilidades acopladas.
  • Consejo: pon los dobles en pruebas/ayudas/. red-falsa.js, almacen-falso.js y backlog-de-prueba.js se comparten entre ficheros, se prueban una vez y evitan diez copias divergentes.
  • Consejo: prefiere afirmar propiedades a valores exactos cuando hay aleatoriedad o fórmulas ajustables: «la espera crece» sobrevive a un cambio de fórmula; «la espera vale 430 ms», no.

Ejercicios

Ejercicio 1 — Probar pedirJson con los siete fallos de 07-03. Escribe pruebas/datos/http.test.js con la batería completa de pedirJson, cubriendo los siete escenarios de la tabla que abría 07-03: red caída (TypeError), timeout (TimeoutError), cancelación (AbortError), 4xx, 5xx, respuesta no-JSON y respuesta 204 sin cuerpo. Para cada uno comprueba el codigo, el status y si es reintentable. Crea una ayuda pruebas/ayudas/red-falsa.js reutilizable y explica por qué usas new Response(...) en lugar de un objeto literal.

Ejercicio 2 — Un doble para CanalTablero. js/datos/tiempo-real.js expone CanalTablero extends EventTarget, que abre un WebSocket, reconecta con retroceso y emite eventos. Escribe un fake CanalFalso extends EventTarget que permita, desde la prueba, provocar mensajes entrantes (simularMensaje(datos)), caídas (simularCaida()) y reconexiones, sin abrir ningún socket. Con él, prueba que: (a) un mensaje tarea:actualizada con la tarea 2 en 'en-curso' actualiza el tablero; (b) un mensaje con un id desconocido se ignora sin lanzar; (c) tras una caída, los cambios locales se encolan y se envían al reconectar. Usa temporizadores falsos para la reconexión.

Ejercicio 3 — Reescribir una suite acoplada a la implementación. Esta suite pasa en verde y no protege nada. Identifica los cuatro problemas, explica qué fallo real dejaría pasar cada uno, y reescríbela para que compruebe comportamiento.

test('crearTarea funciona', async () => {
  const red = instalarFetchFalso();
  red.mockResolvedValue(respuestaJson({ id: 7 }, { status: 201 }));
  const construir = jest.spyOn(Tarea, 'desdeJSON');
  const serializar = jest.spyOn(JSON, 'stringify');

  await crearTarea({ titulo: 'Revisar extintores', horasEstimadas: 2 });

  expect(red).toHaveBeenCalled();
  expect(serializar).toHaveBeenCalled();
  expect(construir).toHaveBeenCalled();
});

Soluciones

Solución 1

// pruebas/datos/http.test.js
import { jest } from '@jest/globals';
import { pedirJson } from '../../js/datos/http.js';
import { ErrorDeApi } from '../../js/modelo/errores.js';
import { instalarFetchFalso, respuestaJson, respuestaVacia, respuestaHtml } from '../ayudas/red-falsa.js';
import { capturarAsync } from '../ayudas/backlog-de-prueba.js';

// Usamos `new Response(...)` y no un objeto literal porque la Response real
// deriva `ok` de `status`, expone `headers` con la API de Headers y permite
// leer el cuerpo UNA sola vez. Un objeto casero es más permisivo que el
// original: dejaría pasar código que en el navegador fallaría.

describe('pedirJson', () => {
  let red;
  const fetchOriginal = globalThis.fetch;

  beforeEach(() => { red = instalarFetchFalso(); });
  afterEach(() => { globalThis.fetch = fetchOriginal; });

  test('devuelve el JSON en una respuesta 200 correcta', async () => {
    red.mockResolvedValue(respuestaJson({ tareas: 6 }));

    await expect(pedirJson('/tareas')).resolves.toEqual({ tareas: 6 });
  });

  test.each`
    caso                  | error                                                          | codigo         | reintentable
    ${'red caída'}        | ${new TypeError('Failed to fetch')}                             | ${'red'}       | ${true}
    ${'timeout'}          | ${Object.assign(new Error('t'), { name: 'TimeoutError' })}      | ${'timeout'}   | ${true}
    ${'cancelación'}      | ${Object.assign(new Error('a'), { name: 'AbortError' })}        | ${'cancelado'} | ${false}
  `('$caso → ErrorDeApi con código $codigo', async ({ error, codigo, reintentable }) => {
    red.mockRejectedValue(error);

    const capturado = await capturarAsync(() => pedirJson('/tareas'));

    expect(capturado).toBeInstanceOf(ErrorDeApi);
    expect(capturado.codigo).toBe(codigo);
    expect(capturado.status).toBe(0);
    expect(capturado.reintentable).toBe(reintentable);
    expect(capturado.causa).toBe(error);            // la causa original se conserva
  });

  test.each`
    status | codigo         | reintentable | motivo
    ${400} | ${'cliente'}   | ${false}     | ${'datos mal enviados'}
    ${401} | ${'cliente'}   | ${false}     | ${'sesión caducada'}
    ${404} | ${'cliente'}   | ${false}     | ${'no existe'}
    ${408} | ${'cliente'}   | ${true}      | ${'timeout del servidor: sí se reintenta'}
    ${429} | ${'cliente'}   | ${true}      | ${'demasiadas peticiones: sí se reintenta'}
    ${500} | ${'servidor'}  | ${true}      | ${'error interno'}
    ${503} | ${'servidor'}  | ${true}      | ${'servicio no disponible'}
  `('un $status da código $codigo, reintentable: $reintentable ($motivo)',
    async ({ status, codigo, reintentable }) => {
      red.mockResolvedValue(respuestaJson({ mensaje: `Error ${status}` }, { status }));

      const error = await capturarAsync(() => pedirJson('/tareas'));

      expect(error.status).toBe(status);
      expect(error.codigo).toBe(codigo);
      expect(error.reintentable).toBe(reintentable);
      expect(error.message).toBe(`Error ${status}`);   // usa el mensaje del servidor
    });

  test('una respuesta HTML en lugar de JSON da código de formato', async () => {
    red.mockResolvedValue(respuestaHtml(200));

    const error = await capturarAsync(() => pedirJson('/tareas'));

    expect(error.codigo).toBe('formato');
    expect(error.message).toContain('text/html');
    expect(error.reintentable).toBe(false);
  });

  test('un 204 sin cuerpo devuelve null en lugar de lanzar', async () => {
    red.mockResolvedValue(respuestaVacia());

    await expect(pedirJson('/tareas/3', { method: 'DELETE' })).resolves.toBeNull();
  });

  test('un cuerpo JSON malformado da código de formato, no un SyntaxError suelto', async () => {
    red.mockResolvedValue(new Response('{{{ roto', {
      status: 200, headers: { 'content-type': 'application/json' }
    }));

    const error = await capturarAsync(() => pedirJson('/tareas'));

    expect(error).toBeInstanceOf(ErrorDeApi);        // nunca escapa un SyntaxError crudo
    expect(error.codigo).toBe('formato');
  });
});

Solución 2

// pruebas/ayudas/canal-falso.js
/**
 * FAKE de CanalTablero: misma interfaz (EventTarget + enviar/cerrar), cero sockets.
 * Añade utilidades _simular* que solo existen para las pruebas.
 */
export class CanalFalso extends EventTarget {
  enviados = [];
  cola = [];
  conectado = true;
  intentosDeReconexion = 0;

  enviar(tipo, datos) {
    if (!this.conectado) { this.cola.push({ tipo, datos }); return false; }
    this.enviados.push({ tipo, datos });
    return true;
  }

  cerrar() { this.conectado = false; }

  // ── Utilidades de prueba ────────────────────────────────────────────
  simularMensaje(tipo, datos) {
    this.dispatchEvent(new CustomEvent(tipo, { detail: datos }));
  }

  simularCaida() {
    this.conectado = false;
    this.dispatchEvent(new CustomEvent('canal:cerrado'));
  }

  simularReconexion() {
    this.conectado = true;
    this.intentosDeReconexion += 1;
    for (const pendiente of this.cola.splice(0)) this.enviar(pendiente.tipo, pendiente.datos);
    this.dispatchEvent(new CustomEvent('canal:abierto'));
  }
}
// pruebas/datos/tiempo-real.test.js
import { jest } from '@jest/globals';
import { conectarTiempoReal } from '../../js/datos/tiempo-real.js';
import { CanalFalso } from '../ayudas/canal-falso.js';
import { unTablero } from '../ayudas/backlog-de-prueba.js';

describe('sincronización en tiempo real', () => {
  let canal, tablero;

  beforeEach(() => {
    jest.useFakeTimers();
    canal = new CanalFalso();
    tablero = unTablero();
    conectarTiempoReal(tablero, { canal });          // ← inyección, no mockeo de módulo
  });

  afterEach(() => { jest.useRealTimers(); });

  test('(a) un mensaje tarea:actualizada aplica el cambio al tablero', () => {
    canal.simularMensaje('tarea:actualizada', { id: 2, estado: 'en-curso' });

    expect(tablero.buscarPorId(2).estado).toBe('en-curso');
    expect(tablero.horasAbiertas).toBe(45);          // el estado abierto no cambia
  });

  test('(b) un mensaje con un id desconocido se ignora sin lanzar', () => {
    expect(() => canal.simularMensaje('tarea:actualizada', { id: 999, estado: 'hecha' }))
      .not.toThrow();

    expect(tablero.total).toBe(6);                   // nada se ha añadido ni roto
  });

  test('(c) los cambios durante una caída se encolan y se envían al reconectar', () => {
    canal.simularCaida();

    tablero.cambiarEstado(2, 'en-curso');            // cambio local mientras no hay red

    expect(canal.enviados).toHaveLength(0);
    expect(canal.cola).toHaveLength(1);

    canal.simularReconexion();

    expect(canal.cola).toHaveLength(0);
    expect(canal.enviados).toHaveLength(1);
    expect(canal.enviados[0]).toMatchObject({ tipo: 'tarea:cambiada', datos: { id: 2 } });
  });

  test('la reconexión respeta el retroceso exponencial', async () => {
    canal.simularCaida();

    await jest.advanceTimersByTimeAsync(1000);
    expect(canal.intentosDeReconexion).toBe(0);      // aún no toca

    canal.simularReconexion();
    expect(canal.intentosDeReconexion).toBe(1);
  });
});

Solución 3

Los cuatro problemas:

# Problema Qué fallo real dejaría pasar
1 expect(red).toHaveBeenCalled() sin comprobar con qué Que se envíe GET en lugar de POST, o a la URL equivocada
2 Espiar JSON.stringify Detalle interno absoluto. Pasaría aunque el cuerpo enviado estuviera vacío o mal formado
3 Espiar Tarea.desdeJSON Igual: comprueba cómo se construye, no que se devuelva una Tarea con el id 7
4 Ninguna aserción sobre el valor devuelto Que crearTarea devuelva undefined, o la respuesta cruda en vez de una Tarea

En conjunto: la prueba pasaría con una implementación que llamara a fetch, serializara algo y construyera una tarea… y devolviera null. Y fallaría si un refactor sustituyera JSON.stringify por otra forma de serializar, sin que nada estuviera roto.

// Reescrita: comprueba comportamiento, y solo la interacción que es observable
test('crearTarea envía POST con el cuerpo correcto y devuelve la Tarea con su id', async () => {
  const red = instalarFetchFalso();
  const nueva = { titulo: 'Revisar extintores', responsable: 'Marta', prioridad: 'media',
                  horasEstimadas: 2, fechaLimite: '2026-10-20', etiquetas: ['seguridad'] };
  red.mockResolvedValue(respuestaJson({ ...nueva, id: 7, estado: 'pendiente' }, { status: 201 }));

  const creada = await crearTarea(nueva);

  // 1 · El resultado: lo que promete la función
  expect(creada).toBeInstanceOf(Tarea);
  expect(creada.id).toBe(7);                    // R1: el id lo asigna el servidor
  expect(creada.estado).toBe('pendiente');      // R5
  expect(creada.titulo).toBe('Revisar extintores');

  // 2 · La interacción, SOLO donde es comportamiento observable por el servidor
  const [url, opciones] = red.mock.calls[0];
  expect(String(url)).toContain('/tareas');
  expect(opciones.method).toBe('POST');
  expect(opciones.headers['Content-Type']).toBe('application/json');
  expect(JSON.parse(opciones.body)).toMatchObject({
    titulo: 'Revisar extintores', horasEstimadas: 2
  });
});

test('crearTarea propaga un 422 de validación como ErrorDeApi de cliente', async () => {
  const red = instalarFetchFalso();
  red.mockResolvedValue(respuestaJson({ mensaje: 'Faltan horas' }, { status: 422 }));

  const error = await capturarAsync(() => crearTarea({ titulo: 'Sin horas' }));

  expect(error).toBeInstanceOf(ErrorDeApi);
  expect(error.status).toBe(422);
  expect(error.reintentable).toBe(false);
  expect(error.message).toBe('Faltan horas');
});

Conclusión

Las cuatro dependencias que estropean una prueba —red, reloj, almacenamiento y aleatoriedad— han dejado de ser un obstáculo. Sabes qué es un doble de prueba y manejas el vocabulario con precisión: el dummy que solo rellena un hueco, el stub que devuelve respuestas fijas, el spy que observa dejando pasar la llamada real, el mock que además impone expectativas sobre cómo se le llama, y el fake que es una implementación real simplificada. Y tienes clara la distinción operativa que importa: con un stub afirmas sobre el resultado, con un mock sobre la interacción, y por defecto se prefiere lo primero.

Dominas jest.fn() con su registro completo (mock.calls, mock.lastCall, mock.results), sus matchers (toHaveBeenCalledWith, toHaveBeenCalledTimes, toHaveBeenNthCalledWith) y las comparaciones asimétricas —expect.objectContaining, expect.stringContaining, expect.any— que evitan atar una prueba a un objeto completo. Programas respuestas con mockReturnValue, mockResolvedValue, mockRejectedValue, mockImplementation y, sobre todo, con las variantes ...Once que permiten simular secuencias: dos 503 seguidos de un éxito, algo imposible de reproducir contra un servidor real. Distingues mockClear, mockReset y mockRestore, y sabes que restoreMocks y clearMocks en la configuración eliminan una familia entera de fallos entre pruebas. Usas jest.spyOn para observar sin sustituir —comprobando que el guardado real ocurre— y para silenciar un console.warn sin perder la aserción de que se emitió. Y conoces la mecánica de simular módulos con ESM, con unstable_mockModule antes de cualquier importación y await import después, además de la simulación parcial que conserva el resto del módulo intacto.

Nómada Tareas tiene ahora cubierto lo que faltaba. api-tareas.js se prueba sin red, con Response reales y no objetos caseros, verificando que devuelve instancias de Tarea, que omite los filtros vacíos de la URL, que envía POST con el cuerpo correcto y que los cinco escenarios de fallo —404, 500, TypeError de transporte, HTML inesperado y cancelación— producen ErrorDeApi con su status, su codigo y su reintentable exactos. El debounce se prueba en microsegundos con temporizadores falsos, incluido el caso límite del milisegundo 299 y las ocho pulsaciones de Iván tecleando «carpintería» que deben producir una llamada con el último valor. Los reintentos con retroceso exponencial se comprueban con advanceTimersByTimeAsync para que las microtareas del await se procesen, con Math.random fijado a 0,5 para domar el jitter, afirmando la propiedad («cada espera es mayor que la anterior») en lugar de valores exactos que se romperían al ajustar la fórmula. La fecha se congela cuando hace falta, aunque el plan A sigue siendo el parámetro hoy = HOY que 05-04 tuvo la precaución de dejar. Y localStorage tiene su fake en memoria, fiel hasta el String(valor) que impone Web Storage, capaz de provocar el QuotaExceededError que en un navegador real es dificilísimo de reproducir: repositorio-local.js pasa del 31 % a cobertura casi completa, incluidas sus dos ramas defensivas.

Y tienes las dos advertencias que separan una suite útil de una que estorba. La primera: la inyección de dependencias suele ser mejor que el mockeo agresivo. Un parámetro con valor por defecto —{ listar = listarTareas } = {}, { almacen }, hoy = HOY— deja el código de producción intacto, hace la dependencia explícita en la firma y convierte la prueba en JavaScript normal, sin acoplarla a la API del ejecutor; simular un módulo se reserva para las globales del entorno y el código de terceros. La segunda: las pruebas que solo comprueban que se llamó a un mock no protegen nada. Pasan aunque el resultado sea incorrecto y fallan cuando refactorizas, convirtiendo la red de seguridad en una camisa de fuerza. Afirma sobre lo que el módulo promete; comprueba la interacción solo cuando alguien de fuera notaría la diferencia —que un borrado use DELETE, que no se llame a la API si la validación falla, que se emita TAREA_CAMBIADA con su detail—.

Con esto, las cuatro capas de Nómada Tareas tienen pruebas unitarias: modelo, utilidades, datos y red. Cada una aislada de todas las demás, que es precisamente la definición de una prueba unitaria… y también su punto ciego. Porque todas estas pruebas dan por supuesto un contrato: que RepositorioLocal guarda exactamente lo que Tablero.importar sabe leer; que el JSON que produce toJSON() es el que desdeJSON espera recibir; que lo que TableroVista renderiza es lo que el controlador delegado de 06-04 sabe interpretar por su data-id. Cada pieza cumple su parte del contrato en su propia prueba, con dobles que responden justo lo que la prueba les dijo que respondieran… y nadie ha comprobado nunca que las piezas reales se entiendan entre sí. Ese hueco —los fallos que solo aparecen al juntar las cosas: formatos de fecha que no coinciden, versiones de esquema mal migradas, errores que nadie captura en la costura— es el territorio de Pruebas de Integración, donde montarás Tablero con RepositorioLocal de verdad, renderizarás la vista completa en un DOM sin navegador y probarás el recorrido entero de formulario a render.

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