En la lección anterior quedó probado que validarReserva rechaza 25 horas y que el reductor sliceReservas marca una reserva como cancelada sin borrarla. Nada de eso garantiza que un usuario de CicloUrbano vea el mensaje de error junto al campo de duración, ni que el botón «Reservar» esté deshabilitado para una bicicleta en mantenimiento, ni que pulsar el filtro «Eléctrica» avise al padre. Esa mitad —la visible, la que el usuario toca— es la que cubre esta lección, y es donde el trofeo de pruebas pone su mayor peso.

La herramienta es React Testing Library, y su valor no está en el código que ahorra sino en la disciplina que impone: obliga a buscar los elementos como los busca una persona —por su rol, por su etiqueta, por su texto— en lugar de por clases CSS o estructura del DOM. Eso tiene una consecuencia que hay que anunciar de entrada: toda la accesibilidad del módulo 3 era, sin decirlo, la preparación para esto. Los label htmlFor, los roles, los nombres accesibles, el aria-describedby que asocia el error a su campo, el role="alert" de los mensajes… todo eso, que allí se justificó por los lectores de pantalla, es exactamente el mismo mecanismo que Testing Library usa para encontrar cosas. Un componente accesible es un componente fácil de probar, y uno inaccesible es casi imposible de probar bien. No es una coincidencia: es el mismo principio dos veces.

Contenido

  1. La filosofía de Testing Library
  2. jsdom: qué te da y qué no
  3. render y screen, y la limpieza entre pruebas
  4. Las tres familias de consultas: getBy, queryBy, findBy
  5. La prioridad de consultas y por qué getByRole va primero
  6. Depurar una consulta que falla
  7. Interacción: userEvent frente a fireEvent
  8. Las aserciones de jest-dom
  9. EtiquetaEstado: un componente de presentación
  10. TarjetaBicicleta: props, callbacks y estados
  11. SelectorTipo: probar un componente controlado
  12. FormularioReserva: el caso completo
  13. La utilidad renderizar con los proveedores reales
  14. Componentes que dependen de la ruta
  15. Qué no hay que probar nunca

  1. La filosofía de Testing Library

Testing Library se resume en una frase de su autor, Kent C. Dodds, que conviene tener presente al escribir cada consulta:

Cuanto más se parezcan tus pruebas a la forma en que se usa tu software, más confianza pueden darte.

De ahí sale todo lo demás. Un usuario de CicloUrbano no sabe que existe una clase .tarjeta_a3f9x, ni que el estado interno se llama tipoElegido, ni que el segundo hijo del tercer div contiene el precio. Lo que un usuario percibe es: hay un botón que pone «Reservar», hay un campo etiquetado «Duración (horas)», hay un texto que dice «En mantenimiento». Y lo que un usuario hace es: pulsar, escribir, tabular.

Por eso la biblioteca no ofrece ninguna forma de acceder al estado, a las props o a los métodos internos de un componente. No es una carencia: es la característica principal. La versión anterior de la herramienta estándar, Enzyme, sí lo permitía (wrapper.state(), wrapper.instance(), wrapper.find(MiComponente)), y el resultado fueron millones de pruebas frágiles que se rompían con cada refactorización. Testing Library eliminó la tentación quitando la posibilidad.

Lo que sí ofrece:

Testing Library te da Testing Library no te da
Renderizar un componente en un DOM real de jsdom Acceso al estado o a las props
Buscar elementos como lo haría una persona o un lector de pantalla Acceso a la instancia del componente
Simular interacciones reales (userEvent) Renderizado superficial (shallow)
Esperar a que algo aparezca o desaparezca Contar renders
Aserciones sobre el DOM (jest-dom) Buscar por nombre de componente

Ese «no hay renderizado superficial» merece una nota. En Enzyme era habitual renderizar un componente sin sus hijos, sustituyéndolos por marcadores. Testing Library renderiza siempre el árbol completo, y es lo correcto: si TarjetaBicicleta pinta EtiquetaEstado dentro, lo que importa es que el usuario vea «En mantenimiento», no que exista un elemento llamado EtiquetaEstado. Esto convierte casi todas las pruebas de componentes en pruebas de integración, que es exactamente lo que quiere el trofeo de 09-01.

  1. jsdom: qué te da y qué no

jsdom es una implementación de los estándares del DOM y del HTML escrita en JavaScript puro, que se ejecuta en Node. Cuando vite.config.js declara environment: 'jsdom', cada fichero de pruebas recibe un window, un document, un localStorage, un history y toda la API del DOM, sin abrir ningún navegador.

Es rápido —montar un componente cuesta decenas de milisegundos— pero es una simulación, y hay que conocer sus límites porque explican bastantes sorpresas:

Funciona en jsdom No funciona en jsdom
Estructura del DOM, atributos, eventos Diseño real: no hay motor de maquetación
localStorage, sessionStorage getBoundingClientRect() devuelve todo ceros
history.pushState y la API de navegación window.location.assign y la navegación de verdad
fetch (Node 18+) IntersectionObserver, ResizeObserver, matchMedia (hay que simularlos)
crypto.randomUUID Animaciones y transiciones CSS
Cálculo de estilos declarados en línea Cascada completa de CSS Modules o hojas externas
Eventos de teclado, ratón y foco Desplazamiento (scrollTo no hace nada)

Consecuencias prácticas para CicloUrbano:

  • toBeVisible() no comprueba visibilidad real. Comprueba display: none, visibility: hidden, hidden y opacity: 0 declarados en línea o en estilos que jsdom haya procesado. Un elemento tapado por otro con position: absolute se considera visible. Ese tipo de fallo solo lo detecta una prueba de extremo a extremo (09-05).
  • Las clases de CSS Modules existen como cadenas, pero no aplican estilo. Otra razón, además de la del apartado 15, para no afirmar nunca sobre ellas.
  • Si un componente usa IntersectionObserver —típico en carga perezosa de imágenes— hay que proporcionarlo en configuracion.js o la prueba fallará con «is not defined».
  • La navegación real no existe. Por eso los componentes que enrutan se prueban con createMemoryRouter (apartado 14), que mantiene la ruta en memoria.

  1. render y screen, y la limpieza entre pruebas

import { render, screen } from '@testing-library/react';
import EtiquetaEstado from './EtiquetaEstado.jsx';

test('muestra el texto del estado', () => {
  render(<EtiquetaEstado estado="disponible" />);
  expect(screen.getByText('Disponible')).toBeInTheDocument();
});

Qué hace cada pieza:

  • render(elemento) crea un <div> contenedor, lo añade a document.body y monta ahí el árbol de React. Devuelve un objeto con utilidades, de las que en la práctica solo se usan tres: rerender (volver a renderizar con otras props), unmount (desmontar, útil para probar limpiezas de efectos) y container (el nodo raíz, que casi nunca hace falta).
  • screen es un objeto con todas las consultas ya ligadas a document.body. Es la forma recomendada: screen.getByRole(...) en lugar de desestructurar const { getByRole } = render(...). La razón es doble: no hay que mantener una lista de consultas desestructuradas que crece con cada prueba, y screen encuentra también lo que se renderiza fuera del contenedor, como un modal montado con un portal —justo el caso de Modal y DialogoReserva en CicloUrbano.

Sobre la limpieza: Testing Library desmonta el árbol y vacía el body después de cada prueba de forma automática, siempre que el entorno tenga los ganchos globales (globals: true en Vitest, que es el caso). Sin esa limpieza, la segunda prueba encontraría dos botones «Reservar» —el suyo y el de la prueba anterior— y getByRole fallaría con «se han encontrado varios elementos». En src/pruebas/configuracion.js ya se dejó explícito en 09-01, junto con el localStorage.clear().

  1. Las tres familias de consultas: getBy, queryBy, findBy

Esta es la primera decisión de cada línea de prueba, y equivocarse produce mensajes de error confusos. Son tres familias, y cada una responde a una pregunta distinta:

Familia Si encuentra Si no encuentra Si encuentra varios ¿Asíncrona? Cuándo se usa
getBy… Devuelve el elemento Lanza error con el DOM volcado Lanza error No «Esto debe estar ahora»
queryBy… Devuelve el elemento Devuelve null Lanza error No «Esto no debe estar»
findBy… Devuelve una promesa resuelta Rechaza tras 1000 ms Rechaza «Esto aparecerá» (asíncrono)

Y cada una tiene su variante en plural, que devuelve un array y no falla por encontrar varios:

Plural Si no encuentra ninguno
getAllBy… Lanza error
queryAllBy… Devuelve []
findAllBy… Rechaza tras el tiempo de espera

Las reglas de uso, con los errores típicos:

// ✅ Existe: getBy. Si no está, el error incluye el DOM completo y se depura solo
expect(screen.getByRole('button', { name: 'Reservar' })).toBeInTheDocument();

// ✅ No existe: queryBy. Es la ÚNICA familia que puede devolver null sin fallar
expect(screen.queryByRole('button', { name: 'Cancelar reserva' })).not.toBeInTheDocument();

// ❌ MAL: getBy lanza antes de llegar a la aserción; el error dirá "no se ha encontrado",
//         que es confuso cuando lo que querías era justamente comprobar que no está
expect(screen.getByRole('button', { name: 'Cancelar reserva' })).not.toBeInTheDocument();

// ✅ Aparecerá tras una carga: findBy (y se espera con await)
expect(await screen.findByText('Urbana Clásica')).toBeInTheDocument();

// ❌ MAL: sin await, la aserción recibe una promesa, que siempre es "truthy"
expect(screen.findByText('Urbana Clásica')).toBeInTheDocument();

Esta lección usará casi siempre getBy y queryBy, porque todavía no hay asincronía de red: findBy es el protagonista de 09-04.

Un matiz sobre getAllBy, que aparece en cuanto se prueba una lista:

test('pinta una tarjeta por bicicleta', () => {
  render(<ListaBicicletas bicicletas={BICICLETAS} />);

  // Los modelos son encabezados <h3> dentro de cada <article>
  expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(5);
});

  1. La prioridad de consultas y por qué getByRole va primero

Testing Library define un orden de preferencia explícito, y seguirlo no es purismo: cada escalón que bajas aleja la prueba de lo que percibe el usuario y la acerca a los detalles de la implementación.

# Consulta Qué busca Cuándo usarla
1 getByRole El rol de accesibilidad, normalmente con { name } Siempre que se pueda. Es lo que ve un lector de pantalla
2 getByLabelText El elemento asociado a una <label> Campos de formulario. El caso natural del label htmlFor de 03-06
3 getByPlaceholderText El atributo placeholder Solo si el campo no tiene etiqueta —lo cual ya es un fallo de accesibilidad
4 getByText El contenido textual Elementos no interactivos: párrafos, mensajes, encabezados
5 getByDisplayValue El valor actual de un campo Comprobar un formulario ya rellenado
6 getByAltText El alt de una imagen Imágenes
7 getByTitle El atributo title Poco fiable: no se anuncia de forma consistente
8 getByTestId data-testid Último recurso, cuando no hay nada semántico a lo que agarrarse

Por qué getByRole es la primera opción

Porque una consulta por rol comprueba dos cosas a la vez:

screen.getByRole('button', { name: 'Reservar' })
  1. Que existe un elemento con rol de botón —un <button>, o algo con role="button"—, es decir, que es pulsable y enfocable de verdad.
  2. Que su nombre accesible es «Reservar», el texto que anunciaría un lector de pantalla.

Si alguien sustituye el <button> por un <div onClick>, esta consulta falla. Y debe fallar, porque ese cambio rompe el teclado y los lectores de pantalla, exactamente el fallo que se corrigió en 03-06 con la tarjeta pulsable. Una prueba escrita con roles es también una prueba de accesibilidad, gratis.

El nombre accesible se calcula, en este orden: aria-labelledby, aria-label, el contenido textual del elemento, el <label> asociado, title. Por eso funciona igual con estos tres:

<button>Reservar</button>
<button aria-label="Reservar">🚲</button>
<button aria-labelledby="titulo-accion">🚲</button>

Los roles más habituales en CicloUrbano:

Elemento HTML Rol implícito Ejemplo del proyecto
<button> button «Reservar», «Ver ficha», BotonTema
<a href> link Enlaces de MigasDePan y Cabecera
<h1><h6> heading (con level) El modelo en TarjetaBicicleta es heading nivel 3
<input type="text"> textbox El buscador
<input type="checkbox"> checkbox «Acepto las condiciones»
<input type="number"> spinbutton «Duración (horas)»
<select> combobox «Bicicleta» del formulario
<option> option Cada bicicleta disponible
<ul> / <li> list / listitem ListaBicicletas
<form> con nombre accesible form FormularioReserva
<nav> navigation La navegación principal
<article> article Cada TarjetaBicicleta
Elemento con role="alert" alert Los mensajes de error del formulario
Elemento con role="status" status La región aria-live de avisos

Fíjate en que <input type="number"> tiene rol spinbutton, no textbox: es de los que más despistan.

Opciones útiles de getByRole

screen.getByRole('button', { name: 'Reservar' })              // nombre exacto
screen.getByRole('button', { name: /reservar/i })             // expresión regular: robusto ante mayúsculas
screen.getByRole('heading', { level: 3 })                     // solo los h3
screen.getByRole('button', { name: 'Reservar', hidden: true })// incluye los ocultos a la accesibilidad
screen.getAllByRole('listitem')                               // todos los <li>
screen.getByRole('checkbox', { checked: true })               // por su estado

La variante con expresión regular merece un comentario: { name: /reservar/i } sobrevive a un cambio de mayúsculas o a que el diseño añada un icono al lado. Es la forma de no atarse al texto literal cuando el texto puede cambiar sin que cambie el comportamiento.

Cuándo es legítimo bajar a data-testid

getByTestId es el último escalón, y usarlo demasiado pronto anula la ventaja de la biblioteca: un data-testid no comprueba accesibilidad, no comprueba semántica y no se rompe cuando debería. Pero hay tres casos en los que es la respuesta correcta:

  1. Elementos sin rol ni texto estable: un contenedor de diseño, un gráfico, un lienzo. En CicloUrbano, el EsqueletoPagina es exactamente eso: no tiene texto porque es una silueta gris.
  2. Textos dinámicos que cambian a menudo por motivos editoriales y que harían frágil la prueba.
  3. Pruebas de extremo a extremo, donde data-testid es el contrato explícito entre la interfaz y las pruebas. Se argumenta a fondo en 09-05.
// Legítimo: el esqueleto no tiene texto ni rol
<div className={estilos.esqueleto} data-testid="esqueleto-pagina" aria-hidden="true" />
expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();

Antes de escribir un data-testid, hazte esta pregunta: ¿cómo encontraría este elemento una persona ciega? Si no hay respuesta, el problema no es la prueba: es el componente.

  1. Depurar una consulta que falla

Cuando getByRole no encuentra nada, Testing Library vuelca el DOM completo en el mensaje de error. Ese volcado es la herramienta de depuración principal, y hay que aprender a leerlo:

TestingLibraryElementError: Unable to find an accessible element with the role "button"
and name "Reservar"

Here are the accessible roles:
  article:
    Name "":
    <article class="tarjeta_a3f9x" />
  heading:
    Name "Urbana Clásica":
    <h3 />
  button:
    Name "Ver ficha":
    <button type="button" />
    Name "Reservar bicicleta":        ← ¡el nombre real es otro!
    <button type="button" />

Ahí está el diagnóstico: el botón existe y su rol es correcto, pero su nombre accesible es «Reservar bicicleta», no «Reservar». La solución es { name: /reservar/i } o el nombre completo.

Las tres herramientas de depuración, por orden de utilidad:

import { render, screen, logRoles } from '@testing-library/react';

test('depuración', () => {
  const { container } = render(<TarjetaBicicleta bicicleta={BICI} />);

  // 1) Volcar el DOM completo, con formato y colores
  screen.debug();

  // 2) Volcar solo un trozo
  screen.debug(screen.getByRole('article'));

  // 3) Listar TODOS los roles disponibles y sus nombres accesibles: lo más útil
  logRoles(container);
});
Herramienta Qué da Cuándo
screen.debug() El HTML renderizado, con formato «¿Se ha pintado siquiera?»
screen.debug(elemento) Solo ese subárbol Cuando el DOM es grande y el volcado es ilegible
logRoles(container) El árbol de roles con sus nombres accesibles «¿Por qué no encuentra mi botón?». Resuelve la mayoría de los casos
Testing Playground Interfaz web que sugiere la mejor consulta para cada elemento Al escribir la primera prueba de un componente nuevo

