El entorno ya respira: Vitest encuentra los ficheros, jsdom da un DOM y los matchers de jest-dom están registrados. Toca escribir pruebas de verdad, y esta lección lo hace deliberadamente fuera de React. No hay ni un render en todo el texto. El motivo es doble: primero, porque las herramientas del ejecutor —describe, los ganchos, las aserciones, los dobles de prueba, los temporizadores falsos— se entienden mucho mejor sin el ruido de un árbol de componentes; y segundo, porque la parte más rentable de CicloUrbano en relación coste-beneficio es precisamente la que no necesita React. validarReserva codifica todas las reglas de negocio de las reservas. El reductor sliceReservas codifica el ciclo de vida activa → confirmada / cancelada. Los selectores derivan el catálogo visible. Las tres cosas son funciones puras, y probar una función pura es llamarla y comparar. Eso es lo que se prometió en 05-05 y en 07-04, y esta lección lo cobra.

El título dice «con Jest», y hay una razón para respetarlo: Jest es el modelo mental del ecosistema. Todo lo que aprendas aquí se escribe con Vitest, que es lo que corresponde a un proyecto Vite, pero su API es la misma, así que sirve exactamente igual en los miles de proyectos que usan Jest. Empezaremos por dejar clara esa equivalencia.

Contenido

  1. Qué es un ejecutor de pruebas y qué hace por ti
  2. Jest y Vitest: el mismo modelo, dos implementaciones
  3. Configurar Jest en un proyecto que no usa Vite
  4. Anatomía de un fichero de pruebas: describe, test y los ganchos
  5. Aserciones: el catálogo de matchers
  6. toBe frente a toEqual: la diferencia que más errores causa
  7. La suite de validarReserva, regla por regla
  8. Casos límite y test.each
  9. Probar el reductor sliceReservas como función pura
  10. Probar los selectores
  11. Dobles de prueba: espías, stubs y mocks
  12. vi.fn(): comprobar que se llamó a un callback
  13. vi.spyOn y vi.mock: sustituir sin romper
  14. Control del tiempo: vi.useFakeTimers
  15. Ejecutar, filtrar y leer la cobertura

  1. Qué es un ejecutor de pruebas y qué hace por ti

Un ejecutor de pruebas (test runner) es un programa que automatiza todo lo que rodea a una prueba. Sin él, tendrías que escribir a mano el bucle que llama a cada función, el try/catch que captura los errores y el console.log que resume el resultado. Con él, escribes solo la parte que aporta valor.

Lo que hace un ejecutor moderno, por orden de ejecución:

Responsabilidad Qué significa en la práctica
Descubrir Encuentra los ficheros que coinciden con un patrón (*.test.js, *.spec.jsx) sin que los registres
Transformar Convierte JSX, módulos ES y sintaxis moderna en algo que el entorno pueda ejecutar
Aislar Cada fichero se ejecuta en su propio contexto de módulos, para que uno no contamine a otro
Ejecutar en paralelo Reparte los ficheros entre varios procesos y aprovecha todos los núcleos
Proporcionar la API describe, test, expect, los ganchos, los dobles de prueba, los temporizadores falsos
Informar Presenta qué ha pasado y qué ha fallado, con la diferencia exacta entre lo esperado y lo obtenido
Vigilar En modo vigilancia, detecta qué ficheros han cambiado y reejecuta solo lo afectado
Medir Instrumenta el código para calcular la cobertura

Esa última columna del «informar» es la que más se nota al depurar. Cuando falla una aserción sobre objetos, un buen ejecutor no dice «no son iguales»: dice qué campo difiere.

  1. Jest y Vitest: el mismo modelo, dos implementaciones

Jest nació en Facebook y se convirtió en el estándar de facto de las pruebas en JavaScript: durante años, «probar en React» y «probar con Jest» eran la misma frase. Create React App lo traía preconfigurado, y la mayor parte de la documentación, de las respuestas de Stack Overflow y del código heredado que te encontrarás está escrita en su API.

Vitest apareció después, para proyectos construidos con Vite, y tomó una decisión de diseño muy deliberada: replicar la API de Jest. No es un dialecto parecido; es la misma API, con el mismo comportamiento, para que migrar cueste casi nada y para que todo lo que sabe el ecosistema siga sirviendo.

Por qué en CicloUrbano se usa Vitest y no Jest:

  • Comparte la configuración de Vite. Los alias, los complementos, las variables de entorno y la resolución de módulos ya están definidos en vite.config.js. Con Jest habría que duplicarlo todo y mantener las dos copias sincronizadas.
  • Entiende módulos ES de forma nativa. El proyecto entero usa import/export. Jest se apoya en CommonJS y necesita transformación o banderas experimentales para los módulos ES.
  • Transforma con esbuild. Arranque en decenas de milisegundos frente a segundos, y modo vigilancia casi instantáneo.
  • No hay una segunda cadena de compilación. Lo que se prueba se transforma igual que lo que se ejecuta en desarrollo. Se elimina toda una familia de fallos del tipo «funciona en la aplicación y no en las pruebas».

Tabla de equivalencias

Concepto Jest Vitest ¿Difiere?
Agrupar describe describe No
Declarar prueba test / it test / it No
Aserción expect(x).toBe(y) expect(x).toBe(y) No
Matchers toEqual, toContain, toThrow Los mismos No
Ganchos beforeEach, afterAll Los mismos No
Objeto de utilidades jest vi Sí: el nombre
Función simulada jest.fn() vi.fn() Solo el prefijo
Espiar un método jest.spyOn(obj, 'm') vi.spyOn(obj, 'm') Solo el prefijo
Simular un módulo jest.mock('./m') vi.mock('./m') El prefijo y que en Vitest no hay elevación automática de las variables: usa fábricas
Temporizadores falsos jest.useFakeTimers() vi.useFakeTimers() Solo el prefijo
Avanzar el reloj jest.advanceTimersByTime(400) vi.advanceTimersByTime(400) Solo el prefijo
Restaurar jest.restoreAllMocks() vi.restoreAllMocks() Solo el prefijo
Configuración jest.config.js Bloque test de vite.config.js
Transformación Babel o ts-jest esbuild, vía Vite
Módulos ES Soporte parcial, requiere ajustes Nativo
Entorno DOM testEnvironment: 'jsdom' environment: 'jsdom' Solo el nombre de la clave
Velocidad de arranque Segundos Decenas de milisegundos
Globales sin importar Por defecto Requiere globals: true

Las diferencias de verdad se reducen a tres: el prefijo vi en lugar de jest, dónde vive la configuración y cómo se transforma el código. El 95 % del contenido de un fichero de pruebas es idéntico. De hecho, con globals: true en la configuración, un fichero de pruebas escrito para Jest suele ejecutarse en Vitest sin tocar una línea.

En el proyecto se usará siempre vi. Cuando leas código con jest.fn(), sabrás que es exactamente lo mismo.

  1. Configurar Jest en un proyecto que no usa Vite

Esta es la receta para cuando te toque un proyecto con Webpack, con Create React App heredado o sin empaquetador moderno. No se usa en CicloUrbano, pero conviene saber leerla.

npm install -D jest jest-environment-jsdom @babel/preset-env @babel/preset-react babel-jest \
               @testing-library/react @testing-library/jest-dom @testing-library/user-event \
               identity-obj-proxy
// jest.config.js
export default {
  // 1) El DOM en memoria: en Jest 28+ es un paquete aparte
  testEnvironment: 'jest-environment-jsdom',

  // 2) Equivalente a setupFiles de Vitest
  setupFilesAfterEnv: ['<rootDir>/src/pruebas/configuracion.js'],

  // 3) Jest NO entiende CSS ni imágenes: hay que sustituirlos
  moduleNameMapper: {
    '\\.(css|less|scss)$': 'identity-obj-proxy',
    '\\.(jpg|png|svg|webp)$': '<rootDir>/src/pruebas/ficheroSimulado.js',
    '^@/(.*)$': '<rootDir>/src/$1'          // el alias que en Vite ya estaba resuelto
  },

  // 4) Qué ficheros son pruebas
  testMatch: ['**/*.test.{js,jsx}'],

  // 5) Cobertura
  collectCoverageFrom: ['src/**/*.{js,jsx}', '!src/pruebas/**', '!src/main.jsx']
};
// babel.config.js — Jest necesita transformar JSX y módulos ES
export default {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' } }],
    ['@babel/preset-react', { runtime: 'automatic' }]
  ]
};
// src/pruebas/configuracion.js — en Jest, sin el sufijo /vitest
import '@testing-library/jest-dom';

