El módulo anterior terminó con una confesión incómoda: nueve refactorizaciones sobre código que funcionaba —la firma de ListaBicicletas, el estado movido de sitio, el value de dos proveedores de contexto reescrito, las rutas partidas en nueve fragmentos, una capa de carga perezosa con sus fallos de red— y una única comprobación en todas ellas: abrir el navegador y mirar. Funcionó porque el proyecto es pequeño y porque la persona que tocaba el código recordaba qué había que mirar. Ninguna de esas dos condiciones sobrevive al crecimiento. Este módulo construye lo que faltaba: una red de seguridad automática que se ejecuta en segundos, que comprueba lo que un humano no puede recordar y que falla cuando el comportamiento cambia. Esta primera lección no escribe todavía pruebas de la aplicación: establece el criterio. Qué es una prueba, qué tipos hay, cuáles compensan en una interfaz de React, qué merece la pena probar de CicloUrbano y qué no, cuáles son las métricas que engañan, y cómo se deja el entorno listo para que las cuatro lecciones siguientes escriban código de prueba desde la primera línea.

Contenido

  1. El coste real de refactorizar sin pruebas
  2. Qué es una prueba automatizada: preparar, actuar, comprobar
  3. Las tres cosas que aporta una prueba
  4. Tipos de prueba: estática, unitaria, de integración y de extremo a extremo
  5. La pirámide de pruebas frente al trofeo de pruebas
  6. El principio rector del módulo: comportamiento, no implementación
  7. Qué merece la pena probar en CicloUrbano y qué no
  8. Falsos amigos: cobertura, pruebas frágiles y pruebas inestables
  9. Cómo se nombra una prueba
  10. Puesta en marcha del entorno: Vitest, jsdom y Testing Library
  11. La primera prueba: verificar que el entorno respira
  12. La estrategia del Módulo 9

  1. El coste real de refactorizar sin pruebas

Recupera el cierre del módulo 8 y piensa en lo que ocurrió realmente en cada una de esas nueve refactorizaciones. En cada una hubo un momento, después de guardar el fichero, en el que el estado del proyecto era desconocido. Se resolvió mirando la pantalla. Mirar la pantalla comprueba, como mucho, lo que hay en pantalla en ese instante: el catálogo con las cinco bicicletas y el filtro por tipo. No comprueba lo que estaba en la otra pestaña, ni el caso de la reserva con cero horas, ni qué pasa cuando json-server devuelve un 500, ni si el botón «Reservar» sigue deshabilitado para bici-003, que está en mantenimiento.

Esto se puede formular con precisión. Al tocar código, la pregunta es siempre la misma:

¿Qué comportamientos podría haber roto este cambio, y cómo sé que no los he roto?

Sin pruebas, la respuesta a la segunda mitad es «no lo sé», y el equipo la sustituye por dos estrategias, ambas malas:

  • No tocar el código. El fichero que nadie se atreve a modificar acaba siendo el que más lo necesita. La deuda técnica no es el código malo: es el código malo que no se puede cambiar.
  • Tocarlo y esperar. El fallo llega al usuario, que lo cuenta días después, cuando ya nadie recuerda el cambio que lo causó.

Una prueba automatizada convierte «no lo sé» en «lo compruebo en cuatro segundos». Esa es la diferencia, y es una diferencia de grado tan grande que cambia cómo se escribe el código: con red, se refactoriza a menudo y en pasos pequeños; sin red, se acumula y se reescribe de golpe.

flowchart LR
    A["Cambio en el código"] --> B{"¿Hay pruebas?"}
    B -- No --> C["Comprobación manual parcial"]
    C --> D["Regresión no detectada"]
    D --> E["El usuario informa del fallo"]
    E --> F["Depuración sin contexto: días después"]
    B -- Sí --> G["npm test · segundos"]
    G --> H{"¿Falla algo?"}
    H -- Sí --> I["Se corrige con el cambio aún fresco"]
    H -- No --> J["Se integra con confianza"]

El eje horizontal de ese diagrama es tiempo, y es donde está el ahorro: el coste de arreglar un fallo crece con la distancia entre el momento en que se introduce y el momento en que se detecta.

  1. Qué es una prueba automatizada: preparar, actuar, comprobar

Una prueba automatizada es, sin ninguna magia, una función que ejecuta código de la aplicación y lanza un error si el resultado no es el esperado. Nada más. El ejecutor de pruebas se encarga de encontrar esas funciones, llamarlas, capturar los errores y presentar un informe.

Toda prueba, sea del tipo que sea, tiene tres partes. En la literatura anglosajona se conocen como Arrange, Act, Assert (AAA); en este curso las llamaremos preparar, actuar y comprobar.

Parte Qué hace Ejemplo en CicloUrbano
Preparar Construye el escenario: datos de entrada, estado inicial, dependencias simuladas Un catálogo con bici-001 disponible y bici-003 en mantenimiento
Actuar Ejecuta una sola acción: llamar a la función, pulsar el botón, enviar el formulario Llamar a validarReserva({ bicicletaId: 'bici-003', … }, catalogo)
Comprobar Afirma qué debería haber pasado El resultado contiene un error en bicicletaId con el texto «no está disponible»
// Estructura de cualquier prueba, con las tres partes marcadas
test('rechaza una bicicleta en mantenimiento', () => {
  // 1. PREPARAR
  const bicicletas = [{ id: 'bici-003', modelo: 'Carga Max', estado: 'mantenimiento' }];
  const datos = { bicicletaId: 'bici-003', fechaInicio: '2030-01-01T10:00', horas: 2, condiciones: true };

  // 2. ACTUAR
  const errores = validarReserva(datos, bicicletas);

  // 3. COMPROBAR
  expect(errores.bicicletaId).toContain('no está disponible');
});