El Testing Playground se usa de dos formas: como extensión del navegador sobre la aplicación en marcha, o desde la prueba con screen.logTestingPlaygroundURL(), que imprime un enlace con el DOM actual ya cargado. Señalas un elemento y te dice la consulta recomendada, ordenada por prioridad. Es la manera más rápida de interiorizar la tabla del apartado 5.

Un detalle de configuración: por defecto screen.debug() corta el volcado a 7.000 caracteres. Para páginas grandes:

// vite.config.js, dentro de test
test: { environment: 'jsdom', globals: true, setupFiles: '…' }
// o puntualmente en la prueba
screen.debug(undefined, 30000);

  1. Interacción: userEvent frente a fireEvent

Hay dos formas de simular una interacción, y la diferencia importa más de lo que parece.

fireEvent dispara un evento del DOM, exactamente el que le pides:

fireEvent.click(boton);        // dispara un único evento 'click'
fireEvent.change(campo, { target: { value: 'urbana' } });   // un único 'change'

userEvent simula la secuencia completa de eventos que produce esa acción en un navegador real:

await usuario.click(boton);
// dispara, en orden: pointerover, pointerenter, pointermove, pointerdown,
// mousedown, focus, pointerup, mouseup, click
fireEvent userEvent
Qué dispara Un evento aislado La secuencia real completa
Foco No lo mueve Lo mueve, como un clic de verdad
Elementos deshabilitados Dispara igualmente No hace nada, como en un navegador
Escribir texto Asigna el valor de golpe Tecla a tecla, con keydown/keypress/input/keyup
Detecta fallos de accesibilidad No Sí (pointer-events: none, elementos ocultos)
API Síncrona Asíncrona: hay que esperarla
Cuándo usarlo Casos raros que userEvent no cubre Por defecto, siempre

La fila que decide es la de los elementos deshabilitados. Con fireEvent.click(botonDeshabilitado) el manejador se ejecuta y la prueba pasa, aunque en la aplicación real ese clic no haga nada: la prueba miente. userEvent reproduce el comportamiento del navegador y no llama al manejador, que es lo que debe verificarse en TarjetaBicicleta con una bicicleta en mantenimiento.

Lo mismo con la escritura: fireEvent.change asigna el valor de una vez, así que un componente con useDebounce o con validación por tecla no se comporta como en producción. userEvent.type teclea de verdad.

La API de userEvent

import userEvent from '@testing-library/user-event';

test('interacción completa', async () => {
  // setup() debe llamarse ANTES de render, y una vez por prueba
  const usuario = userEvent.setup();
  render(<FormularioReserva bicicletas={BICICLETAS} />);

  await usuario.click(screen.getByRole('button', { name: 'Reservar' }));
  await usuario.type(screen.getByLabelText('Duración (horas)'), '3');
  await usuario.clear(screen.getByLabelText('Duración (horas)'));
  await usuario.selectOptions(screen.getByLabelText('Bicicleta'), 'bici-001');
  await usuario.tab();                                   // mueve el foco al siguiente
  await usuario.keyboard('{Escape}');                    // pulsa Escape
  await usuario.keyboard('urbana{Enter}');               // escribe y pulsa Intro
  await usuario.hover(screen.getByRole('article'));
  await usuario.dblClick(screen.getByRole('button', { name: 'Ver ficha' }));
});
Método Qué simula
click(el) Un clic completo, con foco incluido
dblClick(el) Doble clic
type(el, texto) Escribir tecla a tecla en un campo enfocado
clear(el) Seleccionar todo y borrar
selectOptions(select, valor) Elegir una o varias opciones
tab() Avanzar el foco. tab({ shift: true }) retrocede
keyboard('{Enter}') Pulsaciones de teclado sueltas
hover(el) / unhover(el) Entrar y salir con el puntero
upload(input, fichero) Subir un fichero

Por qué todo es asíncrono y hay que esperarlo. Desde la versión 14, userEvent devuelve promesas por dos razones: internamente introduce pausas entre los eventos de la secuencia para parecerse a una persona, y envuelve las actualizaciones en act para que React procese el cambio de estado antes de devolver el control. Si olvidas el await, la aserción se ejecuta antes de que React haya repintado y ves el estado anterior. Con usuario.type el efecto es aún más visible: se pierden letras.

// ❌ Sin await: la aserción corre antes de que React actualice
usuario.click(boton);
expect(alReservar).toHaveBeenCalled();     // falla de forma intermitente

// ✅ Con await
await usuario.click(boton);
expect(alReservar).toHaveBeenCalled();

Regla mecánica: si la línea empieza por usuario., lleva await.

  1. Las aserciones de jest-dom

@testing-library/jest-dom, registrado en src/pruebas/configuracion.js desde 09-01, añade matchers que hablan de DOM. Sin ellos habría que escribir expect(el.disabled).toBe(true); con ellos, expect(el).toBeDisabled(), que además produce un mensaje de error mucho más claro.

Matcher Comprueba Ejemplo en CicloUrbano
toBeInTheDocument() El elemento está en el documento expect(screen.getByText('Disponible')).toBeInTheDocument()
toBeVisible() Está y es visible (con los límites del apartado 2) El panel tras abrir el acordeón
toBeDisabled() / toBeEnabled() Atributo disabled o aria-disabled El botón «Reservar» de una bicicleta en mantenimiento
toHaveValue(v) El valor de un campo expect(campoHoras).toHaveValue(2)
toHaveDisplayValue(v) El valor mostrado de un select La opción elegida
toBeChecked() Casilla o radio marcados «Acepto las condiciones»
toHaveTextContent(t) Contiene ese texto expect(tarjeta).toHaveTextContent('2,50 €')
toHaveAttribute(a, v) Tiene el atributo expect(campo).toHaveAttribute('aria-invalid', 'true')
toHaveAccessibleName(n) Su nombre accesible es ese expect(boton).toHaveAccessibleName('Reservar')
toHaveAccessibleDescription(d) Su descripción accesible (vía aria-describedby) El mensaje de error asociado al campo
toHaveClass(c) Tiene esa clase Evitar con CSS Modules (apartado 15)
toHaveFocus() Tiene el foco Tras usuario.tab()
toBeRequired() Es obligatorio Los campos del formulario
toBeInvalid() / toBeValid() aria-invalid o validación nativa El campo con error
toBeEmptyDOMElement() No tiene hijos La región aria-live antes del primer aviso

Los dos que más partido dan y menos se usan son toHaveAccessibleName y toHaveAccessibleDescription, porque verifican directamente lo que anunciaría un lector de pantalla. La segunda es la forma correcta de comprobar el aria-describedby de FormularioReserva, y se usará en el apartado 12.

  1. EtiquetaEstado: un componente de presentación

Empezamos por el más simple. EtiquetaEstado recibe un estado y lo pinta por tres canales: color (clase), símbolo (aria-hidden) y texto.

// src/componentes/EtiquetaEstado.test.jsx
import { render, screen } from '@testing-library/react';
import EtiquetaEstado from './EtiquetaEstado.jsx';

describe('EtiquetaEstado', () => {
  test.each([
    ['disponible',    'Disponible'],
    ['alquilada',     'Alquilada'],
    ['mantenimiento', 'En mantenimiento']
  ])('con estado "%s" muestra el texto "%s"', (estado, texto) => {
    render(<EtiquetaEstado estado={estado} />);
    expect(screen.getByText(texto)).toBeInTheDocument();
  });

  test('muestra un texto de reserva ante un estado desconocido', () => {
    render(<EtiquetaEstado estado="teletransportada" />);
    expect(screen.getByText('Desconocido')).toBeInTheDocument();
  });

  test('usa "disponible" cuando no recibe la prop', () => {
    render(<EtiquetaEstado />);
    expect(screen.getByText('Disponible')).toBeInTheDocument();
  });

  test('el símbolo decorativo se oculta a las tecnologías de asistencia', () => {
    render(<EtiquetaEstado estado="mantenimiento" />);

    // El nombre accesible de la etiqueta NO debe incluir el símbolo:
    // por eso lleva aria-hidden en 03-06
    expect(screen.getByText('En mantenimiento')).toBeInTheDocument();
    expect(screen.queryByText('🔧')).not.toBeInTheDocument();
  });
});