Fíjate en el trabajo extra que aparece y que en Vitest sencillamente no existe: un moduleNameMapper para el CSS y las imágenes, una configuración de Babel, un paquete aparte para el entorno DOM y la duplicación de los alias. Eso es exactamente lo que se ahorra al compartir la configuración de Vite, y es el argumento práctico del apartado anterior.

  1. Anatomía de un fichero de pruebas: describe, test y los ganchos

// src/utilidades/ejemplo.test.js
import { describe, test, expect, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';

describe('grupo exterior', () => {
  beforeAll(() => console.log('1 · beforeAll exterior'));
  beforeEach(() => console.log('3 · beforeEach exterior'));
  afterEach(() => console.log('5 · afterEach exterior'));
  afterAll(() => console.log('7 · afterAll exterior'));

  describe('grupo interior', () => {
    beforeAll(() => console.log('2 · beforeAll interior'));
    beforeEach(() => console.log('4 · beforeEach interior'));
    afterEach(() => console.log('6 · afterEach interior'));

    test('la prueba', () => {
      expect(true).toBe(true);
    });
  });
});

Con globals: true en la configuración, esa primera línea de importación es opcional. En este curso se omitirá para no repetirla en cada ejemplo.

Elemento Cuándo se ejecuta Para qué sirve
describe(nombre, fn) Al cargar el fichero (agrupa, no aísla) Organizar por sujeto y compartir ganchos
test(nombre, fn) / it(...) Una vez por prueba La prueba en sí. test e it son alias exactos
beforeAll(fn) Una vez, antes de la primera prueba del bloque Preparar algo caro y compartible: arrancar un servidor
beforeEach(fn) Antes de cada prueba del bloque Reconstruir el escenario limpio de cada prueba
afterEach(fn) Después de cada prueba Limpiar: restaurar espías, vaciar localStorage
afterAll(fn) Una vez, tras la última prueba del bloque Cerrar lo que se abrió en beforeAll

El orden de ejecución es el que marcan los números del ejemplo, y tiene su lógica: los before van de fuera hacia dentro y los after de dentro hacia fuera, como una pila. Es decir, beforeAll exterior → beforeAll interior → beforeEach exterior → beforeEach interior → la pruebaafterEach interior → afterEach exterior → afterAll interior → afterAll exterior.

Consecuencias prácticas:

  • Prefiere beforeEach a beforeAll. beforeAll crea estado compartido, que es la causa número uno de pruebas que solo fallan al ejecutar la suite completa (el problema 8.3 de la lección anterior). Solo se usa para lo que es caro y de solo lectura.
  • El código dentro de un describe pero fuera de un gancho se ejecuta al cargar el fichero, no antes de cada prueba. Este es un fallo clásico:
// ❌ El objeto se crea UNA vez y las pruebas se lo pasan mutado entre sí
describe('sliceReservas', () => {
  const estado = { entidades: {}, ids: [] };   // ¡compartido!
  test('a', () => { estado.ids.push('res-01'); /* … */ });
  test('b', () => { /* aquí estado.ids ya tiene 'res-01' */ });
});

// ✅ Escenario nuevo por prueba
describe('sliceReservas', () => {
  let estado;
  beforeEach(() => { estado = { entidades: {}, ids: [] }; });
  // …
});
  • Los ganchos pueden ser asíncronos. Si devuelven una promesa, el ejecutor la espera. Se aprovechará en 09-04 para arrancar el servidor de MSW.

  1. Aserciones: el catálogo de matchers

Una aserción es expect(valorObtenido).matcher(valorEsperado). Si la comprobación falla, se lanza un error con la diferencia y la prueba se marca en rojo.

Matcher Comprueba Ejemplo en CicloUrbano
toBe(x) Igualdad por identidad (Object.is) expect(reserva.estado).toBe('activa')
toEqual(x) Igualdad estructural recursiva; ignora undefined expect(errores).toEqual({ horas: '…' })
toStrictEqual(x) Como toEqual pero distingue undefined, huecos de array y la clase del objeto expect(reserva).toStrictEqual(new Reserva(...))
toContain(x) Un array contiene el elemento (por identidad) o una cadena contiene la subcadena expect(errores.bicicletaId).toContain('no está disponible')
toContainEqual(x) Un array contiene un elemento estructuralmente igual expect(reservas).toContainEqual({ id: 'res-01', … })
toHaveLength(n) .length de arrays y cadenas expect(estado.ids).toHaveLength(2)
toHaveProperty(ruta, v) Existe la propiedad, opcionalmente con ese valor expect(errores).toHaveProperty('horas')
toMatchObject(x) El objeto contiene al menos esas propiedades expect(reserva).toMatchObject({ estado: 'activa' })
toThrow(x) La función lanza; opcionalmente con ese mensaje o clase expect(() => useTema()).toThrow('dentro de <ProveedorTema>')
toBeCloseTo(n, d) Números decimales con tolerancia expect(total).toBeCloseTo(5.0, 2)
toBeTruthy() / toBeFalsy() Veracidad, no valor exacto expect(esValido).toBeTruthy()
toBeNull() / toBeUndefined() / toBeDefined() Valores concretos expect(estado.error).toBeNull()
toBeGreaterThan(n) / toBeLessThan(n) Comparaciones numéricas expect(bicicleta.precioHora).toBeGreaterThan(0)
toMatch(regex) Una cadena casa con la expresión regular expect(reserva.id).toMatch(/^res-[a-z0-9]{8}$/)
toHaveBeenCalled() Una función simulada se llamó Apartado 12
toHaveBeenCalledWith(...) Se llamó con esos argumentos Apartado 12
toHaveBeenCalledTimes(n) Se llamó exactamente n veces Apartado 12

La negación con .not

Cualquier matcher se invierte anteponiendo .not:

expect(errores).not.toHaveProperty('fechaInicio');    // NO hay error de fecha
expect(estado.ids).not.toContain('res-99');
expect(alReservar).not.toHaveBeenCalled();

Un aviso sobre .not, porque es una trampa recurrente: una aserción negativa demuestra menos de lo que parece. expect(errores).not.toHaveProperty('horas') pasa tanto si la validación es correcta como si validarReserva devuelve {} porque se ha roto entera. Siempre que puedas, acompaña una aserción negativa de una positiva:

// ✅ La positiva sujeta a la negativa
expect(errores).toEqual({ fechaInicio: 'La reserva no puede empezar en el pasado.' });

Esa aserción única dice a la vez que hay error de fecha y que no hay ningún otro, que es exactamente lo que se quiere afirmar.

  1. toBe frente a toEqual: la diferencia que más errores causa

const a = { id: 'bici-001', modelo: 'Urbana Clásica' };
const b = { id: 'bici-001', modelo: 'Urbana Clásica' };

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, la comparación por identidad. Para primitivos —cadenas, números, booleanos— identidad y contenido coinciden, así que expect('activa').toBe('activa') funciona. Para objetos y arrays, compara referencias.
  • toEqual recorre la estructura y compara clave a clave, recursivamente.

Esta distinción no es un detalle académico: es exactamente la misma comparación por identidad que gobierna memo y useMemo en el módulo 8, y la que hace que un selector que devuelve un array nuevo repinte el componente en 07-05. La regla mnemotécnica: toBe para primitivos, toEqual para objetos y arrays.

Y una diferencia entre toEqual y toStrictEqual que conviene tener presente:

expect({ id: 'res-01', canceladaEn: undefined }).toEqual({ id: 'res-01' });        // ✅ PASA
expect({ id: 'res-01', canceladaEn: undefined }).toStrictEqual({ id: 'res-01' });  // ❌ FALLA

toEqual ignora las propiedades con valor undefined; toStrictEqual no, y además comprueba que ambos objetos sean de la misma clase. En CicloUrbano toEqual es lo habitual, porque un campo opcional ausente y un campo opcional a undefined son lo mismo para la aplicación. Usa toStrictEqual cuando la ausencia de una clave sea significativa.

  1. La suite de validarReserva, regla por regla

Aquí está el caso central de la lección. Recordemos la función que se probó a escribir en 03-05: recibe datos y el catálogo de bicicletas, y devuelve un objeto con un mensaje por cada campo con error, o {} si todo es válido. Cuatro reglas: bicicleta obligatoria, existente y disponible; fecha obligatoria, válida y no anterior a ahora; horas entero entre 1 y 24; condiciones aceptadas.

// src/utilidades/validarReserva.test.js
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
import { validarReserva } from './validarReserva.js';

// PREPARAR: un catálogo mínimo pero representativo, con los tres estados posibles
const BICICLETAS = [
  { id: 'bici-001', modelo: 'Urbana Clásica', tipo: 'urbana',    estado: 'disponible',    estacionId: 'est-01', precioHora: 2.5 },
  { id: 'bici-002', modelo: 'Eléctrica Pro',  tipo: 'electrica', estado: 'alquilada',     estacionId: 'est-01', precioHora: 4.0 },
  { id: 'bici-003', modelo: 'Carga Max',      tipo: 'carga',     estado: 'mantenimiento', estacionId: 'est-02', precioHora: 5.5 }
];

// Fábrica de datos válidos: cada prueba parte de aquí y estropea SOLO un campo.
// Es el patrón que mantiene legibles las suites de validación.
function datosValidos(cambios = {}) {
  return {
    bicicletaId: 'bici-001',
    fechaInicio: '2026-05-04T09:00',
    horas: 2,
    condiciones: true,
    ...cambios
  };
}

describe('validarReserva', () => {
  // El reloj se congela: la regla "no puede empezar en el pasado" depende de Date.now()
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-05-04T08:00:00'));
  });

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

  test('devuelve un objeto vacío cuando todos los datos son correctos', () => {
    expect(validarReserva(datosValidos(), BICICLETAS)).toEqual({});
  });
});