Tres reglas que salen directamente de esta estructura y que conviene interiorizar antes de escribir nada:

  • Una prueba, una acción. Si en el bloque de actuar hay tres cosas, cuando la prueba falle no sabrás cuál de las tres la rompió. Dividir.
  • La preparación debe ser explícita y local. Un dato que viene de otro fichero, de una prueba anterior o de un estado compartido convierte el fallo en un misterio. Cada prueba construye su escenario.
  • Comprobar el resultado, no los pasos. La prueba de arriba no comprueba que se haya llamado a bicicletas.find; comprueba qué devuelve la función. Esto es el principio del apartado 6, en miniatura.

  1. Las tres cosas que aporta una prueba

Vale la pena separar los tres beneficios, porque cada uno justifica un tipo de prueba distinto y explica decisiones que se toman más adelante.

3.1. Permite refactorizar sin miedo

Es el beneficio principal y el que motiva este módulo. Una refactorización, por definición, cambia la implementación sin cambiar el comportamiento. Si tienes una batería de pruebas que describe el comportamiento y no la implementación, refactorizar se convierte en un procedimiento mecánico: cambias el código, ejecutas las pruebas, y si todas pasan, la refactorización es correcta.

Aplicado a lo que ya hiciste: si ListaBicicletas hubiera tenido una prueba que dijera «dadas cinco bicicletas, pinta cinco tarjetas, y al pulsar “Reservar” en la primera avisa con bici-001», el cambio de firma y la introducción de useCallback habrían quedado verificados en el acto. Y si el memo hubiera dejado de propagar un campo, la prueba habría fallado.

La contrapartida está en la letra pequeña: solo funciona si la prueba no depende de la implementación. Una prueba que comprueba nombres de estado interno o número de renders se rompe con cada refactorización, y entonces la red de seguridad se convierte en un lastre. De ahí la insistencia del apartado 6.

3.2. Es documentación ejecutable

Un fichero de pruebas bien escrito responde a la pregunta «¿qué hace esto exactamente?» mejor que cualquier comentario, por una razón simple: los comentarios mienten con el tiempo y las pruebas no, porque si mienten, fallan.

✓ validarReserva
  ✓ devuelve un objeto vacío cuando todos los datos son correctos
  ✓ exige elegir una bicicleta
  ✓ rechaza una bicicleta que no existe en el catálogo
  ✓ rechaza una bicicleta en mantenimiento
  ✓ rechaza una fecha de inicio anterior a ahora
  ✓ rechaza 0 horas y acepta 1
  ✓ rechaza 25 horas y acepta 24
  ✓ exige aceptar las condiciones

Esa salida es la especificación de las reglas de negocio de las reservas de CicloUrbano, generada por el propio código. Quien entre nuevo al equipo la lee en treinta segundos.

3.3. Detecta regresiones antes que el usuario

Una regresión es un comportamiento que funcionaba y ha dejado de funcionar. Es el tipo de fallo más caro, porque nadie lo busca: se descubre por accidente, normalmente en producción y normalmente en la parte de la aplicación en la que nadie estaba trabajando.

Las pruebas invierten esa dinámica: cada comportamiento que se prueba una vez queda vigilado para siempre, sin coste marginal. Es la única forma de que una aplicación pueda crecer sin que la superficie de riesgo crezca con ella.

  1. Tipos de prueba: estática, unitaria, de integración y de extremo a extremo

No todas las pruebas cuestan ni aportan lo mismo. Esta tabla es el mapa del módulo entero, y conviene volver a ella al empezar cada lección.

Tipo Qué prueba Velocidad Coste de mantenimiento Confianza que aporta Qué detecta que las otras no
Estática (ESLint, TypeScript) El código sin ejecutarlo: sintaxis, tipos, reglas Instantánea (mientras escribes) Muy bajo Baja pero gratuita Erratas, variables no usadas, hooks mal llamados, props inexistentes
Unitaria Una función o un módulo aislado Muy alta (milisegundos) Bajo si el módulo es puro Baja: la unidad funciona, el conjunto no se sabe Errores de lógica y casos límite difíciles de reproducir por la interfaz
De integración Varias piezas juntas: un componente con sus hijos, su estado y sus proveedores Alta (decenas o cientos de ms) Medio Alta: es como se usa de verdad Cableado roto entre piezas que funcionan por separado
De extremo a extremo (e2e) La aplicación entera en un navegador real Baja (segundos por prueba) Alto Máxima Fallos de enrutamiento real, CSS que tapa un botón, sesión, red real, varias pantallas encadenadas

Algunas precisiones que suelen faltar:

  • Las pruebas estáticas son pruebas. ESLint con eslint-plugin-react-hooks detecta hoy una familia entera de fallos que en 2018 requerían pruebas: un useEffect con dependencias incompletas, un hook dentro de un if. Configurarlo bien es la inversión más rentable del proyecto, y es requisito previo, no alternativa.
  • La frontera entre unitaria e integración es difusa, y no importa. Cuando se prueba TarjetaBicicleta, que renderiza EtiquetaEstado dentro, ¿es unitaria o de integración? Es de integración, y es lo correcto. Discutir la etiqueta es tiempo perdido; lo que importa es el criterio del apartado siguiente.
  • La confianza no es proporcional al número de pruebas, sino a su tipo. Mil pruebas unitarias de funciones auxiliares no garantizan que la aplicación arranque. Una prueba e2e que reserva una bicicleta sí.

  1. La pirámide de pruebas frente al trofeo de pruebas