Comentarios sobre las decisiones:

  • test.each para los tres estados evita tres pruebas idénticas y hace que añadir un cuarto estado sea añadir una fila.
  • El caso del estado desconocido prueba el ?? DESCONOCIDO del componente. Es una rama que el usuario no debería ver nunca, y precisamente por eso nadie la comprobaría a mano.
  • La última prueba verifica la accesibilidad, no la apariencia. queryByText('🔧') devuelve null porque el <span aria-hidden="true"> está excluido del árbol de accesibilidad y las consultas por texto lo respetan. Si alguien quitara el aria-hidden, esta prueba fallaría y avisaría de que un lector de pantalla empezaría a leer «llave inglesa En mantenimiento».
  • No se comprueba ninguna clase CSS. Que el color sea verde o rojo no es verificable en jsdom ni es responsabilidad de esta prueba.

  1. TarjetaBicicleta: props, callbacks y estados

Aquí aparecen las dos cosas nuevas: verificar que se avisa al padre y verificar un comportamiento condicional.

// src/componentes/TarjetaBicicleta.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import TarjetaBicicleta from './TarjetaBicicleta.jsx';

const DISPONIBLE = {
  id: 'bici-001', modelo: 'Urbana Clásica', tipo: 'urbana',
  estado: 'disponible', estacionId: 'est-01', precioHora: 2.5
};

const EN_MANTENIMIENTO = {
  id: 'bici-003', modelo: 'Carga Max', tipo: 'carga',
  estado: 'mantenimiento', estacionId: 'est-02', precioHora: 5.5
};

// Fábrica de props: los callbacks son simulados y se devuelven para poder afirmar sobre ellos
function pintar(bicicleta, extras = {}) {
  const props = {
    bicicleta,
    nombreEstacion: 'Plaza Mayor',
    alSeleccionar: vi.fn(),
    alReservar: vi.fn(),
    ...extras
  };
  render(<TarjetaBicicleta {...props} />);
  return props;
}