Dos decisiones de diseño que valen para cualquier suite de validación:

  • La fábrica datosValidos(cambios). Sin ella, cada prueba repetiría los cuatro campos y el lector tendría que compararlos visualmente para saber cuál es el que se está probando. Con ella, datosValidos({ horas: 25 }) dice por sí solo qué se comprueba. Además, si mañana el formulario añade un quinto campo, se cambia en un sitio.
  • El reloj congelado en beforeEach. La regla de la fecha compara con Date.now(). Si la prueba usara el reloj real, la fecha '2026-05-04T09:00' sería válida hoy e inválida dentro de un año: una prueba con fecha de caducidad, que es un caso de libro de prueba inestable. Congelando el sistema a 2026-05-04T08:00, la prueba dará el mismo resultado dentro de una década.

Las reglas de la bicicleta

describe('bicicleta', () => {
  test('exige elegir una bicicleta', () => {
    const errores = validarReserva(datosValidos({ bicicletaId: '' }), BICICLETAS);
    expect(errores).toEqual({ bicicletaId: 'Elige una bicicleta.' });
  });

  test('rechaza una bicicleta que no está en el catálogo', () => {
    const errores = validarReserva(datosValidos({ bicicletaId: 'bici-999' }), BICICLETAS);
    expect(errores.bicicletaId).toBe('La bicicleta seleccionada no existe.');
  });

  test('rechaza una bicicleta alquilada e incluye su modelo en el mensaje', () => {
    const errores = validarReserva(datosValidos({ bicicletaId: 'bici-002' }), BICICLETAS);
    expect(errores.bicicletaId).toBe('Eléctrica Pro no está disponible ahora mismo.');
  });

  test('rechaza una bicicleta en mantenimiento', () => {
    const errores = validarReserva(datosValidos({ bicicletaId: 'bici-003' }), BICICLETAS);
    expect(errores.bicicletaId).toContain('no está disponible');
  });

  test('rechaza cualquier bicicleta si el catálogo llega vacío', () => {
    // El valor por defecto es [], y ese camino también hay que recorrerlo
    expect(validarReserva(datosValidos()).bicicletaId).toBe('La bicicleta seleccionada no existe.');
  });
});

Fíjate en la mezcla deliberada de matchers, porque cada uno dice algo distinto:

  • toEqual({ bicicletaId: '…' }) en la primera afirma que ese es el único error. Es la aserción más fuerte y la que detectaría una regresión que hiciera fallar también la fecha.
  • toBe('Eléctrica Pro no está disponible ahora mismo.') comprueba el mensaje literal, incluida la interpolación del modelo. Es correcto aquí porque ese texto lo lee el usuario y forma parte del comportamiento.
  • toContain('no está disponible') es más laxo a propósito: comprueba la regla sin atarse a la redacción exacta. Úsalo cuando el texto pueda cambiar por motivos editoriales sin que cambie el comportamiento.

Las reglas de la fecha, las horas y las condiciones

describe('fecha de inicio', () => {
  test('exige una fecha de inicio', () => {
    expect(validarReserva(datosValidos({ fechaInicio: '' }), BICICLETAS))
      .toEqual({ fechaInicio: 'Indica cuándo empieza la reserva.' });
  });

  test('rechaza una fecha con formato no válido', () => {
    expect(validarReserva(datosValidos({ fechaInicio: 'mañana' }), BICICLETAS).fechaInicio)
      .toBe('La fecha no tiene un formato válido.');
  });

  test('rechaza una fecha anterior al momento actual', () => {
    // Reloj congelado a las 08:00; la reserva pide las 07:59
    expect(validarReserva(datosValidos({ fechaInicio: '2026-05-04T07:59' }), BICICLETAS).fechaInicio)
      .toBe('La reserva no puede empezar en el pasado.');
  });

  test('acepta una fecha en el mismo minuto en que estamos', () => {
    // 08:00:00 no es MENOR que 08:00:00, así que es válida: la frontera exacta
    expect(validarReserva(datosValidos({ fechaInicio: '2026-05-04T08:00' }), BICICLETAS))
      .toEqual({});
  });
});

describe('condiciones de uso', () => {
  test('exige aceptar las condiciones', () => {
    expect(validarReserva(datosValidos({ condiciones: false }), BICICLETAS))
      .toEqual({ condiciones: 'Debes aceptar las condiciones de uso.' });
  });
});

describe('acumulación de errores', () => {
  test('devuelve todos los errores a la vez, no solo el primero', () => {
    const errores = validarReserva(
      { bicicletaId: '', fechaInicio: '', horas: '', condiciones: false },
      BICICLETAS
    );
    expect(Object.keys(errores)).toHaveLength(4);
    expect(errores).toEqual({
      bicicletaId: 'Elige una bicicleta.',
      fechaInicio: 'Indica cuándo empieza la reserva.',
      horas: 'Indica cuántas horas quieres la bicicleta.',
      condiciones: 'Debes aceptar las condiciones de uso.'
    });
  });
});

Esa última prueba es más valiosa de lo que parece. Verifica una decisión de diseño explícita de 03-05: la validación no corta en el primer error, porque un formulario que enseña los errores de uno en uno obliga al usuario a cuatro intentos. Si alguien refactoriza validarReserva con return tempranos, esta prueba lo caza.

  1. Casos límite y test.each

Los casos límite son los valores que están justo en la frontera de una regla, y son donde vive la mayoría de los fallos reales. Para horas, la regla es «entero entre 1 y 24». Las fronteras son 0/1 y 24/25.

Escribir ocho pruebas casi idénticas es ruido. test.each recibe una tabla y genera una prueba por fila:

describe('duración en horas', () => {
  test.each([
    // valor,   ¿válido?,  fragmento esperado del mensaje
    [0,     false, 'La reserva mínima es de 1 hora.'],
    [1,     true,  null],
    [2,     true,  null],
    [24,    true,  null],
    [25,    false, 'La reserva máxima es de 24 horas.'],
    [-3,    false, 'La reserva mínima es de 1 hora.'],
    [2.5,   false, 'Las horas deben ser un número entero.'],
    ['',    false, 'Indica cuántas horas quieres la bicicleta.'],
    ['dos', false, 'Indica cuántas horas quieres la bicicleta.']
  ])('con horas = %p el error es %p', (horas, esValido, mensaje) => {
    const errores = validarReserva(datosValidos({ horas }), BICICLETAS);

    if (esValido) {
      expect(errores).not.toHaveProperty('horas');
    } else {
      expect(errores.horas).toBe(mensaje);
    }
  });
});

Cómo funciona:

  • La tabla es un array de arrays. Cada fila se desestructura en los parámetros de la función de prueba.
  • El nombre admite marcadores al estilo printf: %p imprime el valor tal cual (útil para distinguir '' de 'dos'), %s lo convierte a cadena, %i a entero. En el informe aparecen nueve pruebas con nombres distintos, así que un fallo señala la fila exacta.
  • También existe la variante con plantillas etiquetadas, más legible cuando hay muchas columnas:
test.each`
  horas   | esperado
  ${0}    | ${'La reserva mínima es de 1 hora.'}
  ${25}   | ${'La reserva máxima es de 24 horas.'}
  ${2.5}  | ${'Las horas deben ser un número entero.'}
`('con horas = $horas devuelve "$esperado"', ({ horas, esperado }) => {
  expect(validarReserva(datosValidos({ horas }), BICICLETAS).horas).toBe(esperado);
});