Durante dos décadas, el modelo dominante fue la pirámide de pruebas (Mike Cohn, 2009): muchas unitarias en la base, algunas de integración en medio, muy pocas de extremo a extremo en la cima. La justificación era económica: las unitarias eran baratas y rápidas; las de integración, lentas y frágiles.

flowchart TB
    subgraph PIRAMIDE["Pirámide clásica"]
        direction TB
        P3["E2E · muy pocas"]
        P2["Integración · algunas"]
        P1["Unitarias · muchísimas"]
        P3 --- P2 --- P1
    end
    subgraph TROFEO["Trofeo de pruebas (front-end moderno)"]
        direction TB
        T4["E2E · pocas, solo flujos críticos"]
        T3["Integración · LA MAYORÍA"]
        T2["Unitarias · las justas: lógica pura y casos límite"]
        T1["Estática · linter y tipos, gratis y continua"]
        T4 --- T3 --- T2 --- T1
    end

Kent C. Dodds propuso el trofeo de pruebas para el front-end actual, y el cambio de forma responde a tres hechos concretos:

  1. Las pruebas de integración han dejado de ser caras. jsdom monta un DOM completo en memoria en milisegundos, y Testing Library permite interactuar con él como lo haría una persona. Lo que en 2010 requería levantar un navegador hoy cuesta 50 ms.
  2. El análisis estático ha absorbido gran parte de lo que probaban las unitarias. Un tipo mal pasado o un hook mal llamado ya no necesitan prueba: el linter y el compilador de tipos los cazan antes.
  3. En una interfaz, casi todos los fallos reales están en el cableado, no en las unidades. El componente funciona, el reductor funciona, el hook funciona… y la pantalla está vacía porque el selector devuelve undefined. Las pruebas unitarias de cada pieza pasan las tres. La prueba de integración falla, que es lo que se quiere.

De ahí sale la regla operativa de este módulo, y la que se aplicará a CicloUrbano:

El peso cae en las pruebas de integración de componentes. Las unitarias se reservan para la lógica pura con casos límite —validaciones, reductores, selectores, utilidades— y las de extremo a extremo para tres o cuatro flujos críticos de negocio.

  1. El principio rector del módulo: comportamiento, no implementación

Si de este módulo solo te llevas una idea, que sea esta:

Prueba lo que el usuario ve y hace, no cómo lo has escrito por dentro.

La forma práctica de saber si una prueba respeta el principio es la prueba de la refactorización: si puedes reescribir el interior del componente sin cambiar nada de lo que el usuario percibe, y la prueba falla, la prueba está mal.

Míralo con dos versiones de la misma comprobación sobre SelectorTipo. Aquí solo interesa el contraste; la mecánica de render y screen es materia de 09-03.

// ❌ MAL: comprueba la implementación
test('actualiza el estado tipoElegido al pulsar Eléctrica', () => {
  const componente = montarYEspiarEstado(<SelectorTipo />);
  componente.pulsar('Eléctrica');
  // Se afirma sobre el NOMBRE de una variable interna
  expect(componente.estado.tipoElegido).toBe('electrica');
});
// ✅ BIEN: comprueba el comportamiento observable
test('marca el filtro pulsado como activo y avisa del tipo elegido', async () => {
  const usuario = userEvent.setup();
  const alCambiarTipo = vi.fn();
  render(<SelectorTipo tipo="todos" alCambiarTipo={alCambiarTipo} />);

  await usuario.click(screen.getByRole('button', { name: 'Eléctrica' }));

  expect(alCambiarTipo).toHaveBeenCalledWith('electrica');
});

Ahora aplica un cambio perfectamente legítimo: renombrar tipoElegido a tipoActivo, o sustituir el useState por un useReducer, o —como ocurrió de verdad en el módulo 7— subir ese estado a Redux y pasar el tipo por props.

Cambio en la implementación La prueba «MAL» La prueba «BIEN»
Renombrar tipoElegidotipoActivo Falla (falso positivo) Pasa
useStateuseReducer Falla Pasa
Estado subido a Redux / a la URL Falla Pasa
El botón deja de avisar al padre Pasa (¡fallo real no detectado!) Falla

Esa última fila es la más elocuente: la prueba acoplada a la implementación no solo se rompe cuando no debe, sino que deja pasar el fallo que sí importa. Falla cuando no toca y calla cuando toca. Es peor que no tener prueba, porque además consume tiempo de mantenimiento.

La lista de cosas que no se prueban nunca, y que se repetirá en 09-03 con ejemplos:

  • El estado interno de un componente y el nombre de sus variables.
  • Los nombres de las clases de CSS (estilos.activo es un hash generado por CSS Modules).
  • Cuántas veces se ha renderizado un componente (para eso está el Profiler de 08-05, que es una herramienta de diagnóstico, no de verificación).
  • Que se haya llamado a un hook concreto o a un método concreto de una biblioteca.
  • Métodos privados y funciones no exportadas: si merecen prueba, es que merecen ser exportadas.

  1. Qué merece la pena probar en CicloUrbano y qué no

Probarlo todo es imposible y probar al azar es inútil. El criterio que funciona combina dos ejes: cuánto duele si se rompe y cuánto cuesta la prueba. Aplicado al código que ya existe en el proyecto:

Tipo de código de CicloUrbano Ejemplos concretos ¿Se prueba? Con qué tipo de prueba Lección
Utilidades puras validarReserva, disponibilidad.js, clases Sí, a fondo Unitaria, con todos los casos límite 09-02
Reductores y selectores sliceReservas, sliceCatalogo, createSelector Sí, a fondo Unitaria: son funciones puras, no necesitan React 09-02
Componentes de presentación EtiquetaEstado, TarjetaBicicleta, Panel Sí, lo justo Integración ligera: qué pinta y qué avisa 09-03
Formularios con reglas FormularioReserva Sí, prioritario Integración: rellenar, enviar, ver errores 09-03
Hooks personalizados useAlternar, useDebounce, useAlmacenLocal Sí, si tienen lógica propia Unitaria con renderHook 09-04
Páginas con datos del servidor PaginaCatalogo, PaginaDetalleEstacion Sí, los tres estados Integración con la red simulada (MSW) 09-04
Mutaciones useCrearReserva y su invalidación Integración con MSW 09-04
Flujos completos de negocio Identificarse · reservar · cancelar Sí, solo esos tres Extremo a extremo con Cypress 09-05
Estilos y maquetación CSS Modules, variables :root, tema oscuro No con pruebas de código Revisión visual; a lo sumo, regresión visual
Código de terceros React Router, TanStack Query, Redux Toolkit No Ya están probados por sus autores
Envoltorios sin lógica Panel si solo pinta children dentro de un <section> No merece la pena
Constantes y datos ficticios datos/dominio.js No Probar una constante es tautológico

Dos criterios más para desempatar cuando dudes:

  • ¿Ha fallado alguna vez? Cada fallo real que llega a producción merece una prueba que lo reproduzca antes de arreglarlo. Esa prueba es la que garantiza que no vuelva.
  • ¿Está en el camino del dinero? En CicloUrbano, crear una reserva es el camino del dinero. Cambiar el tema a oscuro no lo es. El presupuesto de pruebas se reparte en consecuencia.

  1. Falsos amigos: cobertura, pruebas frágiles y pruebas inestables

8.1. La cobertura como métrica

La cobertura de código (code coverage) mide qué porcentaje del código se ha ejecutado durante las pruebas, normalmente desglosado en cuatro dimensiones:

Métrica Qué mide
Líneas (lines) Porcentaje de líneas ejecutadas
Sentencias (statements) Igual, pero por sentencia; difiere cuando hay varias en una línea
Ramas (branches) Porcentaje de caminos de if, ?:, &&, switch recorridos. La más informativa
Funciones (functions) Porcentaje de funciones llamadas al menos una vez

Para lo que sirve de verdad: para encontrar lo que no has probado. Abres el informe, ves que la rama del error 500 de PaginaCatalogo está en rojo, y decides si merece una prueba. Ese uso es excelente.

Para lo que no sirve: como objetivo. Esta prueba da 100 % de cobertura de validarReserva y no comprueba absolutamente nada:

// 100 % de cobertura, cero valor
test('validarReserva no explota', () => {
  validarReserva({ bicicletaId: 'bici-001', fechaInicio: '2030-01-01T10:00', horas: 2, condiciones: true }, []);
});

Ejecuta la función, recorre líneas… y no tiene ni una sola aserción. La cobertura mide ejecución, no verificación. Por eso el 100 % no es el objetivo: perseguirlo empuja a escribir pruebas de relleno para envoltorios triviales y a probar código que no lo merece, y el tiempo sale del presupuesto de las pruebas que sí importan. Un rango razonable en un proyecto sano está entre el 70 % y el 85 %, con una condición: que ese porcentaje incluya la lógica de negocio. Un 90 % que deja validarReserva sin cubrir vale menos que un 60 % que la cubre entera.

8.2. Pruebas frágiles

Una prueba frágil (brittle) es la que falla ante cambios que no rompen nada. Es exactamente la del apartado 6, y sus causas habituales son siempre las mismas:

  • Aserciones sobre detalles internos (estado, clases CSS, estructura del DOM exacta).
  • Selectores acoplados a la maquetación: «el tercer <div> dentro del segundo <section>».
  • Textos completos y literales con puntuación incluida, que se rompen al corregir una tilde.
  • Dependencia del orden entre pruebas: la prueba B solo pasa si antes se ejecutó la A.

El síntoma reconocible: en cada pull request hay que «arreglar las pruebas». Cuando eso pasa, el equipo deja de creerse los fallos, y una prueba en la que nadie cree ya no protege nada.

8.3. Pruebas inestables (flaky)

Una prueba inestable es la que pasa o falla con el mismo código, sin cambiar nada. Es el peor de los males, porque destruye la única propiedad que hace útil a una suite: que un fallo signifique algo.

Causa habitual Cómo se manifiesta Solución correcta
Esperar con un tiempo fijo (setTimeout(500)) Falla en una máquina lenta o en integración continua Esperar a que aparezca lo que se espera, con findBy* / waitFor (09-04)
Estado compartido entre pruebas Falla solo al ejecutar la suite entera, no en solitario Aislar: caché nueva, almacén nuevo, localStorage limpio en cada prueba
Dependencia del reloj real Falla a medianoche o al cambiar la hora Temporizadores falsos y fechas fijas (09-02)
Dependencia del orden de ejecución Falla al ejecutar en paralelo Cada prueba construye su escenario completo
Red real en una prueba Falla cuando la API va lenta o está caída Interceptar la red con MSW (09-04)

Y la regla más importante sobre ellas: una prueba inestable no se reintenta, se arregla o se borra. Marcarla como «reintentar tres veces» esconde un fallo real de aislamiento que, tarde o temprano, será un fallo de la aplicación.

  1. Cómo se nombra una prueba