describe('TarjetaBicicleta', () => {
  test('muestra el modelo, la estación, el estado y el precio por hora', () => {
    pintar(DISPONIBLE);

    expect(screen.getByRole('heading', { level: 3, name: 'Urbana Clásica' })).toBeInTheDocument();
    expect(screen.getByText('Plaza Mayor')).toBeInTheDocument();
    expect(screen.getByText('Disponible')).toBeInTheDocument();
    // El formateador de Intl produce el formato español: "2,50 €"
    expect(screen.getByText(/2,50\s*€\s*\/\s*hora/)).toBeInTheDocument();
  });

  test('avisa con el identificador al pulsar «Ver ficha»', async () => {
    const usuario = userEvent.setup();
    const { alSeleccionar, alReservar } = pintar(DISPONIBLE);

    await usuario.click(screen.getByRole('button', { name: 'Ver ficha' }));

    expect(alSeleccionar).toHaveBeenCalledTimes(1);
    expect(alSeleccionar).toHaveBeenCalledWith('bici-001');
    expect(alReservar).not.toHaveBeenCalled();      // no se dispara el otro por error
  });

  test('avisa con el identificador al reservar una bicicleta disponible', async () => {
    const usuario = userEvent.setup();
    const { alReservar } = pintar(DISPONIBLE);

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

    expect(alReservar).toHaveBeenCalledWith('bici-001');
  });

  describe('cuando la bicicleta no está disponible', () => {
    test('deshabilita el botón «Reservar»', () => {
      pintar(EN_MANTENIMIENTO);
      expect(screen.getByRole('button', { name: 'Reservar' })).toBeDisabled();
    });

    test('pulsar «Reservar» no avisa al padre', async () => {
      const usuario = userEvent.setup();
      const { alReservar } = pintar(EN_MANTENIMIENTO);

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

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

    test('«Ver ficha» sigue funcionando', async () => {
      const usuario = userEvent.setup();
      const { alSeleccionar } = pintar(EN_MANTENIMIENTO);

      await usuario.click(screen.getByRole('button', { name: 'Ver ficha' }));

      expect(alSeleccionar).toHaveBeenCalledWith('bici-003');
    });
  });
});

Lo que hay que retener de este bloque:

  • La fábrica pintar cumple aquí la misma función que datosValidos en 09-02: cada prueba dice solo lo que la distingue, y los callbacks simulados quedan disponibles para afirmar sobre ellos.
  • La prueba de «pulsar no avisa» es la que justifica userEvent. Con fireEvent.click sobre un botón deshabilitado, el manejador se ejecutaría y alReservar habría sido llamado: la prueba fallaría aunque el código sea correcto. Es el caso concreto de la tabla del apartado 7.
  • La aserción negativa expect(alReservar).not.toHaveBeenCalled() en la prueba de «Ver ficha» parece redundante, pero atrapa un fallo real y frecuente: dos manejadores intercambiados. Cumple la regla de 09-02 de acompañar lo positivo con lo negativo.
  • Se afirma sobre el precio con una expresión regular, no con la cadena exacta '2,50 € / hora'. Intl.NumberFormat inserta un espacio irrompible antes del símbolo del euro, y comparar la cadena literal produce un fallo desconcertante. La expresión regular con \s* es inmune.
  • En ningún momento se comprueba que el componente esté memorizado con memo. El memo de 08-02 es una optimización, no un comportamiento: probarlo sería probar la implementación.

  1. SelectorTipo: probar un componente controlado

SelectorTipo se volvió controlado en el módulo 4: ya no guarda el tipo, lo recibe por props y avisa de los cambios. Esa distinción cambia por completo lo que hay que probar.

// src/componentes/SelectorTipo.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import SelectorTipo from './SelectorTipo.jsx';

describe('SelectorTipo', () => {
  test('muestra un botón por cada tipo disponible', () => {
    render(<SelectorTipo tipo="todos" alCambiarTipo={vi.fn()} />);

    expect(screen.getByRole('button', { name: 'Todas' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Urbana' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Eléctrica' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'De carga' })).toBeInTheDocument();
  });

  test('avisa al padre con el tipo elegido al pulsar un filtro', 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).toHaveBeenCalledTimes(1);
    expect(alCambiarTipo).toHaveBeenCalledWith('electrica');
  });

  test('refleja el tipo activo que recibe por props', () => {
    render(<SelectorTipo tipo="carga" alCambiarTipo={vi.fn()} />);

    // El estado activo se expresa con aria-pressed, no con una clase CSS
    expect(screen.getByRole('button', { name: 'De carga', pressed: true })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Urbana', pressed: false })).toBeInTheDocument();
  });

  test('NO cambia el filtro activo por su cuenta: es un componente controlado', async () => {
    const usuario = userEvent.setup();
    render(<SelectorTipo tipo="todos" alCambiarTipo={vi.fn()} />);

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

    // El padre no ha cambiado la prop, así que "Todas" sigue siendo el activo.
    // Esto es correcto y es LA característica de un componente controlado.
    expect(screen.getByRole('button', { name: 'Todas', pressed: true })).toBeInTheDocument();
  });

  test('vuelve a «todos» al pulsar Escape', async () => {
    const usuario = userEvent.setup();
    const alCambiarTipo = vi.fn();
    render(<SelectorTipo tipo="electrica" alCambiarTipo={alCambiarTipo} />);

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

    expect(alCambiarTipo).toHaveBeenLastCalledWith('todos');
  });
});

La cuarta prueba es la que más enseña. Podría parecer un fallo —«pulso Urbana y no se marca»— pero es exactamente el contrato de un componente controlado: la fuente de la verdad está en el padre. Documentarlo con una prueba impide que alguien «arregle» el componente reintroduciendo estado interno y rompiendo la sincronización con Redux y con la URL.

Y fíjate en cómo se comprueba el filtro activo: con { pressed: true }, que consulta el atributo aria-pressed. Ni una palabra sobre estilos.activo. Si el diseño cambia el color del botón activo, la prueba sigue verde; si el botón deja de anunciar su estado, falla.

Sobre useTransition: SelectorTipo envuelve el cambio en una transición para no bloquear el tecleo (08-03). En jsdom las transiciones se resuelven de forma síncrona dentro del act que userEvent ya introduce, así que no requiere ningún tratamiento especial. Es un buen recordatorio del principio: la optimización es invisible para la prueba porque es invisible para el usuario.

  1. FormularioReserva: el caso completo

Este es el componente que más se beneficia de una prueba de integración, porque combina estado, valores derivados, accesibilidad y un callback hacia fuera. Y porque, tal y como está, ninguna prueba unitaria puede garantizar que el error se vea.

// src/componentes/FormularioReserva.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import FormularioReserva from './FormularioReserva.jsx';

const BICICLETAS = [
  { id: 'bici-001', modelo: 'Urbana Clásica', tipo: 'urbana',    estado: 'disponible',    estacionId: 'est-01', precioHora: 2.5 },
  { id: 'bici-003', modelo: 'Carga Max',      tipo: 'carga',     estado: 'mantenimiento', estacionId: 'est-02', precioHora: 5.5 },
  { id: 'bici-005', modelo: 'Eléctrica Pro',  tipo: 'electrica', estado: 'disponible',    estacionId: 'est-02', precioHora: 4.0 }
];

describe('FormularioReserva', () => {
  beforeEach(() => {
    vi.useFakeTimers({ shouldAdvanceTime: true });
    vi.setSystemTime(new Date('2026-05-04T08:00:00'));
  });

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

  test('solo ofrece las bicicletas disponibles', () => {
    render(<FormularioReserva bicicletas={BICICLETAS} />);

    const selector = screen.getByLabelText('Bicicleta');
    // 2 disponibles + la opción vacía inicial
    expect(screen.getAllByRole('option')).toHaveLength(3);
    expect(selector).toHaveDisplayValue('— Elige una bicicleta —');
    expect(screen.queryByRole('option', { name: /Carga Max/ })).not.toBeInTheDocument();
  });

  test('no muestra errores antes de tocar los campos', () => {
    render(<FormularioReserva bicicletas={BICICLETAS} />);
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  });
});

El useFakeTimers({ shouldAdvanceTime: true }) merece una explicación: se congela la fecha del sistema para que la validación de «no en el pasado» sea determinista, pero se deja que el reloj avance solo, porque userEvent introduce esperas internas entre eventos y con temporizadores completamente detenidos se quedaría bloqueado. Es una combinación que hay que conocer: fecha fija + reloj que avanza.

Enviar vacío y ver todos los errores

test('al enviar vacío muestra los cuatro errores y no llama al padre', async () => {
  const usuario = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  const alCrearReserva = vi.fn();
  render(<FormularioReserva bicicletas={BICICLETAS} alCrearReserva={alCrearReserva} />);

  await usuario.click(screen.getByRole('button', { name: 'Crear reserva' }));

  // role="alert" en cada mensaje: así los busca el usuario de lector de pantalla
  const errores = screen.getAllByRole('alert');
  expect(errores).toHaveLength(4);

  expect(screen.getByText(/Elige una bicicleta/)).toBeInTheDocument();
  expect(screen.getByText(/Indica cuándo empieza la reserva/)).toBeInTheDocument();
  expect(screen.getByText(/Debes aceptar las condiciones/)).toBeInTheDocument();

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

Consultar los errores con getAllByRole('alert') en lugar de por texto es una decisión deliberada: comprueba a la vez que los mensajes existen y que están marcados como alertas, que es lo que hace que un lector de pantalla los anuncie al aparecer. Vuelve a ser accesibilidad y prueba a la vez.

El error asociado a su campo mediante aria-describedby

Esta es la prueba más valiosa del componente, y la que enlaza directamente con 03-06:

test('asocia el error de duración al campo mediante su descripción accesible', async () => {
  const usuario = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  render(<FormularioReserva bicicletas={BICICLETAS} />);

  const campoHoras = screen.getByLabelText('Duración (horas)');

  await usuario.clear(campoHoras);
  await usuario.type(campoHoras, '25');
  await usuario.tab();                       // salir del campo lo marca como "tocado"

  // 1) El campo se anuncia como no válido
  expect(campoHoras).toBeInvalid();
  expect(campoHoras).toHaveAttribute('aria-invalid', 'true');

  // 2) Y su descripción accesible incluye el mensaje: eso es lo que lee
  //    un lector de pantalla al enfocar el campo. Verifica el aria-describedby completo.
  expect(campoHoras).toHaveAccessibleDescription(/La reserva máxima es de 24 horas/);
});

toHaveAccessibleDescription resuelve el aria-describedby, sigue los identificadores —incluida la cadena 'horas-ayuda horas-error'— y comprueba el texto resultante. Con una sola aserción se verifica que el error existe, que está en el DOM, que tiene el identificador correcto y que ese identificador está referenciado desde el campo correcto. Comprobarlo con getByText habría verificado solo lo primero, y el fallo real más frecuente —el error visible pero asociado al campo equivocado— habría pasado desapercibido.

El camino feliz completo

test('crea la reserva con los datos introducidos y limpia el formulario', async () => {
  const usuario = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
  const alCrearReserva = vi.fn();
  render(
    <FormularioReserva bicicletas={BICICLETAS} usuarioId="usr-01" alCrearReserva={alCrearReserva} />
  );

  await usuario.selectOptions(screen.getByLabelText('Bicicleta'), 'bici-005');

  const campoFecha = screen.getByLabelText('Inicio de la reserva');
  await usuario.type(campoFecha, '2026-05-04T10:00');

  const campoHoras = screen.getByLabelText('Duración (horas)');
  await usuario.clear(campoHoras);
  await usuario.type(campoHoras, '3');

  await usuario.click(screen.getByRole('checkbox', { name: /Acepto las condiciones/ }));

  // El total derivado aparece cuando los datos son válidos: 4,00 € × 3 h = 12,00 €
  expect(screen.getByText(/12,00\s*€/)).toBeInTheDocument();

  await usuario.click(screen.getByRole('button', { name: 'Crear reserva' }));

  expect(alCrearReserva).toHaveBeenCalledTimes(1);
  expect(alCrearReserva).toHaveBeenCalledWith(
    expect.objectContaining({
      id: expect.stringMatching(/^res-[a-z0-9]{8}$/),
      bicicletaId: 'bici-005',
      usuario: 'usr-01',
      fechaInicio: '2026-05-04T10:00',
      horas: 3,
      estado: 'activa'
    })
  );

  // Tras crear, el formulario vuelve a su estado inicial
  expect(screen.getByLabelText('Bicicleta')).toHaveValue('');
  expect(screen.getByRole('checkbox', { name: /Acepto las condiciones/ })).not.toBeChecked();
  expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

Detalles importantes:

  • expect.objectContaining con stringMatching para el identificador. El identificador se genera con crypto.randomUUID(), así que no se puede comparar con un valor fijo. Se comprueba el formato, que es lo que forma parte del contrato. Es el comparador asimétrico de 09-02 aplicado aquí.
  • La aserción sobre el total (12,00 €) verifica un valor derivado: precioHora × horas. Es la comprobación de que el formulario calcula bien sin necesidad de exponer el cálculo.
  • La comprobación de que el formulario se limpia es un comportamiento que un usuario percibe y que se rompe con facilidad al refactorizar el manejador de envío.
  • getByRole('checkbox', { name: /Acepto las condiciones/ }) funciona porque la casilla está dentro de su <label>, así que el texto de la etiqueta es su nombre accesible. Sin esa envoltura —el fallo de accesibilidad que se corrigió en 03-06— habría que recurrir a un data-testid.

  1. La utilidad renderizar con los proveedores reales

Todos los ejemplos anteriores prueban componentes autónomos. En cuanto uno consume useTema, useSelector o useQuery, un render desnudo revienta:

Error: useTema debe usarse dentro de <ProveedorTema>
Error: could not find react-redux context value
Error: No QueryClient set, use QueryClientProvider

Hay dos salidas. La mala: simular los hooks con vi.mock('react-redux'). La buena: envolver el componente con los proveedores de verdad. Y hay tres razones sólidas para preferir la segunda:

Simular los proveedores Usar los proveedores reales
La prueba deja de verificar la integración con Redux/Query, que es donde están los fallos Verifica el cableado completo
Hay que mantener sincronizada la simulación con la API real No hay nada que sincronizar
Un selector mal escrito pasa la prueba Un selector mal escrito la rompe
El componente puede exigir props que la simulación esconde Se prueba como se usa

Por eso el proyecto define su propio renderizar:

// src/pruebas/utilidades.jsx
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createMemoryRouter, RouterProvider } from 'react-router';

import reductorReservas from '../funcionalidades/reservas/sliceReservas.js';
import reductorCatalogo from '../funcionalidades/catalogo/sliceCatalogo.js';
import reductorSesion from '../funcionalidades/sesion/sliceSesion.js';
import { ProveedorTema } from '../contextos/ContextoTema.jsx';
import { ProveedorAvisos } from '../contextos/ContextoAvisos.jsx';

/**
 * Crea un almacén NUEVO por prueba, con los reductores reales.
 * `estadoInicial` permite colocar la aplicación en el escenario que se quiera probar.
 */
export function crearAlmacenDePrueba(estadoInicial = {}) {
  return configureStore({
    reducer: {
      reservas: reductorReservas,
      catalogo: reductorCatalogo,
      sesion: reductorSesion
    },
    preloadedState: estadoInicial
  });
}

/**
 * Crea un QueryClient NUEVO por prueba, sin reintentos ni caché persistente.
 */
export function crearClienteDePrueba() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,          // sin esto, un error tarda 3 reintentos en manifestarse
        gcTime: Infinity,      // la caché no se recoge durante la prueba
        staleTime: 0
      },
      mutations: { retry: false }
    }
  });
}