Qué revela un caso límite mal cubierto

Mira la fila de 2.5. Sin ella, esta implementación alternativa de la regla pasaría todas las demás pruebas:

// Implementación con un fallo que solo detecta el caso límite decimal
if (horas < 1)       errores.horas = 'La reserva mínima es de 1 hora.';
else if (horas > 24) errores.horas = 'La reserva máxima es de 24 horas.';
// ⚠️ Falta la comprobación de entero: 2.5 pasa como válido

Con 0, 1, 24 y 25 la suite estaría verde, y sin embargo el usuario podría reservar durante dos horas y media, algo que el precio por hora y el sistema de estaciones no contemplan. Ese es el argumento de los casos límite en una frase: las pruebas de los valores «normales» confirman lo que ya sabías; las de los bordes encuentran lo que no sabías.

La lista de bordes que conviene recorrer siempre: el cero, el valor mínimo, el máximo, el máximo más uno, el negativo, el decimal cuando se espera un entero, la cadena vacía, null, undefined, y la colección vacía.

  1. Probar el reductor sliceReservas como función pura

Aquí se cierra lo que quedó pendiente en 05-05 y se prometió de nuevo en 07-04: un reductor es una función pura (estado, accion) => nuevoEstado, así que probarlo es llamarlo y comparar. No hace falta React, ni un almacén, ni un componente, ni Provider.

Recuerda la forma del estado: { entidades, ids, estadoCarga, error, estadoEnvio }, normalizada.

// src/funcionalidades/reservas/sliceReservas.test.js
import { describe, test, expect, beforeEach, vi } from 'vitest';
import reductorReservas, {
  reservaCreada, reservaConfirmada, reservaCancelada, envioIniciado, envioFallido
} from './sliceReservas.js';

const ESTADO_VACIO = {
  entidades: {},
  ids: [],
  estadoCarga: 'inactivo',
  error: null,
  estadoEnvio: 'inactivo'
};

const RESERVA_01 = {
  id: 'res-01',
  bicicletaId: 'bici-002',
  usuario: 'usr-01',
  fechaInicio: '2026-05-04T09:00',
  horas: 2,
  estado: 'activa'
};

// Un estado ya poblado, para probar transiciones
const ESTADO_CON_RESERVA = {
  ...ESTADO_VACIO,
  entidades: { 'res-01': RESERVA_01 },
  ids: ['res-01']
};

describe('reductor sliceReservas', () => {
  test('devuelve el estado inicial ante una acción desconocida', () => {
    const resultado = reductorReservas(undefined, { type: 'accion/inexistente' });
    expect(resultado).toEqual(ESTADO_VACIO);
  });

  test('no modifica el estado ante una acción que no le corresponde', () => {
    const resultado = reductorReservas(ESTADO_CON_RESERVA, { type: 'catalogo/terminoCambiado' });
    // toBe, no toEqual: comprobamos que devuelve LA MISMA referencia
    expect(resultado).toBe(ESTADO_CON_RESERVA);
  });
});

Esa segunda prueba usa toBe a propósito, y es un buen ejemplo de cuándo la identidad es comportamiento observable: si el reductor devolviera una copia ante cada acción ajena, todos los useSelector suscritos a reservas repintarían con cualquier acción de la aplicación. Es el problema de 07-05 convertido en prueba.

El ciclo de vida completo

describe('creación de reservas', () => {
  beforeEach(() => {
    // reservaCreada usa crypto.randomUUID y new Date: hay que fijar ambos
    vi.setSystemTime(new Date('2026-05-04T08:00:00'));
    vi.spyOn(crypto, 'randomUUID').mockReturnValue('abcd1234-0000-0000-0000-000000000000');
  });

  test('añade la reserva a entidades y su id al final de ids', () => {
    // La acción se construye con el creador: `prepare` genera el id y la fecha
    const accion = reservaCreada('bici-001', 'usr-01', '2026-05-04T10:00', 3);
    // Y el reductor se invoca como lo que es: una función de dos argumentos
    const resultado = reductorReservas(ESTADO_VACIO, accion);

    expect(resultado.ids).toEqual(['res-abcd1234']);
    expect(resultado.entidades['res-abcd1234']).toMatchObject({
      bicicletaId: 'bici-001',
      usuario: 'usr-01',
      fechaInicio: '2026-05-04T10:00',
      horas: 3,
      estado: 'activa'
    });
    expect(resultado.estadoEnvio).toBe('enviado');
    expect(resultado.error).toBeNull();
  });

  test('no muta el estado recibido', () => {
    const accion = reservaCreada('bici-001', 'usr-01', '2026-05-04T10:00', 3);
    reductorReservas(ESTADO_VACIO, accion);

    // El estado de entrada sigue intacto: Immer devuelve una copia
    expect(ESTADO_VACIO.ids).toHaveLength(0);
    expect(ESTADO_VACIO.entidades).toEqual({});
  });
});

Tres cosas que verifican estas pruebas y que no se pueden verificar mirando la pantalla:

  1. La forma exacta del estado tras la acción, incluidos estadoEnvio y error, que en la interfaz solo se manifiestan indirectamente.
  2. Que prepare genera el identificador con el formato acordado, res- más ocho caracteres. Por eso se espía crypto.randomUUID: sin fijarlo, el identificador sería distinto en cada ejecución y no habría nada que comparar.
  3. Que no hay mutación. Aunque escribas estado.ids.push(...) dentro del reductor, Immer —que Redux Toolkit incluye— produce una copia. Esta prueba lo confirma y detectaría el día en que alguien saque esa lógica de createSlice y la mutación pase a ser real.

Las guardas de negocio

Las reglas más valiosas del reductor son las guardas, porque son invisibles y difíciles de provocar a mano:

describe('confirmación y cancelación', () => {
  test('confirma una reserva activa', () => {
    const resultado = reductorReservas(ESTADO_CON_RESERVA, reservaConfirmada('res-01'));
    expect(resultado.entidades['res-01'].estado).toBe('confirmada');
  });

  test('ignora la confirmación de una reserva que no existe', () => {
    const resultado = reductorReservas(ESTADO_CON_RESERVA, reservaConfirmada('res-99'));
    expect(resultado).toEqual(ESTADO_CON_RESERVA);
  });

  test('no confirma una reserva ya cancelada', () => {
    const cancelada = {
      ...ESTADO_CON_RESERVA,
      entidades: { 'res-01': { ...RESERVA_01, estado: 'cancelada' } }
    };
    const resultado = reductorReservas(cancelada, reservaConfirmada('res-01'));
    expect(resultado.entidades['res-01'].estado).toBe('cancelada');
  });

  test('cancela una reserva activa y guarda el momento de la cancelación', () => {
    vi.setSystemTime(new Date('2026-05-04T12:30:00'));
    const resultado = reductorReservas(ESTADO_CON_RESERVA, reservaCancelada('res-01'));

    expect(resultado.entidades['res-01'].estado).toBe('cancelada');
    expect(resultado.entidades['res-01'].canceladaEn).toBe('2026-05-04T12:30:00.000Z');
  });

  test('cancelar dos veces no cambia el momento de la primera cancelación', () => {
    vi.setSystemTime(new Date('2026-05-04T12:30:00'));
    const unaVez = reductorReservas(ESTADO_CON_RESERVA, reservaCancelada('res-01'));

    vi.setSystemTime(new Date('2026-05-04T18:00:00'));
    const dosVeces = reductorReservas(unaVez, reservaCancelada('res-01'));

    expect(dosVeces.entidades['res-01'].canceladaEn).toBe('2026-05-04T12:30:00.000Z');
  });
});

Esa última prueba es exactamente el tipo de comprobación que justifica todo el módulo: reproducir a mano una doble cancelación con la latencia adecuada es tedioso y poco fiable; en código son seis líneas y se ejecuta en dos milisegundos, para siempre.

Encadenar acciones para probar una secuencia