El nombre de una prueba es lo único que se ve cuando falla en un registro de integración continua a las tres de la mañana. Tiene que bastar para entender qué se ha roto, sin abrir el fichero.

La fórmula que mejor funciona describe el comportamiento esperado, no el mecanismo:

<sujeto> + qué hace + en qué circunstancia

❌ Nombre pobre ✅ Nombre útil
test('funciona') test('devuelve un objeto vacío cuando todos los datos son válidos')
test('validarReserva 2') test('rechaza una reserva de 25 horas porque supera el máximo')
test('render') test('muestra el botón Reservar deshabilitado si la bicicleta está en mantenimiento')
test('setState') test('avisa al padre con el tipo elegido al pulsar un filtro')

Convenciones del proyecto, para que las cinco lecciones sean coherentes:

  • Los nombres van en español, como todos los textos del proyecto; describe, test y expect no se traducen, son API.
  • Los describe agrupan por sujeto: el nombre del módulo o del componente, tal cual (describe('validarReserva', …), describe('TarjetaBicicleta', …)).
  • Nada de «debería»: test('rechaza…') en presente de indicativo. Es más corto y se lee mejor en el informe.
  • Un describe anidado cuando comparten circunstancia: describe('cuando la bicicleta está en mantenimiento', …).

  1. Puesta en marcha del entorno: Vitest, jsdom y Testing Library

CicloUrbano se construye con Vite, y el ejecutor de pruebas natural para un proyecto Vite es Vitest: comparte la misma configuración, el mismo sistema de resolución de módulos y las mismas transformaciones, así que no hay una segunda cadena de compilación que mantener. Y —esto importa para la lección siguiente— implementa la misma API que Jest, el estándar del ecosistema.

10.1. Instalación

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
Paquete Para qué
vitest El ejecutor: encuentra los ficheros de prueba, los ejecuta y da el informe
jsdom Implementación del DOM en JavaScript: da un document y un window fuera del navegador
@testing-library/react render, screen y las consultas para probar componentes como los usa una persona
@testing-library/jest-dom Matchers específicos del DOM: toBeInTheDocument, toBeDisabled, toHaveValue
@testing-library/user-event Simula interacciones reales: clic, escritura, tabulación

Todo va en devDependencies (-D): no forma parte del paquete que se despliega.

10.2. Configuración en vite.config.js

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],

  test: {
    // 1) Entorno: un DOM en memoria, porque probamos componentes
    environment: 'jsdom',

    // 2) describe / test / expect disponibles sin importarlos, como en Jest
    globals: true,

    // 3) Fichero que se ejecuta UNA VEZ antes de cada archivo de pruebas
    setupFiles: './src/pruebas/configuracion.js',

    // 4) Qué se incluye en el informe de cobertura
    coverage: {
      reporter: ['text', 'html'],
      include: ['src/**/*.{js,jsx}'],
      exclude: ['src/pruebas/**', 'src/main.jsx', 'src/**/*.test.{js,jsx}']
    }
  }
});

Apartado por apartado:

  1. environment: 'jsdom' es lo que permite que render(<TarjetaBicicleta />) tenga un document donde pintar. Sin él, el entorno por defecto es node y cualquier acceso al DOM lanza document is not defined. Para probar solo funciones puras, node es más rápido; se puede fijar por fichero con un comentario // @vitest-environment node.
  2. globals: true hace que describe, test, expect, beforeEach y vi estén disponibles sin importarlos. Es lo que da compatibilidad de código con Jest y lo que permite que @testing-library/jest-dom se enganche. Sin esta opción hay que importar todo de vitest en cada fichero.
  3. setupFiles apunta al fichero de preparación común, que es el siguiente apartado. Ahí se registran los matchers y, en 09-04, se arrancará el servidor simulado de MSW.
  4. coverage excluye lo que no tiene sentido medir: las propias utilidades de prueba, el punto de entrada main.jsx (que solo llama a createRoot) y los ficheros .test.

10.3. El fichero de preparación: src/pruebas/configuracion.js

// src/pruebas/configuracion.js
// Se ejecuta antes de CADA fichero de pruebas (setupFiles en vite.config.js)

// 1) Registra los matchers de DOM: toBeInTheDocument, toBeDisabled, toHaveValue…
import '@testing-library/jest-dom/vitest';

// 2) Comodidades globales del entorno de pruebas de CicloUrbano
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

// Testing Library limpia sola si `globals: true`, pero dejarlo explícito
// documenta la intención y protege ante un cambio de configuración.
afterEach(() => {
  cleanup();
  localStorage.clear();   // aísla las pruebas de useAlmacenLocal
});

La importación es @testing-library/jest-dom/vitest, con el sufijo: esa variante llama a expect.extend del expect de Vitest. Si importas @testing-library/jest-dom a secas en Vitest, los matchers pueden no registrarse y toBeInTheDocument aparecerá como «no es una función».

localStorage.clear() en el afterEach no es adorno: useAlmacenLocal y ProveedorTema escriben ahí, y sin limpieza una prueba heredaría el tema oscuro que dejó la anterior. Es prevención directa del punto 8.3.

10.4. Estructura de ficheros del módulo

Los ficheros de prueba se colocan junto al código que prueban, y las utilidades compartidas en una carpeta propia:

src/
├── componentes/
│   ├── TarjetaBicicleta.jsx
│   ├── TarjetaBicicleta.test.jsx      ← junto al componente
│   ├── FormularioReserva.jsx
│   └── FormularioReserva.test.jsx
├── utilidades/
│   ├── validarReserva.js
│   └── validarReserva.test.js
├── hooks/
│   ├── useDebounce.js
│   └── useDebounce.test.js
└── pruebas/                            ← utilidades compartidas, sin pruebas propias
    ├── configuracion.js                 (10.3, y MSW en 09-04)
    ├── utilidades.jsx                   (el `renderizar` con proveedores, 09-03)
    ├── manejadores.js                   (manejadores de MSW, 09-04)
    └── servidor.js                      (setupServer de MSW, 09-04)

Colocar la prueba al lado del código, y no en un __tests__ lejano, tiene tres ventajas medibles: se ve de un vistazo qué está probado y qué no, la ruta de importación es './TarjetaBicicleta.jsx' en lugar de '../../componentes/TarjetaBicicleta.jsx', y al mover o borrar un componente la prueba viaja o desaparece con él.

10.5. Los scripts de package.json

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "api": "json-server --watch db.json --port 3001",
    "test": "vitest",
    "test:ejecutar": "vitest run",
    "test:ui": "vitest --ui",
    "cobertura": "vitest run --coverage"
  }
}
Script Qué hace Cuándo se usa
npm test Modo vigilancia: se queda escuchando y reejecuta solo lo afectado al guardar Mientras programas. Es el modo por defecto de Vitest
npm run test:ejecutar Una pasada y sale con código 0 o 1 Integración continua y ganchos de pre-commit
npm run test:ui Interfaz web con el árbol de pruebas, el DOM y los tiempos Depurar un fallo que no se entiende leyendo la consola
npm run cobertura Genera el informe en consola y en coverage/index.html De vez en cuando, para buscar huecos (apartado 8.1)

test:ui requiere npm install -D @vitest/ui, y la cobertura requiere npm install -D @vitest/coverage-v8. Vitest lo pide por consola la primera vez que ejecutas el script.

  1. La primera prueba: verificar que el entorno respira

Antes de escribir una sola prueba de la aplicación, conviene confirmar que la tubería funciona. Esta prueba no comprueba CicloUrbano: comprueba Vitest, jsdom y los matchers.

// src/pruebas/entorno.test.js
import { describe, test, expect } from 'vitest';

describe('entorno de pruebas', () => {
  test('el ejecutor de pruebas funciona', () => {
    expect(2 + 2).toBe(4);
  });

  test('jsdom proporciona un DOM en memoria', () => {
    // Si `environment` no fuera 'jsdom', esta línea lanzaría "document is not defined"
    document.body.innerHTML = '<h1>CicloUrbano</h1>';

    expect(document.querySelector('h1')).toBeInTheDocument();
    expect(document.querySelector('h1')).toHaveTextContent('CicloUrbano');
  });
});

Qué verifica cada una:

  • La primera comprueba que Vitest encuentra el fichero, lo ejecuta y evalúa una aserción. Si esta falla, el problema está en la configuración, no en tu código.
  • La segunda comprueba dos cosas a la vez: que environment: 'jsdom' está activo, porque existe document; y que los matchers de jest-dom se han registrado, porque toBeInTheDocument y toHaveTextContent no son de Vitest, vienen del fichero de preparación.
$ npm test

 ✓ src/pruebas/entorno.test.js (2)
   ✓ entorno de pruebas (2)
     ✓ el ejecutor de pruebas funciona
     ✓ jsdom proporciona un DOM en memoria

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  09:14:32
   Duration  412ms

Con esa salida en pantalla, el entorno está listo. Esta prueba puede borrarse después —no aporta nada a largo plazo—, pero es un buen primer paso, porque separa «mi prueba está mal» de «mi configuración está mal», que son dos problemas muy distintos y se depuran de forma muy distinta.

  1. La estrategia del Módulo 9

Este es el plan, y responde punto por punto al hueco que dejó el módulo 8:

Lección Qué añade Sobre qué código de CicloUrbano
09-01 (esta) Criterio, tipos de prueba, entorno funcionando La configuración
09-02 El ejecutor, las aserciones y los dobles de prueba; funciones puras, sin React validarReserva, sliceReservas y sus selectores, monitorizacion.js
09-03 Componentes con Testing Library: consultas, interacción, proveedores EtiquetaEstado, TarjetaBicicleta, SelectorTipo, FormularioReserva
09-04 Asincronía, temporizadores, red simulada con MSW y hooks personalizados PaginaCatalogo, useCrearReserva, useDebounce, useAlmacenLocal, LimiteDeError
09-05 Extremo a extremo en un navegador real con Cypress, e integración continua Los tres flujos críticos y el flujo de trabajo completo

Y en el módulo 11, la lección 11-04 aplicará todo esto al proyecto final. Aquí se aprende la técnica; allí se ejecuta sobre una aplicación completa.

Errores Comunes y Consejos

  • Escribir pruebas para subir la cobertura. Es el error que más tiempo consume y menos aporta. La cobertura es un mapa para encontrar huecos, no una nota. Un 72 % con la lógica de negocio cubierta vale más que un 95 % lleno de pruebas de envoltorios.
  • Probar el estado interno «porque es más fácil». Suele serlo, y es exactamente la razón por la que hay que resistirse: una prueba fácil que se rompe en cada refactorización tiene coste negativo. Si probar el comportamiento cuesta mucho, casi siempre significa que el componente hace demasiadas cosas; la señal es útil.
  • Empezar por las pruebas de extremo a extremo. Son las que más confianza dan y las más caras. Empezar por ahí produce una suite lenta e inestable que el equipo acaba desactivando. Se empieza por lo puro y por la integración de componentes; e2e al final y solo para los flujos críticos.
  • Confundir «no tiene pruebas» con «hay que probarlo todo ya». En un proyecto existente, la estrategia que funciona es: prueba cada fallo antes de arreglarlo, prueba cada funcionalidad nueva, y prueba las zonas que tocas. La cobertura crece por donde el proyecto se mueve, que es donde hace falta.
  • Dejar pruebas comentadas o con test.skip permanente. Una prueba desactivada es una mentira almacenada: parece que hay red donde no la hay. Se arregla o se borra; el historial de Git guarda lo que se borró.
  • No ejecutar las pruebas en integración continua. Una suite que solo se ejecuta cuando alguien se acuerda no protege nada. En 09-05 verás el flujo de trabajo mínimo de GitHub Actions que las lanza en cada cambio.
  • Consejo: instala primero, escribe después. Deja el entorno del apartado 10 funcionando con la prueba trivial del 11 antes de intentar probar nada real. Depurar una configuración y una prueba al mismo tiempo multiplica el tiempo por tres.
  • Consejo: cuando una prueba falle, léela como si fuera un informe de fallo. Si el nombre y el mensaje no te dicen qué se ha roto, el problema es la prueba. Mejórala en ese momento, cuando tienes el contexto en la cabeza.

Ejercicios

Ejercicio 1. Clasifica cada uno de estos comportamientos de CicloUrbano según el tipo de prueba que le corresponde (estática, unitaria, de integración o de extremo a extremo) y justifica por qué no le corresponde el nivel inmediatamente inferior:

a) validarReserva rechaza una reserva de 25 horas. b) FormularioReserva muestra el mensaje «La reserva máxima es de 24 horas» junto al campo de duración cuando se escribe 25 y se envía. c) Ana Ribera entra en /acceso, reserva bici-001 y ve la reserva en /reservas. d) useEffect en BuscadorBicicletas declara alBuscar en sus dependencias. e) seleccionarBicicletasVisibles devuelve las bicicletas urbanas ordenadas por precio cuando el estado tiene tipo: 'urbana' y orden: 'precio'.

Ejercicio 2. Un compañero presenta esta prueba de TarjetaBicicleta y afirma que da confianza porque cubre el 100 % del componente. Identifica cuatro problemas distintos y reescribe el enunciado de las pruebas que la sustituirían (solo los nombres, no la implementación).

test('TarjetaBicicleta', () => {
  const { container } = render(
    <TarjetaBicicleta bicicleta={{ id: 'bici-001', modelo: 'Urbana Clásica', estado: 'disponible', precioHora: 2.5 }} />
  );
  expect(container.querySelector('.tarjeta_a3f9x')).toBeTruthy();
  expect(container.querySelectorAll('button').length).toBe(2);
  expect(container.innerHTML).toContain('Urbana Clásica');
});

Ejercicio 3. El equipo de CicloUrbano tiene una prueba que falla aproximadamente una de cada diez ejecuciones en integración continua y siempre pasa en local. Comprueba que, tras crear una reserva, aparece el aviso «Reserva creada». Un miembro del equipo propone envolverla en retry(3). Explica por qué es mala idea, enumera tres causas posibles de la inestabilidad dadas las herramientas del proyecto, y describe qué comprobarías primero y por qué.

Soluciones

Solución 1.

Caso Tipo Por qué no el nivel inferior
a Unitaria Es lógica pura sin DOM ni React. Bajar más solo dejaría el análisis estático, que no puede saber que el máximo son 24 horas: eso es una regla de negocio, no un tipo
b Integración Una unitaria de validarReserva confirma que el error se genera, pero no que el formulario lo muestre ni que lo asocie al campo correcto. El fallo típico aquí es de cableado: el error existe y no se pinta porque tocados no se actualizó
c Extremo a extremo Encadena tres pantallas, navegación real, sesión y persistencia. Una prueba de integración de cada página pasaría aunque la redirección tras el acceso llevara a la ruta equivocada
d Estática eslint-plugin-react-hooks lo detecta al guardar, gratis y sin ejecutar nada. Escribir una prueba para esto sería tirar el dinero
e Unitaria Un selector es una función pura de (estado) => resultado. Montar componentes para verificarlo añade coste, ruido en el diagnóstico y ninguna confianza adicional

Solución 2. Los cuatro problemas:

  1. El nombre no describe nada. test('TarjetaBicicleta') no dice qué comportamiento se verifica, así que su fallo en integración continua no informa. Y el describe debería llevar ese nombre, no el test.
  2. Aserción sobre una clase de CSS Modules. .tarjeta_a3f9x es un identificador generado: cambia en cada compilación y con cada versión de Vite. Además, que exista un div con esa clase no es un comportamiento que el usuario perciba.
  3. Contar botones con querySelectorAll. Se comprueba la estructura del DOM, no la función. Si se añade un tercer botón legítimo, la prueba falla sin que nada esté roto; y si el botón «Reservar» deja de estar deshabilitado en mantenimiento —el fallo real que importa—, la prueba sigue pasando.
  4. Varias comprobaciones inconexas en una sola prueba y sin screen. Se usan container.querySelector e innerHTML en lugar de las consultas accesibles, y al fallar solo se sabe que «TarjetaBicicleta» falla, no cuál de las tres cosas.

Los enunciados que la sustituyen:

describe('TarjetaBicicleta')
  test('muestra el modelo, la estación y el precio por hora')
  test('permite reservar una bicicleta disponible y avisa con su identificador')
  test('deshabilita el botón Reservar cuando la bicicleta está en mantenimiento')
  test('avisa con el identificador al pulsar «Ver ficha»')

Solución 3. Por qué retry(3) es mala idea: no arregla nada, esconde el síntoma y, sobre todo, desactiva la señal. Si la prueba falla una de cada diez veces por una condición de carrera real, esa misma condición de carrera afecta a los usuarios; taparla con reintentos convierte un fallo de producción detectado en un fallo de producción invisible. Además contamina el criterio del equipo: en cuanto se acepta que «a veces fallan», nadie vuelve a mirar un fallo rojo.

Tres causas posibles con las herramientas del proyecto:

  1. Espera por tiempo en lugar de por resultado. Si la prueba usa un retardo fijo tras enviar el formulario, en la máquina más lenta de integración continua la mutación aún no ha resuelto cuando se comprueba el aviso. Se corrige esperando al aviso con findByText, que reintenta hasta que aparece (09-04).
  2. Estado compartido entre pruebas. Un QueryClient reutilizado entre ficheros deja en caché la lista de reservas de una prueba anterior; según el orden de ejecución, la lista ya contiene la reserva y el aviso nunca se dispara. Se corrige creando un cliente nuevo por prueba con retry: false.
  3. La red no está interceptada. Si la prueba llega a json-server de verdad, depende de que el proceso esté levantado, de su latencia y de que db.json no haya sido modificado por otra prueba. Se corrige con MSW.

Qué comprobaría primero: ejecutar el fichero en solitario y luego dentro de la suite completa. Es el diagnóstico más barato y separa de golpe las dos familias de causas. Si pasa en solitario y falla en la suite, el problema es de aislamiento (causas 2 y 3). Si falla en ambos casos de forma intermitente, el problema es de espera (causa 1). Sin esa bifurcación, cualquier corrección es a ciegas.

Conclusión

Esta lección ha puesto el criterio antes que la herramienta, que es el orden correcto: sin criterio, una batería de pruebas se convierte en un lastre que hay que «arreglar» en cada pull request.

Lo esencial que te llevas. Una prueba automatizada no es más que una función con tres partes —preparar, actuar y comprobar— y aporta tres cosas distintas: permite refactorizar sin miedo (el hueco exacto que dejaron las nueve refactorizaciones del módulo 8), sirve de documentación ejecutable que no puede mentir sin fallar, y detecta regresiones antes de que las cuente un usuario. Hay cuatro niveles —estática, unitaria, de integración y de extremo a extremo— y cada uno detecta fallos que los demás no ven: el linter caza el hook mal declarado, la unitaria el caso límite de 25 horas, la de integración el cableado roto entre piezas que funcionan por separado, y la e2e el CSS que tapa el botón de reservar. El reparto de peso ya no es la pirámide clásica sino el trofeo: en front-end moderno la mayoría de las pruebas son de integración, porque jsdom las ha abaratado, porque el análisis estático ha absorbido buena parte de las unitarias y porque ahí es donde están los fallos de verdad.

Por encima de todo queda el principio rector del módulo: se prueba el comportamiento visible, no la implementación. La prueba que afirma sobre tipoElegido falla al renombrar una variable y calla cuando el componente deja de avisar al padre —falla cuando no toca y calla cuando toca—; la que afirma que alCambiarTipo recibe 'electrica' sobrevive a useState, a useReducer y a subir el estado a Redux, y se rompe solo si se rompe algo. De ahí sale la lista de lo que no se prueba nunca: estado interno, clases de CSS Modules, número de renders y detalles de bibliotecas de terceros. Y el reparto para CicloUrbano: a fondo las utilidades puras, los reductores y los selectores; con integración los componentes y el formulario; con MSW las páginas con datos; con Cypress solo tres flujos críticos; y nada para los estilos, las constantes y el código de terceros.

Conoces también los falsos amigos. La cobertura es un mapa para encontrar huecos, no un objetivo: mide ejecución, no verificación, y una prueba sin una sola aserción puede dar el 100 %. Las pruebas frágiles fallan ante cambios inocuos y acaban con la credibilidad de la suite. Las inestables son peores, porque destruyen el significado del color rojo, y no se reintentan: se arreglan o se borran. Y sabes nombrar una prueba para que su fallo se entienda solo, con la fórmula sujeto + qué hace + en qué circunstancia.

Por último, el entorno está montado y verificado: Vitest con environment: 'jsdom', globals: true y setupFiles: './src/pruebas/configuracion.js'; los matchers de @testing-library/jest-dom/vitest registrados; localStorage limpio entre pruebas; los ficheros .test.jsx junto al código y las utilidades compartidas en src/pruebas/; y los scripts npm test, npm run test:ui y npm run cobertura. La prueba trivial de entorno.test.js confirma en 400 ms que la tubería entera funciona, que es lo que separa «mi prueba está mal» de «mi configuración está mal».

Con el criterio fijado y el entorno respirando, toca escribir pruebas de verdad. La próxima lección se queda deliberadamente fuera de React: el ejecutor por dentro, la anatomía de un fichero de pruebas con describe y sus ganchos, el catálogo de aserciones —empezando por la diferencia entre toBe y toEqual, que causa más confusión que ninguna otra—, los dobles de prueba con vi.fn() y vi.mock, y el control del tiempo con temporizadores falsos. Todo ello aplicado a validarReserva y al reductor sliceReservas, cerrando por fin lo que se prometió en 05-05 y 07-04: que un reductor puro se prueba llamándolo. Y como el índice manda, se hará desde el modelo de Jest, con la tabla de equivalencias que te permitirá llevarte lo aprendido a cualquier proyecto. La próxima lección es Pruebas Unitarias con Jest.

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