/**
 * Renderiza un componente con TODOS los proveedores reales de CicloUrbano.
 *
 * Opciones:
 *  - estadoInicial: estado precargado del almacén de Redux
 *  - almacen / cliente: instancias propias, si la prueba necesita inspeccionarlas
 *  - ruta: ruta inicial del enrutador en memoria (por defecto '/')
 *  - rutas: definición de rutas propia, para probar navegación
 *
 * Devuelve lo mismo que `render` MÁS `almacen` y `cliente`, para poder
 * despachar acciones o inspeccionar el estado desde la prueba.
 */
export function renderizar(elemento, opciones = {}) {
  const {
    estadoInicial,
    almacen = crearAlmacenDePrueba(estadoInicial),
    cliente = crearClienteDePrueba(),
    ruta = '/',
    rutas,
    ...opcionesDeRender
  } = opciones;

  const definicionDeRutas = rutas ?? [{ path: '*', element: elemento }];
  const enrutador = createMemoryRouter(definicionDeRutas, { initialEntries: [ruta] });

  function Envoltura() {
    return (
      <QueryClientProvider client={cliente}>
        <Provider store={almacen}>
          <ProveedorTema>
            <ProveedorAvisos>
              <RouterProvider router={enrutador} />
            </ProveedorAvisos>
          </ProveedorTema>
        </Provider>
      </QueryClientProvider>
    );
  }

  return {
    ...render(<Envoltura />, opcionesDeRender),
    almacen,
    cliente,
    enrutador
  };
}

// Se reexporta todo lo de Testing Library para que las pruebas importen de un solo sitio
export * from '@testing-library/react';
export { default as userEvent } from '@testing-library/user-event';

Decisiones que conviene entender:

  • El orden de los proveedores replica el de main.jsx: QueryClientProvider > Provider > Proveedores > RouterProvider. Si la prueba usara otro orden, podría pasar con un cableado que en producción falla.
  • Almacén y cliente nuevos en cada llamada. Es la prevención directa contra las pruebas inestables de 09-01: un QueryClient compartido filtra datos en caché de una prueba a la siguiente, y un almacén compartido arrastra reservas creadas en pruebas anteriores.
  • retry: false es obligatorio. Con los reintentos por defecto, una prueba de error esperaría tres intentos con retroceso exponencial antes de mostrar el mensaje, y agotaría el tiempo de espera. Es la causa número uno de «mi prueba de error se cuelga».
  • preloadedState permite colocar el escenario. Probar la pantalla de un operario es renderizar(<PaginaTaller />, { estadoInicial: { sesion: { usuario: MARC, cargando: false } } }), sin simular nada.
  • Se devuelven almacen, cliente y enrutador para poder afirmar sobre el estado resultante cuando haga falta. Con moderación: afirmar sobre el almacén es afirmar sobre la implementación, y solo se justifica cuando el efecto no es visible en pantalla.
  • La reexportación al final permite que cada fichero de prueba importe todo de '../pruebas/utilidades.jsx' en lugar de repartir importaciones entre tres paquetes.

Uso:

import { renderizar, screen, userEvent } from '../pruebas/utilidades.jsx';
import PanelReservas from './PanelReservas.jsx';

test('muestra las reservas del usuario identificado', () => {
  renderizar(<PanelReservas />, {
    estadoInicial: {
      sesion: { usuario: { id: 'usr-01', nombre: 'Ana Ribera', rol: 'cliente' }, cargando: false },
      reservas: {
        entidades: { 'res-01': { id: 'res-01', bicicletaId: 'bici-002', usuario: 'usr-01', horas: 2, estado: 'activa' } },
        ids: ['res-01'], estadoCarga: 'correcto', error: null, estadoEnvio: 'inactivo'
      }
    }
  });

  expect(screen.getByRole('heading', { name: /Tus reservas/ })).toBeInTheDocument();
  expect(screen.getAllByRole('listitem')).toHaveLength(1);
});
flowchart TB
    P["renderizar(elemento, opciones)"] --> Q["QueryClientProvider · cliente nuevo, retry: false"]
    Q --> R["Provider · almacén nuevo con preloadedState"]
    R --> S["ProveedorTema"]
    S --> T["ProveedorAvisos"]
    T --> U["RouterProvider · createMemoryRouter(initialEntries)"]
    U --> V["El componente bajo prueba"]

  1. Componentes que dependen de la ruta

Todo componente que use useParams, useNavigate, useSearchParams, <Link> o <Outlet> necesita un enrutador. createMemoryRouter mantiene el historial en memoria, sin tocar la URL del navegador —que en jsdom no existe de verdad.

Un componente que lee un parámetro de ruta

import { renderizar, screen } from '../pruebas/utilidades.jsx';
import PaginaFichaBicicleta from './PaginaFichaBicicleta.jsx';

test('muestra la bicicleta indicada en la URL', () => {
  renderizar(null, {
    ruta: '/bicicletas/bici-002',
    rutas: [{ path: '/bicicletas/:bicicletaId', element: <PaginaFichaBicicleta /> }]
  });

  expect(screen.getByRole('heading', { name: 'Eléctrica Pro' })).toBeInTheDocument();
});

Un componente que lee la cadena de consulta

El filtro ?tipo= de PaginaCatalogo vive en la URL desde el módulo 6:

test('aplica el filtro de tipo que llega en la URL', () => {
  renderizar(<PaginaCatalogo />, { ruta: '/?tipo=electrica' });

  expect(screen.getByRole('button', { name: 'Eléctrica', pressed: true })).toBeInTheDocument();
});

Un componente que navega

Cuando la acción produce una navegación, lo que se comprueba es el destino, no que se haya llamado a useNavigate:

test('«Ver ficha» lleva a la ficha de la bicicleta', async () => {
  const usuario = userEvent.setup();

  const { enrutador } = renderizar(null, {
    ruta: '/',
    rutas: [
      { path: '/', element: <PaginaCatalogo /> },
      { path: '/bicicletas/:bicicletaId', element: <h1>Ficha de bicicleta</h1> }
    ]
  });

  await usuario.click(screen.getAllByRole('button', { name: 'Ver ficha' })[0]);

  // Dos comprobaciones equivalentes; la primera es la que ve el usuario
  expect(screen.getByRole('heading', { name: 'Ficha de bicicleta' })).toBeInTheDocument();
  expect(enrutador.state.location.pathname).toBe('/bicicletas/bici-001');
});

La ruta de destino se sustituye por un componente mínimo (<h1>Ficha de bicicleta</h1>) a propósito: la prueba verifica la navegación, y montar la página real traería sus consultas, sus dependencias y sus fallos, convirtiendo un fallo de navegación en un fallo de carga de datos y arruinando el diagnóstico.

Comparado con la alternativa que se ve a menudo —simular useNavigate con vi.mock('react-router') y comprobar que se llamó con '/bicicletas/bici-001'—, esta forma es mejor por lo de siempre: la simulación comprueba que se pidió navegar; el enrutador en memoria comprueba que se navegó, incluida la construcción correcta de la URL y la existencia de una ruta que la atienda.

  1. Qué no hay que probar nunca

Recapitulación operativa del principio de 09-01, ahora con nombre y apellidos:

❌ No pruebes Por qué ✅ Prueba en su lugar
El estado interno (tipoElegido, tocados) Es implementación; RTL ni siquiera lo permite Lo que el estado produce en pantalla
Nombres de clases CSS (estilos.activo) Son hashes generados por CSS Modules; cambian solos y no aplican estilo en jsdom El atributo semántico: aria-pressed, aria-invalid, disabled
El número de renders Es rendimiento, no comportamiento Mide con el Profiler (08-05)
Que se haya llamado a useSelector o a useNavigate Detalle de la biblioteca El resultado: lo pintado, la ruta alcanzada
Que un componente esté envuelto en memo Optimización invisible al usuario Nada: no es comportamiento
La estructura exacta del DOM (container.querySelector('div > p')) Se rompe con cualquier cambio de maquetación Consultas por rol y por texto
Funciones no exportadas Si merecen prueba, expórtalas Su efecto a través de la API pública
Que React Router redirija, que Query cachee Código de terceros, ya probado Cómo tu código los usa

Y la señal de alarma más fiable: si tienes que leer el código del componente para saber qué consultar en la prueba, la prueba está acoplada. Una buena prueba de componente se escribe mirando la pantalla, no el fichero .jsx.

Errores Comunes y Consejos

  • Usar getBy para comprobar que algo no existe. getBy lanza al no encontrar, así que la prueba falla con un mensaje engañoso antes de llegar al .not. Para ausencias, siempre queryBy.
  • Olvidar el await en userEvent. Produce fallos intermitentes, letras perdidas al escribir y avisos de act. Regla: toda línea que empiece por usuario. lleva await.
  • Llamar a userEvent.setup() después de render. Debe ir antes, porque setup instala la configuración de los eventos sobre el documento. Y una vez por prueba, nunca en el ámbito del describe.
  • Usar fireEvent por costumbre. Deja pasar clics sobre botones deshabilitados y no mueve el foco, así que valida comportamientos que en producción no ocurren. Solo para lo que userEvent no cubre.
  • Comparar textos formateados con cadenas literales. Intl.NumberFormat usa espacio irrompible antes del símbolo del euro y coma decimal en español. Usa expresiones regulares con \s* o toHaveTextContent.
  • Afirmar sobre clases de CSS Modules. El nombre real es tarjeta_a3f9x, cambia en cada compilación y no representa nada visible en jsdom. Si el estado importa, exprésalo con un atributo ARIA y compruébalo por rol.
  • Empezar por getByTestId. Es cómodo y desactiva la mitad del valor de la biblioteca. Baja la escalera de prioridades solo cuando los escalones superiores no existan, y pregúntate antes si el problema es que el componente no es accesible.
  • Envolver todo en act a mano. render y userEvent ya lo hacen. Si aparece el aviso de act, la causa suele ser una actualización asíncrona sin esperar, y se explica a fondo en 09-04.
  • Consejo: escribe la primera aserción antes que la interacción. Comprobar el estado inicial —«no hay errores», «el botón está deshabilitado»— documenta el punto de partida y hace evidente qué cambia la interacción.
  • Consejo: cuando una consulta falle, empieza por logRoles(container). Resuelve la mayoría de los casos en diez segundos, y de paso te enseña qué roles tiene realmente tu maquetación, que a menudo no son los que creías.
  • Consejo: si probar un componente exige diez proveedores y quince props, el componente hace demasiado. La dificultad de la prueba es un indicador de diseño, igual que en 09-02.

Ejercicios

Ejercicio 1. Escribe la suite de BuscadorBicicletas, que recibe la prop alBuscar y contiene un campo de texto etiquetado «Buscar bicicletas». Cubre: que el campo aparece con su etiqueta, que empieza vacío, que al escribir «urbana» el campo muestra ese valor, y que al pulsar el botón «Limpiar» el campo se vacía y se avisa con la cadena vacía. No pruebes el retardo del useDebounce: explica en un comentario por qué esa parte corresponde a 09-04 y qué haría falta para probarla aquí.

Ejercicio 2. Esta prueba de TarjetaEstacion está escrita con el estilo equivocado. Identifica cinco problemas, reescríbela siguiendo la prioridad de consultas y explica qué fallo real detectaría tu versión que la original deja pasar.

test('TarjetaEstacion', () => {
  const { container } = render(
    <TarjetaEstacion estacion={{ id: 'est-01', nombre: 'Plaza Mayor', barrio: 'Centro', plazas: 20 }} />
  );
  expect(container.querySelector('.tarjeta-estacion')).toBeTruthy();
  expect(container.querySelectorAll('p')[0].textContent).toBe('Centro');
  expect(container.querySelectorAll('p')[1].textContent).toBe('20 plazas');
  fireEvent.click(container.querySelector('button'));
  expect(container.querySelector('.detalle')).toBeTruthy();
});

Ejercicio 3. RequiereRol protege PaginaTaller: si el usuario de la sesión no tiene rol operario, redirige a /sin-permisos. Escribe dos pruebas usando renderizar —una para Ana Ribera (cliente) y otra para Marc Solé (operario)— y explica por qué usar los proveedores reales es superior a simular useSelector en este caso concreto.

Soluciones

Solución 1.

// src/componentes/BuscadorBicicletas.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect, vi } from 'vitest';
import BuscadorBicicletas from './BuscadorBicicletas.jsx';

describe('BuscadorBicicletas', () => {
  test('muestra un campo de búsqueda etiquetado y vacío', () => {
    render(<BuscadorBicicletas alBuscar={vi.fn()} />);

    const campo = screen.getByRole('textbox', { name: 'Buscar bicicletas' });
    expect(campo).toBeInTheDocument();
    expect(campo).toHaveValue('');
  });

  test('refleja lo que el usuario escribe', async () => {
    const usuario = userEvent.setup();
    render(<BuscadorBicicletas alBuscar={vi.fn()} />);

    const campo = screen.getByRole('textbox', { name: 'Buscar bicicletas' });
    await usuario.type(campo, 'urbana');

    expect(campo).toHaveValue('urbana');
  });

  test('«Limpiar» vacía el campo y avisa con la cadena vacía', async () => {
    const usuario = userEvent.setup();
    const alBuscar = vi.fn();
    render(<BuscadorBicicletas alBuscar={alBuscar} />);

    const campo = screen.getByRole('textbox', { name: 'Buscar bicicletas' });
    await usuario.type(campo, 'urbana');
    await usuario.click(screen.getByRole('button', { name: 'Limpiar' }));

    expect(campo).toHaveValue('');
    expect(alBuscar).toHaveBeenLastCalledWith('');
  });

  // NO se prueba aquí que `alBuscar` se llame 400 ms después de dejar de escribir.
  // Ese comportamiento depende de un temporizador dentro de un efecto de React, así que
  // exige combinar `vi.useFakeTimers()` con `advanceTimersByTime` DENTRO de un `act`,
  // y coordinarlo con las esperas internas de userEvent. Es materia de 09-04, donde se
  // prueba `useDebounce` con `renderHook`. El mecanismo puro ya quedó probado en 09-02
  // con la función `retrasar`, sin React de por medio.
});

Solución 2. Los cinco problemas:

  1. El nombre no describe ningún comportamiento. test('TarjetaEstacion') debería ser un describe, con pruebas que digan qué se verifica.
  2. container.querySelector('.tarjeta-estacion') afirma sobre una clase CSS. Con CSS Modules ese nombre ni siquiera existe tal cual, y aunque existiera no es algo que el usuario perciba.
  3. querySelectorAll('p')[0] y [1] atan la prueba al orden de los párrafos en la maquetación. Intercambiar barrio y plazas por motivos de diseño rompería la prueba sin romper nada real.
  4. fireEvent.click sobre querySelector('button'). Dos fallos en una línea: se selecciona «el primer botón que haya», sea cual sea, y se usa fireEvent, que dispararía el manejador incluso si el botón estuviera deshabilitado.
  5. Cinco comprobaciones inconexas en una sola prueba. Al fallar, el informe solo dice «TarjetaEstacion», sin indicar si falló la información, la interacción o el detalle.

Reescrita:

describe('TarjetaEstacion', () => {
  const ESTACION = { id: 'est-01', nombre: 'Plaza Mayor', barrio: 'Centro', plazas: 20 };

  test('muestra el nombre, el barrio y el número de plazas', () => {
    render(<TarjetaEstacion estacion={ESTACION} />);

    expect(screen.getByRole('heading', { name: 'Plaza Mayor' })).toBeInTheDocument();
    expect(screen.getByText('Centro')).toBeInTheDocument();
    expect(screen.getByText(/20 plazas/)).toBeInTheDocument();
  });

  test('el detalle está oculto hasta que se pide', () => {
    render(<TarjetaEstacion estacion={ESTACION} />);

    expect(screen.getByRole('button', { name: /Ver detalle/ })).toHaveAttribute('aria-expanded', 'false');
    expect(screen.queryByRole('region', { name: /Detalle de Plaza Mayor/ })).not.toBeInTheDocument();
  });

  test('muestra el detalle al pulsar «Ver detalle»', async () => {
    const usuario = userEvent.setup();
    render(<TarjetaEstacion estacion={ESTACION} />);

    await usuario.click(screen.getByRole('button', { name: /Ver detalle/ }));

    expect(screen.getByRole('region', { name: /Detalle de Plaza Mayor/ })).toBeVisible();
    expect(screen.getByRole('button', { name: /Ver detalle/ })).toHaveAttribute('aria-expanded', 'true');
  });
});

Qué fallo detecta la nueva versión y la original no: que el botón deje de anunciar su estado con aria-expanded. La original comprueba que aparece un elemento con clase .detalle; si alguien elimina el aria-expanded o convierte el botón en un <div onClick>, la original sigue verde y la accesibilidad queda rota. La nueva falla, porque getByRole('button', …) exige un rol de botón real y la aserción exige el atributo. Además, la nueva detectaría que el detalle ya estaba visible desde el principio, cosa que la original no comprueba en ningún momento.

Solución 3.

import { renderizar, screen } from '../pruebas/utilidades.jsx';
import RequiereRol from './RequiereRol.jsx';
import PaginaTaller from '../paginas/PaginaTaller.jsx';

const ANA  = { id: 'usr-01', nombre: 'Ana Ribera', correo: '[email protected]', rol: 'cliente' };
const MARC = { id: 'usr-02', nombre: 'Marc Solé', correo: '[email protected]', rol: 'operario' };

const RUTAS = [
  {
    path: '/taller',
    element: <RequiereRol rol="operario"><PaginaTaller /></RequiereRol>
  },
  { path: '/sin-permisos', element: <h1>Sin permisos</h1> }
];

describe('RequiereRol sobre PaginaTaller', () => {
  test('deja pasar a un operario', () => {
    renderizar(null, {
      ruta: '/taller',
      rutas: RUTAS,
      estadoInicial: { sesion: { usuario: MARC, cargando: false, error: null } }
    });

    expect(screen.getByRole('heading', { name: /Taller/ })).toBeInTheDocument();
    expect(screen.queryByRole('heading', { name: 'Sin permisos' })).not.toBeInTheDocument();
  });

  test('redirige a /sin-permisos a un cliente', () => {
    const { enrutador } = renderizar(null, {
      ruta: '/taller',
      rutas: RUTAS,
      estadoInicial: { sesion: { usuario: ANA, cargando: false, error: null } }
    });

    expect(screen.getByRole('heading', { name: 'Sin permisos' })).toBeInTheDocument();
    expect(enrutador.state.location.pathname).toBe('/sin-permisos');
  });
});

Por qué los proveedores reales son superiores aquí: RequiereRol no lee el rol directamente, sino a través del selector seleccionarEsOperario, que en 07-04 se decidió derivar en lugar de guardar. Si se simulara useSelector para que devolviera true, la prueba pasaría por alto exactamente la pieza que puede fallar: que el selector derive bien el rol a partir del usuario. Con el almacén real y preloadedState, se ejercita la cadena completa —usuario en el estado → selector → decisión del guardián → redirección del enrutador—, que es donde vive el fallo de verdad. Y hay un segundo motivo: con la simulación no se comprobaría a dónde redirige, solo que decidió redirigir; con createMemoryRouter se verifica el destino real, que es lo que el usuario experimenta.

Conclusión

Con esta lección, CicloUrbano tiene por fin pruebas de lo que el usuario ve y toca, y el peso del trofeo de 09-01 queda donde debe estar.

Lo esencial. La filosofía de Testing Library es una restricción deliberada: no da acceso al estado ni a las props, porque cuanto más se parezca la prueba al uso real, más confianza aporta. Renderiza siempre el árbol completo, sin renderizado superficial, así que casi toda prueba de componente es en realidad una prueba de integración. Corre sobre jsdom, que da DOM, eventos, localStorage e historial en milisegundos, pero no da maquetación real, ni getBoundingClientRect, ni cascada de CSS, ni navegación de verdad: por eso toBeVisible no detecta un botón tapado y por eso hace falta Cypress (09-05).

Sabes elegir entre las tres familias de consultas: getBy cuando algo debe estar, queryBy —y solo queryBy— cuando algo no debe estar, y findBy cuando aparecerá. Y conoces la escalera de prioridades, con getByRole en el primer escalón porque comprueba dos cosas a la vez: que el elemento tiene el rol correcto y que su nombre accesible es el esperado. Ahí está la conexión que anunciaba la introducción: los label htmlFor, los roles, los nombres accesibles y el aria-describedby de 03-06 no eran una tarea aparte, eran los agarres que ahora usan las pruebas. Un <div onClick> en lugar de un <button> rompe la prueba, y debe romperla. data-testid queda como último recurso legítimo —elementos sin semántica, como EsqueletoPagina— y como contrato explícito en las pruebas de extremo a extremo. Y cuando una consulta falle, ya tienes el orden de depuración: leer el volcado del DOM, screen.debug(), logRoles(container) y el Testing Playground.

Para interactuar, userEvent siempre, porque simula la secuencia completa de eventos, mueve el foco y —lo decisivo— no dispara nada sobre un elemento deshabilitado, a diferencia de fireEvent, que validaría comportamientos imposibles en producción. Todas sus llamadas son asíncronas: si la línea empieza por usuario., lleva await. Y las aserciones de jest-dom dan un vocabulario que habla de interfaz, con dos joyas infrautilizadas: toHaveAccessibleName y toHaveAccessibleDescription.

Los cuatro casos de CicloUrbano han ido subiendo de dificultad. EtiquetaEstado ha probado presentación pura y, de paso, que el símbolo decorativo sigue oculto al lector de pantalla. TarjetaBicicleta ha introducido los callbacks con vi.fn() y el caso que justifica userEvent: pulsar «Reservar» en una bicicleta en mantenimiento no debe avisar al padre. SelectorTipo ha enseñado a probar un componente controlado: se verifica que avisa con 'electrica' y que no cambia el filtro por su cuenta, y el estado activo se consulta con { pressed: true }, no con una clase. Y FormularioReserva ha cerrado el círculo del módulo 3: cuatro role="alert" al enviar vacío, y sobre todo toHaveAccessibleDescription, que en una sola aserción comprueba que el mensaje existe, que tiene el identificador correcto y que el campo lo referencia —el fallo del error visible pero mal asociado, que ninguna otra comprobación detecta.

Queda fijada la infraestructura: src/pruebas/utilidades.jsx con crearAlmacenDePrueba, crearClienteDePrueba y el renderizar propio, que envuelve el componente con los proveedores reales en el mismo orden que main.jsx y devuelve almacen, cliente y enrutador. Usar los proveedores de verdad, y no simularlos, es lo que hace que un selector mal escrito rompa la prueba en lugar de colarse. Y retry: false en el QueryClient no es un detalle: sin él, cualquier prueba de error se cuelga esperando tres reintentos. Para las rutas, createMemoryRouter con initialEntries permite probar parámetros (/bicicletas/bici-002), cadena de consulta (?tipo=electrica) y navegación real, comprobando el destino alcanzado en vez de que se llamara a useNavigate.

Y la lista de lo que no se prueba nunca ya tiene nombres concretos: estado interno, clases de CSS Modules, número de renders, llamadas a hooks de bibliotecas, memo, estructura del DOM y funciones no exportadas. Con la señal de alarma que las resume: si necesitas leer el .jsx para saber qué consultar, la prueba está acoplada.

Falta el terreno donde fallan la mayoría de las pruebas del mundo real: la asincronía. Todo lo de esta lección era síncrono; en cuanto PaginaCatalogo pida las bicicletas a la API, la aserción se ejecutará antes de que llegue la respuesta y aparecerá el temido aviso de act(...). La próxima lección explica ese aviso de verdad, presenta las herramientas de espera (findBy, waitFor, waitForElementToBeRemoved) y monta MSW para interceptar la red a nivel de protocolo, con src/pruebas/manejadores.js y src/pruebas/servidor.js, de modo que se puedan provocar a voluntad los tres estados de la interfaz: cargando, éxito y error. Además se prueban las mutaciones de TanStack Query y los hooks personalizados con renderHook. La próxima lección es Pruebas de Código Asíncrono y Simulación de APIs.

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