test('recorre el ciclo completo: crear, confirmar y cancelar', () => {
  vi.spyOn(crypto, 'randomUUID').mockReturnValue('abcd1234-0000-0000-0000-000000000000');

  // El estado se va pasando de una llamada a la siguiente: eso es el almacén, sin el almacén
  let estado = reductorReservas(undefined, { type: '@@init' });
  estado = reductorReservas(estado, envioIniciado());
  expect(estado.estadoEnvio).toBe('enviando');

  estado = reductorReservas(estado, reservaCreada('bici-001', 'usr-01', '2026-05-04T10:00', 2));
  expect(estado.estadoEnvio).toBe('enviado');

  estado = reductorReservas(estado, reservaConfirmada('res-abcd1234'));
  expect(estado.entidades['res-abcd1234'].estado).toBe('confirmada');

  estado = reductorReservas(estado, reservaCancelada('res-abcd1234'));
  expect(estado.entidades['res-abcd1234'].estado).toBe('cancelada');
  expect(estado.ids).toEqual(['res-abcd1234']);   // cancelar no elimina, solo marca
});
flowchart LR
    A["estado inicial"] -->|"envioIniciado()"| B["estadoEnvio: enviando"]
    B -->|"reservaCreada(...)"| C["res-abcd1234 · activa"]
    C -->|"reservaConfirmada(id)"| D["confirmada"]
    C -->|"reservaCancelada(id)"| E["cancelada + canceladaEn"]
    D -->|"reservaCancelada(id)"| E
    E -->|"reservaConfirmada(id)"| E2["sin cambios (guarda)"]

  1. Probar los selectores

Un selector es igual de puro: (estado) => valorDerivado. La única particularidad es que los selectores del proyecto reciben el estado global, no el trozo del slice, así que hay que construirlo con la forma que define almacen.js: { reservas, catalogo, sesion }.

// src/funcionalidades/reservas/selectores.test.js
import { describe, test, expect } from 'vitest';
import {
  seleccionarIdsReservas, seleccionarReservaPorId, seleccionarEstadoEnvio
} from './sliceReservas.js';
import { seleccionarReservasActivas } from './selectoresReservas.js';

function estadoGlobal(reservas) {
  return {
    reservas,
    catalogo: { termino: '', orden: 'modelo', bicicletas: [] },
    sesion: { usuario: { id: 'usr-01', nombre: 'Ana Ribera', rol: 'cliente' }, cargando: false }
  };
}

const ESTADO = estadoGlobal({
  entidades: {
    'res-01': { id: 'res-01', bicicletaId: 'bici-002', usuario: 'usr-01', horas: 2, estado: 'activa' },
    'res-02': { id: 'res-02', bicicletaId: 'bici-005', usuario: 'usr-01', horas: 1, estado: 'cancelada' }
  },
  ids: ['res-01', 'res-02'],
  estadoCarga: 'correcto',
  error: null,
  estadoEnvio: 'inactivo'
});

describe('selectores de reservas', () => {
  test('seleccionarIdsReservas devuelve los ids en orden', () => {
    expect(seleccionarIdsReservas(ESTADO)).toEqual(['res-01', 'res-02']);
  });

  test('seleccionarReservaPorId devuelve la reserva pedida', () => {
    expect(seleccionarReservaPorId(ESTADO, 'res-01')).toMatchObject({ bicicletaId: 'bici-002' });
  });

  test('seleccionarReservaPorId devuelve undefined si el id no existe', () => {
    expect(seleccionarReservaPorId(ESTADO, 'res-99')).toBeUndefined();
  });

  test('seleccionarReservasActivas filtra las canceladas', () => {
    const activas = seleccionarReservasActivas(ESTADO);
    expect(activas).toHaveLength(1);
    expect(activas[0].id).toBe('res-01');
  });
});

Probar la memorización de un createSelector

Un selector memorizado tiene una propiedad extra que sí merece prueba, porque es la razón de su existencia: devolver la misma referencia si las entradas no cambian. Es lo que evita los repintados de 07-05, y es exactamente lo que rompería alguien que lo sustituyera por una función normal.

test('seleccionarReservasActivas devuelve la MISMA referencia si el estado no cambia', () => {
  const primera = seleccionarReservasActivas(ESTADO);
  const segunda = seleccionarReservasActivas(ESTADO);

  expect(segunda).toBe(primera);       // ← toBe: identidad, no contenido
});

test('recalcula cuando cambian las reservas', () => {
  const primera = seleccionarReservasActivas(ESTADO);

  const otroEstado = estadoGlobal({
    ...ESTADO.reservas,
    entidades: { ...ESTADO.reservas.entidades, 'res-03': { id: 'res-03', estado: 'activa' } },
    ids: [...ESTADO.reservas.ids, 'res-03']
  });
  const segunda = seleccionarReservasActivas(otroEstado);

  expect(segunda).not.toBe(primera);
  expect(segunda).toHaveLength(2);
});

Un aviso importante: como la memorización de createSelector guarda un solo resultado, si intercalas llamadas con estados distintos, la caché se invalida en cada una. Si escribes esta prueba y falla inesperadamente, comprueba que no hay una llamada con otro estado entre medias.

  1. Dobles de prueba: espías, stubs y mocks

Un doble de prueba (test double) es cualquier objeto que sustituye a una dependencia real durante una prueba, igual que un doble sustituye al actor en una escena peligrosa. Los nombres se usan de forma laxa en el día a día, pero las distinciones son útiles:

Tipo Qué hace Cuándo se usa En Vitest
Espía (spy) Envuelve la función real y registra las llamadas, sin cambiar el comportamiento Comprobar que algo se llamó, conservando lo que hace vi.spyOn(obj, 'metodo')
Stub Sustituye la función por una que devuelve un valor fijo Forzar una respuesta concreta: un error, una lista vacía vi.fn().mockReturnValue(x)
Mock Un stub que además verifica cómo se le llamó Comprobar la interacción, no solo el resultado vi.fn() + toHaveBeenCalledWith
Fake Una implementación alternativa simplificada pero funcional Sustituir una base de datos por un objeto en memoria Una clase escrita a mano

La regla que gobierna todo esto, y que se repetirá en 09-04 con MSW:

Simula lo mínimo. Cada doble de prueba es una copia de la realidad que puede desincronizarse de ella. Una prueba llena de simulaciones acaba probando las simulaciones.

  1. vi.fn(): comprobar que se llamó a un callback

vi.fn() crea una función simulada que no hace nada, devuelve undefined y registra todas las llamadas. Es la herramienta básica para verificar los callbacks del proyecto: la convención alX de CicloUrbano —alReservar, alCambiarTipo, alCrearReserva— es precisamente lo que se comprueba con ella.

// src/utilidades/monitorizacion.test.js
import { describe, test, expect, vi } from 'vitest';

describe('vi.fn en acción', () => {
  test('registra si se llamó, cuántas veces y con qué', () => {
    const alReservar = vi.fn();

    alReservar('bici-001');
    alReservar('bici-004');

    expect(alReservar).toHaveBeenCalled();
    expect(alReservar).toHaveBeenCalledTimes(2);
    expect(alReservar).toHaveBeenCalledWith('bici-001');
    expect(alReservar).toHaveBeenLastCalledWith('bici-004');
    expect(alReservar).toHaveBeenNthCalledWith(1, 'bici-001');

    // Acceso directo al registro, útil para aserciones complejas
    expect(alReservar.mock.calls).toEqual([['bici-001'], ['bici-004']]);
  });

  test('puede devolver valores controlados', () => {
    const obtenerPrecio = vi.fn()
      .mockReturnValueOnce(2.5)      // primera llamada
      .mockReturnValueOnce(4.0)      // segunda
      .mockReturnValue(0);           // el resto

    expect(obtenerPrecio()).toBe(2.5);
    expect(obtenerPrecio()).toBe(4.0);
    expect(obtenerPrecio()).toBe(0);
  });

  test('puede simular una implementación completa', () => {
    const calcularTotal = vi.fn((precioHora, horas) => precioHora * horas);
    expect(calcularTotal(2.5, 4)).toBe(10);
    expect(calcularTotal).toHaveBeenCalledWith(2.5, 4);
  });
});
Método Para qué
mockReturnValue(v) Devuelve siempre v
mockReturnValueOnce(v) Devuelve v solo la próxima vez; se encadenan
mockResolvedValue(v) Devuelve una promesa resuelta con v (funciones asíncronas)
mockRejectedValue(e) Devuelve una promesa rechazada con e: así se prueban los errores
mockImplementation(fn) Sustituye el cuerpo por fn
mockClear() Vacía el registro de llamadas, conserva la implementación
mockReset() Vacía el registro y la implementación
mockRestore() Devuelve la función original (solo para vi.spyOn)

Sobre las aserciones de argumentos: cuando el argumento es un objeto, toHaveBeenCalledWith compara estructuralmente, no por identidad. Y si solo te interesa parte del objeto, existen los comparadores asimétricos:

expect(alCrearReserva).toHaveBeenCalledWith(
  expect.objectContaining({ bicicletaId: 'bici-001', horas: 2, estado: 'activa' })
);

expect(alCrearReserva).toHaveBeenCalledWith(
  expect.objectContaining({ id: expect.stringMatching(/^res-/) })
);

Esto último es lo correcto para el identificador generado con crypto.randomUUID(): comprobar el formato, no el valor, evita tener que espiar la función criptográfica.

  1. vi.spyOn y vi.mock: sustituir sin romper

vi.spyOn: envolver un método existente

vi.spyOn(objeto, 'metodo') sustituye ese método por una función simulada que, por defecto, sigue llamando al original. El caso más habitual en pruebas de React es silenciar console.error, y aparecerá otra vez en 09-04 al probar LimiteDeError.

import { describe, test, expect, vi, afterEach } from 'vitest';
import { registrarError } from './monitorizacion.js';

describe('registrarError', () => {
  afterEach(() => {
    vi.restoreAllMocks();     // imprescindible: devuelve console.error a su ser
  });

  test('escribe el error en la consola en desarrollo', () => {
    // mockImplementation(() => {}) silencia la salida: la consola no se ensucia
    const espia = vi.spyOn(console, 'error').mockImplementation(() => {});

    registrarError(new Error('Fallo al cargar la flota'), { componente: 'PaginaCatalogo' });

    expect(espia).toHaveBeenCalledTimes(1);
    expect(espia.mock.calls[0][0]).toContain('Fallo al cargar la flota');
  });
});

El vi.restoreAllMocks() en el afterEach no es opcional. Si no restauras console.error, todas las pruebas posteriores del mismo proceso quedan mudas, incluidos los avisos legítimos de React. Se puede automatizar en la configuración global:

// vite.config.js, dentro del bloque test
test: {
  restoreMocks: true,     // vi.restoreAllMocks() automático tras cada prueba
  clearMocks: true        // vi.clearAllMocks() automático tras cada prueba
}

vi.mock: sustituir un módulo entero

Cuando lo que hay que sustituir es un módulo completo —porque hace peticiones, escribe en un servicio externo o depende del entorno—, se usa vi.mock:

// src/utilidades/registroReservas.test.js
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { registrarError } from './monitorizacion.js';
import { guardarReserva } from './registroReservas.js';

// Sustituye TODO el módulo monitorizacion.js por funciones simuladas.
// La llamada se eleva al principio del fichero, antes de los imports,
// así que la fábrica NO puede usar variables definidas fuera de ella.
vi.mock('./monitorizacion.js', () => ({
  registrarError: vi.fn(),
  registrarEvento: vi.fn()
}));

describe('guardarReserva', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  test('registra el error cuando la reserva no es válida', () => {
    guardarReserva({ bicicletaId: '', horas: 0 });

    expect(registrarError).toHaveBeenCalledTimes(1);
    expect(registrarError).toHaveBeenCalledWith(
      expect.any(Error),
      expect.objectContaining({ origen: 'guardarReserva' })
    );
  });
});

Tres detalles que causan errores si se ignoran:

  1. vi.mock se eleva al principio del fichero. Aunque lo escribas después de los import, se ejecuta antes. Por eso la fábrica no puede cerrar sobre variables externas: dentro se declaran los vi.fn() directamente. Si necesitas una referencia fuera, se usa vi.hoisted.
  2. El módulo simulado sustituye a todas sus exportaciones. Si monitorizacion.js exportara diez funciones y solo declaras dos en la fábrica, las otras ocho pasan a ser undefined. Para conservar el resto, se combina con importActual:
vi.mock('./monitorizacion.js', async (importarOriginal) => {
  const original = await importarOriginal();
  return { ...original, registrarError: vi.fn() };   // solo se sustituye una
});
  1. vi.mock es el martillo más grande de la caja. Sustituye el módulo real por una copia que no evoluciona con él: si mañana registrarError cambia de firma, la prueba sigue pasando con la firma antigua. Por eso la regla del apartado 11. En orden de preferencia: pasar la dependencia como parámetro > vi.spyOn sobre un método concreto > vi.mock del módulo entero. Y para la red, ninguna de las tres: MSW (09-04).

  1. Control del tiempo: vi.useFakeTimers

El tiempo es la mayor fuente de pruebas lentas e inestables. vi.useFakeTimers() sustituye setTimeout, setInterval, clearTimeout, Date y performance.now por implementaciones controladas: el reloj solo avanza cuando tú lo mandas avanzar.

Función Qué hace
vi.useFakeTimers() Activa los temporizadores falsos
vi.useRealTimers() Los devuelve a la normalidad. Siempre en un afterEach
vi.advanceTimersByTime(ms) Adelanta el reloj ms milisegundos y ejecuta lo que venciera
vi.runAllTimers() Ejecuta todos los temporizadores pendientes de golpe
vi.runOnlyPendingTimers() Ejecuta los pendientes sin encadenar los que estos programen
vi.setSystemTime(fecha) Fija Date.now() y new Date() a una fecha concreta
vi.getTimerCount() Cuántos temporizadores hay pendientes

Vamos a probar el mecanismo de useDebounce a nivel de función, sin React. El hook envuelve un setTimeout que se cancela en la limpieza; la lógica es esta, y es la que hay que verificar:

// src/utilidades/retrasar.js — el mecanismo de useDebounce extraído como función
export function retrasar(accion, retardoMs = 400) {
  let identificador = null;

  function invocar(...args) {
    clearTimeout(identificador);                       // cancela el pendiente
    identificador = setTimeout(() => accion(...args), retardoMs);
  }

  invocar.cancelar = () => clearTimeout(identificador);
  return invocar;
}
// src/utilidades/retrasar.test.js
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { retrasar } from './retrasar.js';

describe('retrasar (el mecanismo de useDebounce)', () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());

  test('no llama a la acción antes de que venza el retardo', () => {
    const buscar = vi.fn();
    const buscarRetrasado = retrasar(buscar, 400);

    buscarRetrasado('urb');
    vi.advanceTimersByTime(399);

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

  test('llama a la acción una vez cumplido el retardo', () => {
    const buscar = vi.fn();
    const buscarRetrasado = retrasar(buscar, 400);

    buscarRetrasado('urb');
    vi.advanceTimersByTime(400);

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

  test('escribir seis letras seguidas produce UNA sola llamada, con la última', () => {
    const buscar = vi.fn();
    const buscarRetrasado = retrasar(buscar, 400);

    // El usuario teclea "urbana" a 100 ms por letra
    for (const texto of ['u', 'ur', 'urb', 'urba', 'urban', 'urbana']) {
      buscarRetrasado(texto);
      vi.advanceTimersByTime(100);
    }

    expect(buscar).not.toHaveBeenCalled();     // aún no han pasado 400 ms desde la última

    vi.advanceTimersByTime(400);

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

  test('cancelar impide la llamada pendiente', () => {
    const buscar = vi.fn();
    const buscarRetrasado = retrasar(buscar, 400);

    buscarRetrasado('urb');
    buscarRetrasado.cancelar();
    vi.advanceTimersByTime(1000);

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

La tercera prueba es la que justifica todo el useDebounce del módulo 5 y toda la optimización del buscador del módulo 8, y es la que sería casi imposible de verificar a mano: teclear seis letras en menos de 400 ms de forma reproducible no es algo que se pueda hacer con los dedos. Con temporizadores falsos, es determinista y tarda un milisegundo.

Con temporizadores falsos, si olvidas avanzar el reloj, la prueba se queda esperando para siempre. El síntoma es un tiempo de espera agotado. La prueba de useDebounce dentro de React, con renderHook, se ve en 09-04, porque requiere combinar los temporizadores falsos con act.

  1. Ejecutar, filtrar y leer la cobertura

Modo vigilancia

npm test          # vitest, modo vigilancia

Vitest se queda escuchando, y al guardar un fichero reejecuta solo las pruebas afectadas por ese cambio, siguiendo el grafo de importaciones. Guardar validarReserva.js reejecuta validarReserva.test.js y FormularioReserva.test.jsx, pero no las 40 pruebas de reservas. En modo vigilancia hay teclas útiles:

Tecla Qué hace
a Reejecuta todas las pruebas
f Reejecuta solo las que fallaron
p Filtra por nombre de fichero
t Filtra por nombre de prueba
q Salir

Filtrar desde el código y desde la línea de órdenes

test.only('solo esta prueba se ejecuta en este fichero', () => { /* … */ });
test.skip('esta se salta y aparece marcada en el informe', () => { /* … */ });
test.todo('pendiente: rechazar reservas solapadas de la misma bicicleta');
describe.only('todo este bloque', () => { /* … */ });
test.fails('esta prueba DEBE fallar', () => { /* … */ });
npx vitest run src/utilidades              # solo los ficheros de esa carpeta
npx vitest run -t "mantenimiento"          # solo las pruebas cuyo nombre contenga eso
npx vitest run --reporter=verbose          # el árbol completo, prueba a prueba

Sobre test.only: es utilísimo mientras depuras y catastrófico si se te olvida, porque desactiva silenciosamente el resto del fichero y la suite sigue en verde. La protección es una regla del linter (vitest/no-focused-tests o jest/no-focused-tests) que lo convierte en error. Actívala.

test.todo es la forma correcta de anotar lo que falta: aparece en el informe como pendiente, no falla, y no miente sobre la cobertura como haría un test.skip permanente.

Leer un informe de cobertura

npm run cobertura
 % Coverage report from v8
---------------------------|---------|----------|---------|---------|-------------------
File                       | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
---------------------------|---------|----------|---------|---------|-------------------
All files                  |   78.42 |    71.05 |   80.00 |   78.42 |
 utilidades                |   96.15 |    94.44 |  100.00 |   96.15 |
  validarReserva.js        |  100.00 |   100.00 |  100.00 |  100.00 |
  clases.js                |  100.00 |   100.00 |  100.00 |  100.00 |
  monitorizacion.js        |   72.72 |    50.00 |  100.00 |   72.72 | 18-24
 funcionalidades/reservas  |   91.30 |    88.88 |  100.00 |   91.30 |
  sliceReservas.js         |   91.30 |    88.88 |  100.00 |   91.30 | 47,63
 componentes               |   31.25 |    12.50 |   25.00 |   31.25 |
  FormularioReserva.jsx    |    0.00 |     0.00 |    0.00 |    0.00 | 1-142
---------------------------|---------|----------|---------|---------|-------------------

Cómo se lee esto sin caer en la trampa del apartado 8.1 de la lección anterior:

  • La columna que más informa es % Branch. Un 100 % de sentencias con un 50 % de ramas significa que se ejecutan todas las líneas pero solo uno de los dos caminos de cada if. En monitorizacion.js, ese 50 % apunta a que la rama de producción nunca se recorre.
  • Uncovered Line #s es la lista de tareas. Las líneas 47 y 63 de sliceReservas.js son, muy probablemente, dos guardas (if (!reserva) return;) que aún no se han provocado. Merecen prueba: son reglas de negocio.
  • El 0 % de FormularioReserva.jsx es correcto hoy, porque los componentes se prueban en 09-03. No es una alarma: es un hueco conocido y planificado.
  • El informe HTML de coverage/index.html pinta el código fuente con las líneas no cubiertas en rojo y las ramas parciales en amarillo. Es mucho más útil que la tabla para decidir qué probar.

Y la regla de siempre: el objetivo no es el número. El objetivo es que las líneas rojas de la lógica de negocio dejen de estar rojas.

Errores Comunes y Consejos

  • Usar toBe con objetos y arrays. El fallo número uno del principiante. expect({ a: 1 }).toBe({ a: 1 }) siempre falla. Regla: primitivos con toBe, estructuras con toEqual, e identidad deliberada con toBe solo cuando la referencia sea el comportamiento que se prueba (memorización, reductor que no debe copiar).
  • Compartir estado entre pruebas con beforeAll o con una constante mutable. Produce pruebas que pasan en solitario y fallan en la suite, o al revés. beforeEach reconstruye; beforeAll solo para lo caro e inmutable.
  • Olvidar vi.useRealTimers() en el afterEach. Los temporizadores falsos se quedan activos y contaminan el resto del fichero; una prueba posterior que espere de verdad se cuelga hasta agotar el tiempo. Lo mismo con vi.restoreAllMocks() y console.error.
  • Dejarse un test.only en el código. Desactiva el resto del fichero en silencio y la suite se queda verde por vacío. Activa la regla del linter que lo prohíbe.
  • Probar la implementación del reductor en vez de su resultado. No compruebes que estado.ids.push fue llamado; comprueba que el estado devuelto contiene el identificador nuevo. Es el principio de 09-01 aplicado a Redux.
  • Simular de más. Un vi.mock de un módulo del propio proyecto suele ser señal de acoplamiento. Antes de simular, pregúntate si la dependencia puede entrar como parámetro: validarReserva(datos, bicicletas) recibe el catálogo precisamente por eso, y por eso se prueba sin ningún doble.
  • Probar una función pura con más preparación de la necesaria. Si para probar el reductor construyes un almacén de Redux completo, has perdido la ventaja: llámalo directamente.
  • Consejo: escribe primero la prueba que falla. Aunque no practiques desarrollo dirigido por pruebas, verificar que la prueba falla antes de escribir el código es la única manera de saber que la prueba comprueba algo. Una prueba que nunca ha estado en rojo puede estar comprobando nada.
  • Consejo: cuando arregles un fallo, escribe antes la prueba que lo reproduce. Te da la confirmación de que lo has entendido y evita que vuelva. Es el mejor momento para escribir una prueba, porque el caso concreto ya lo tienes en la mano.
  • Consejo: si una prueba necesita más de diez líneas de preparación, el código bajo prueba tiene demasiadas dependencias. La dificultad de probar es un indicador de diseño, no una molestia del ejecutor.

Ejercicios

Ejercicio 1. Escribe la suite de src/utilidades/clases.js, la utilidad que compone nombres de clase ignorando los valores falsos:

export function clases(...nombres) {
  return nombres.filter(Boolean).join(' ');
}

Cubre al menos: varios nombres, un valor false intercalado (el caso condicion && estilos.activo), undefined y null, la llamada sin argumentos y la llamada con un solo nombre. Usa test.each donde tenga sentido y justifica por qué el resultado se compara con toBe y no con toEqual.

Ejercicio 2. El equipo añade una regla nueva a validarReserva: una bicicleta de tipo carga no se puede reservar más de 8 horas. Escribe las pruebas antes de implementarla —incluyendo los casos límite— y luego la implementación mínima que las hace pasar. Indica qué prueba de la suite existente podría fallar con el cambio y por qué.

Ejercicio 3. Esta prueba de sliceReservas pasa siempre, incluso si el reductor está roto. Explica por qué y reescríbela para que verifique lo que pretende.

test('la cancelación funciona', () => {
  const estado = {
    entidades: { 'res-01': { id: 'res-01', estado: 'activa' } },
    ids: ['res-01'], estadoCarga: 'inactivo', error: null, estadoEnvio: 'inactivo'
  };
  const resultado = reductorReservas(estado, reservaCancelada('res-01'));
  expect(resultado).toBeTruthy();
  expect(resultado.entidades).toBeDefined();
  expect(Object.keys(resultado.entidades)).toHaveLength(1);
});

Soluciones

Solución 1.

// src/utilidades/clases.test.js
import { describe, test, expect } from 'vitest';
import { clases } from './clases.js';

describe('clases', () => {
  test.each([
    [['tarjeta', 'destacada'],           'tarjeta destacada'],
    [['tarjeta', false, 'activa'],       'tarjeta activa'],
    [['tarjeta', undefined],             'tarjeta'],
    [['tarjeta', null, 'activa'],        'tarjeta activa'],
    [['tarjeta', '', 'activa'],          'tarjeta activa'],
    [['tarjeta'],                        'tarjeta'],
    [[],                                 '']
  ])('clases(...%p) devuelve %p', (entrada, esperado) => {
    expect(clases(...entrada)).toBe(esperado);
  });

  test('reproduce el uso real del proyecto', () => {
    const estilos = { control: 'control_a1', invalido: 'invalido_b2' };
    const hayError = true;
    expect(clases(estilos.control, hayError && estilos.invalido)).toBe('control_a1 invalido_b2');
    expect(clases(estilos.control, false && estilos.invalido)).toBe('control_a1');
  });
});

Se compara con toBe porque el resultado es una cadena, es decir, un primitivo: identidad y contenido coinciden, y toBe da además un mensaje de error más claro con la diferencia de texto. toEqual funcionaría igual, pero usar el matcher más específico documenta el tipo del valor devuelto.

Fíjate en la última fila: clases() sin argumentos devuelve '', no undefined ni un espacio. Es un caso límite real, porque ese valor acaba en className y un undefined ahí pintaría el atributo literalmente en algunos escenarios.

Solución 2. Primero las pruebas:

describe('límite de 8 horas para bicicletas de carga', () => {
  test.each([
    [1,  true],
    [8,  true],
    [9,  false],
    [24, false]
  ])('bici-003 (carga) con %i horas: ¿válida? %p', (horas, esValida) => {
    // bici-003 está en mantenimiento en el catálogo base; para esta regla necesitamos una de carga DISPONIBLE
    const catalogo = [{ id: 'bici-006', modelo: 'Carga Max', tipo: 'carga', estado: 'disponible', precioHora: 5.5 }];
    const errores = validarReserva(datosValidos({ bicicletaId: 'bici-006', horas }), catalogo);

    if (esValida) {
      expect(errores).not.toHaveProperty('horas');
    } else {
      expect(errores.horas).toBe('Las bicicletas de carga se reservan un máximo de 8 horas.');
    }
  });

  test('una bicicleta urbana sí admite 24 horas', () => {
    expect(validarReserva(datosValidos({ bicicletaId: 'bici-001', horas: 24 }), BICICLETAS))
      .not.toHaveProperty('horas');
  });
});

La implementación mínima, añadida tras la comprobación del máximo general:

const MAX_HORAS_CARGA = 8;
// … dentro de la sección de horas, después de la comprobación de MAX_HORAS
const elegida = bicicletas.find((b) => b.id === datos.bicicletaId);
if (!errores.horas && elegida?.tipo === 'carga' && horas > MAX_HORAS_CARGA) {
  errores.horas = 'Las bicicletas de carga se reservan un máximo de 8 horas.';
}

Qué prueba existente podría fallar: ninguna de las que usan bici-001, porque es urbana. Pero sí fallaría cualquier prueba futura que reservara bici-003 durante más de 8 horas esperando el mensaje genérico de las 24. Y hay un detalle de diseño que la suite obliga a resolver: la comprobación nueva no debe sustituir al error de disponibilidad, de ahí el !errores.horas y de ahí que el caso de prueba use una bicicleta de carga disponible. Escribir la prueba primero es lo que ha hecho aflorar esa decisión antes de implementarla.

Solución 3. Por qué pasa siempre: las tres aserciones son tautológicas.

  • expect(resultado).toBeTruthy() pasa con cualquier objeto, incluso con el estado sin tocar.
  • expect(resultado.entidades).toBeDefined() pasa mientras el reductor devuelva algo con esa clave.
  • Object.keys(resultado.entidades)).toHaveLength(1) cuenta las entidades, que no cambian al cancelar: cancelar marca, no elimina. Es decir, esa aserción pasaría igual si el reductor no hiciera absolutamente nada.

Ninguna de las tres mira estado, que es lo único que la acción debería cambiar. Además, el nombre («la cancelación funciona») no dice qué comportamiento se verifica.

Reescrita:

describe('reservaCancelada', () => {
  const ESTADO = {
    entidades: { 'res-01': { id: 'res-01', bicicletaId: 'bici-002', estado: 'activa' } },
    ids: ['res-01'], estadoCarga: 'inactivo', error: null, estadoEnvio: 'inactivo'
  };

  beforeEach(() => vi.setSystemTime(new Date('2026-05-04T12:30:00')));
  afterEach(() => vi.useRealTimers());

  test('marca la reserva como cancelada y anota el momento', () => {
    const resultado = reductorReservas(ESTADO, reservaCancelada('res-01'));

    expect(resultado.entidades['res-01'].estado).toBe('cancelada');
    expect(resultado.entidades['res-01'].canceladaEn).toBe('2026-05-04T12:30:00.000Z');
  });

  test('conserva la reserva en la lista: cancelar no elimina', () => {
    const resultado = reductorReservas(ESTADO, reservaCancelada('res-01'));
    expect(resultado.ids).toEqual(['res-01']);
  });

  test('no muta el estado recibido', () => {
    reductorReservas(ESTADO, reservaCancelada('res-01'));
    expect(ESTADO.entidades['res-01'].estado).toBe('activa');
  });
});

Ahora cada prueba afirma un comportamiento concreto y verificable, y cualquiera de ellas se pondría en rojo si el reductor dejara de hacer su trabajo. La segunda, además, documenta una decisión de diseño —cancelar no borra— que de otro modo solo estaría en la cabeza de quien lo escribió.

Conclusión

Esta lección ha construido la base del módulo sin tocar React, y ha demostrado por qué esa parte es la más rentable: validarReserva, el reductor sliceReservas y los selectores concentran todas las reglas de negocio de CicloUrbano, y probarlos es llamarlos y comparar.

Lo esencial. Un ejecutor de pruebas descubre, transforma, aísla, paraleliza, informa, vigila y mide. Jest es el modelo mental del ecosistema y Vitest implementa la misma API; las únicas diferencias reales son el prefijo vi en lugar de jest, dónde vive la configuración y cómo se transforma el código, así que lo aprendido aquí sirve en ambos —y tienes la receta de jest.config.js con su moduleNameMapper, su Babel y su jest-environment-jsdom para el día en que te toque un proyecto sin Vite. La anatomía de un fichero es describe, test/it y cuatro ganchos cuyo orden es una pila: los before de fuera hacia dentro, los after de dentro hacia fuera; y la regla derivada de eso es preferir beforeEach a beforeAll, porque el estado compartido es la causa número uno de pruebas que solo fallan en la suite completa.

Del catálogo de matchers, la distinción que más errores causa es toBe frente a toEqual: identidad contra estructura, la misma comparación que gobierna memo en el módulo 8 y los selectores en el 7. Primitivos con toBe, objetos con toEqual, toStrictEqual cuando la ausencia de una clave sea significativa, y .not siempre acompañado de una aserción positiva que lo sujete —porque toEqual({ fechaInicio: '…' }) afirma a la vez que hay ese error y que no hay ningún otro.

La suite de validarReserva ha establecido dos patrones reutilizables: la fábrica datosValidos(cambios), que hace evidente qué campo se está estropeando en cada prueba, y el reloj congelado con vi.setSystemTime, sin el cual la regla de la fecha caducaría. Y test.each ha convertido nueve casos límite de horas en una tabla legible, con la lección de fondo: el caso decimal 2.5 es el que atrapa una implementación que 0, 1, 24 y 25 dejarían pasar. Las pruebas de los valores normales confirman lo que ya sabías; las de los bordes encuentran lo que no.

El reductor ha cerrado lo prometido en 05-05 y 07-04. Se prueba llamándolo directamente —sin almacén, sin Provider, sin componentes—, encadenando el estado de una llamada a la siguiente para recorrer el ciclo activa → confirmada / cancelada, y verificando lo que la pantalla no muestra: las guardas de negocio, la ausencia de mutación y —con toBe— que devuelve la misma referencia ante acciones ajenas, que es lo que evita repintar la aplicación entera. Los selectores se prueban igual, construyendo el estado global con la forma de almacen.js, y los memorizados con createSelector tienen una prueba propia y necesaria: que devuelven la misma referencia si las entradas no cambian.

Sobre los dobles de prueba, ya distingues espía, stub, mock y fake, y dominas las tres herramientas: vi.fn() para verificar los callbacks alX del proyecto con toHaveBeenCalledWith y los comparadores asimétricos como expect.objectContaining; vi.spyOn para envolver console.error sin ensuciar la salida, siempre con su restoreAllMocks; y vi.mock para sustituir un módulo entero, con sus tres trampas —la elevación, la sustitución total de las exportaciones y el desacople silencioso frente al módulo real—. De ahí la jerarquía: parámetro > spyOn > mock, y para la red ninguna de las tres. Los temporizadores falsos han convertido en determinista lo que era imposible de reproducir a mano: seis pulsaciones en 500 ms que deben producir una sola llamada con 'urbana'. Y sabes ejecutar en vigilancia, filtrar con test.only/test.skip/test.todo —con la regla del linter que impide olvidarse un only— y leer un informe de cobertura mirando la columna de ramas y las líneas sin cubrir de la lógica de negocio.

Queda la mitad visible. Las reglas están probadas, pero nadie ha comprobado todavía que el usuario vea el mensaje «La reserva máxima es de 24 horas» junto al campo correcto, ni que el botón «Reservar» esté deshabilitado para una bicicleta en mantenimiento, ni que pulsar un filtro avise al padre con el tipo elegido. Ese es el terreno de la próxima lección, y donde el trofeo de pruebas pone su mayor peso: componentes de verdad, montados en jsdom, consultados como los consultaría una persona —por rol, por etiqueta, por texto accesible— e interactuados con userEvent. Ahí verás por qué toda la accesibilidad del módulo 3 era, sin decirlo, la preparación para esto. La próxima lección es Pruebas de Componentes con React Testing Library.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados