Todas las pruebas de la lección anterior eran síncronas: se renderizaba un componente con sus props, se pulsaba un botón y se comprobaba el resultado en la misma vuelta del bucle de eventos. La aplicación real no funciona así. PaginaCatalogo pide las bicicletas a http://localhost:3001/bicicletas y pinta un esqueleto mientras espera; useCrearReserva envía un POST y luego invalida dos consultas; useDebounce retrasa 400 ms; LimiteDeError captura un fallo que ocurre después del primer render. En cuanto entra el tiempo en la ecuación, una prueba escrita como las anteriores falla —o, peor, pasa por casualidad.
Esta lección cubre el terreno donde se rompe la mayoría de las pruebas de front-end. Primero, las herramientas de espera de Testing Library y el aviso de act(...), que casi todo el mundo silencia sin entender. Después, MSW, que intercepta la red a nivel de protocolo y permite provocar a voluntad un error 500, una lista vacía o una respuesta lenta, para comprobar los tres estados de cualquier pantalla: cargando, éxito y error. Y finalmente los hooks personalizados con renderHook, incluido el caso difícil de combinar temporizadores falsos con React.
Contenido
- Por qué la asincronía rompe una prueba ingenua
- Las herramientas de espera:
findBy,waitForywaitForElementToBeRemoved - El aviso
act(...), explicado de verdad - Simular la red: por qué a nivel de protocolo y no del módulo
- MSW v2: instalación y manejadores de CicloUrbano
servidor.jsy el enganche enconfiguracion.js- Probar componentes con TanStack Query
- Los tres estados de la interfaz: cargando, éxito y error
server.use: sobrescribir un manejador en una prueba concreta- Probar una mutación completa
renderHook: probar hooks personalizadosuseDebounce: temporizadores falsos dentro de ReactuseAlmacenLocaly el almacenamiento del navegador- Probar un componente que navega tras una operación
- Probar
LimiteDeErrory silenciar el ruido esperado - Cómo no escribir pruebas inestables
- Por qué la asincronía rompe una prueba ingenua
// ❌ Esta prueba falla SIEMPRE
test('muestra las bicicletas del catálogo', () => {
renderizar(<PaginaCatalogo />);
expect(screen.getByText('Urbana Clásica')).toBeInTheDocument();
});TestingLibraryElementError: Unable to find an element with the text: Urbana Clásica
<body>
<div>
<div data-testid="esqueleto-pagina" aria-hidden="true" />
</div>
</body>El volcado del DOM lo explica todo: en el instante en que se ejecuta la aserción, el componente ha renderizado una sola vez y está en su estado isPending, pintando el esqueleto. La petición se ha lanzado, pero la respuesta no ha llegado; llegará en algún momento de una microtarea futura, y para entonces la prueba ya habrá terminado.
Esta es la secuencia real, y conviene tenerla clara:
sequenceDiagram
participant P as Prueba
participant R as React
participant Q as TanStack Query
participant N as Red (MSW)
P->>R: renderizar(<PaginaCatalogo />)
R->>Q: useBicicletas()
Q->>N: GET /bicicletas
R-->>P: primer render · isPending · esqueleto
Note over P: ❌ getByText falla AQUÍ
N-->>Q: 200 · [ …5 bicicletas… ]
Q->>R: datos disponibles
R-->>P: segundo render · lista pintada
Note over P: ✅ aquí sí estaría
Hay dos formas de resolverlo, y solo una es correcta:
// ❌ MAL: esperar un tiempo fijo
await new Promise((r) => setTimeout(r, 100));
expect(screen.getByText('Urbana Clásica')).toBeInTheDocument();
// ✅ BIEN: esperar A QUE APAREZCA
expect(await screen.findByText('Urbana Clásica')).toBeInTheDocument();Por qué el setTimeout manual está prohibido en este proyecto, con tres argumentos independientes: es lento (siempre espera los 100 ms, aunque el dato llegue en 3); es inestable (en una máquina cargada de integración continua, 100 ms pueden no bastar, y la prueba falla una de cada veinte ejecuciones); y no dice qué espera (el día que falle, nadie sabrá si el problema es el retardo o la aplicación). La espera por resultado, en cambio, termina en cuanto el elemento aparece y falla con un mensaje que nombra lo que buscaba.
- Las herramientas de espera:
findBy, waitFor y waitForElementToBeRemoved
findBy, waitFor y waitForElementToBeRemovedTesting Library ofrece tres, y elegir bien simplifica mucho el código.
| Herramienta | Espera a que… | Devuelve | Cuándo usarla |
|---|---|---|---|
findBy* / findAllBy* |
Un elemento aparezca | El elemento (promesa) | La opción por defecto. Cubre el 80 % de los casos |
waitFor(fn) |
Una aserción cualquiera deje de lanzar | El valor devuelto por fn |
Cuando lo que cambia no es la presencia de un elemento |
waitForElementToBeRemoved(el) |
Un elemento desaparezca | undefined |
Comprobar que el indicador de carga se va |
Todas reintentan cada 50 ms hasta agotar 1.000 ms por defecto, y todas fallan con un mensaje útil que incluye el DOM.
// 1) findBy: lo más habitual
const titulo = await screen.findByRole('heading', { name: 'Eléctrica Pro' });
// 2) findAllBy: una lista que llega del servidor
const tarjetas = await screen.findAllByRole('article');
expect(tarjetas).toHaveLength(5);
// 3) waitFor: cuando la aserción no es "existe un elemento"
await waitFor(() => {
expect(alCrearReserva).toHaveBeenCalledTimes(1);
});
// 4) waitForElementToBeRemoved: el esqueleto se va
await waitForElementToBeRemoved(() => screen.queryByTestId('esqueleto-pagina'));
// 5) Ajustar el tiempo de espera cuando esté justificado
const lento = await screen.findByText('Listo', {}, { timeout: 3000 });findBy es getBy + waitFor combinados, literalmente: internamente hace waitFor(() => getBy(...)). Por eso, siempre que lo que esperas sea la aparición de un elemento, findBy es más corto y produce mejores mensajes de error.
Las dos reglas de waitFor
Regla 1: dentro de waitFor no puede haber efectos secundarios. La función se ejecuta muchas veces —hasta que deje de lanzar—, así que cualquier acción que provoque un cambio se repetirá:
// ❌ MAL: el clic se ejecutaría varias veces
await waitFor(async () => {
await usuario.click(screen.getByRole('button', { name: 'Reservar' }));
expect(alReservar).toHaveBeenCalled();
});
// ✅ BIEN: el efecto fuera, la aserción dentro
await usuario.click(screen.getByRole('button', { name: 'Reservar' }));
await waitFor(() => expect(alReservar).toHaveBeenCalled());Regla 2: una sola aserción por waitFor. Si metes tres, la función se reintenta hasta que las tres pasen a la vez, y cuando falle no sabrás cuál era. Peor: si la primera pasa y la tercera nunca lo hace, el mensaje de error señalará la tercera pero habrás perdido un segundo entero reintentando las tres.
// ❌ Innecesariamente frágil y lento
await waitFor(() => {
expect(screen.getByText('Urbana Clásica')).toBeInTheDocument();
expect(screen.getByText('Eléctrica Pro')).toBeInTheDocument();
expect(screen.getAllByRole('article')).toHaveLength(5);
});
// ✅ Esperar una vez, afirmar las demás de forma síncrona
await screen.findByText('Urbana Clásica');
expect(screen.getByText('Eléctrica Pro')).toBeInTheDocument();
expect(screen.getAllByRole('article')).toHaveLength(5);El patrón de la segunda versión es el que se usará en toda la lección: una espera al principio, aserciones síncronas después. Cuando el primer elemento ha llegado, el render ya ha ocurrido y el resto está en el DOM.
- El aviso
act(...), explicado de verdad
act(...), explicado de verdadTarde o temprano aparece este mensaje, y la reacción habitual —envolver cosas en act hasta que se calle— es casi siempre la equivocada:
Warning: An update to PaginaCatalogo inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...)
Qué es act
act es una función de React que delimita un bloque de trabajo: «ejecuta esto y no me devuelvas el control hasta que hayas procesado todas las actualizaciones de estado, ejecutado todos los efectos y aplicado los cambios al DOM». En el navegador, React procesa las actualizaciones por lotes cuando le conviene; en una prueba, eso sería una carrera contra la aserción. act sincroniza los dos mundos.
Qué provoca el aviso
Siempre lo mismo: un cambio de estado que ocurre fuera del control de la prueba, es decir, después de que la prueba haya terminado su bloque síncrono. Los casos concretos:
| Situación | Por qué aparece | Solución |
|---|---|---|
| Una petición se resuelve y actualiza el estado tras la última aserción | La prueba acabó antes que la respuesta | await screen.findBy… o await waitFor(…) |
Un setTimeout dentro de un efecto vence sin que nadie lo espere |
El temporizador dispara fuera de act |
Temporizadores falsos y act(() => vi.advanceTimersByTime(n)) |
| Se llama al actualizador de un hook desde la prueba | No hay act alrededor |
act(() => resultado.current.alternar()) |
| Un efecto asíncrono actualiza estado tras el desmontaje | Fuga real de la aplicación | Arreglar el componente: cancelar con AbortSignal |
Lo que NO hay que hacer
// ❌ Envolver todo a mano
await act(async () => {
render(<PaginaCatalogo />);
});
await act(async () => {
await usuario.click(boton);
});Es innecesario y contraproducente. render, userEvent, findBy y waitFor ya envuelven su trabajo en act; añadir más capas no aporta nada y enmascara el problema real, que casi siempre es «has olvidado esperar algo».
// ✅ La causa era una espera que faltaba
render(<PaginaCatalogo />);
await screen.findByText('Urbana Clásica'); // el aviso desapareceEl único uso legítimo de act a mano es el del apartado 11: llamar directamente al actualizador de un hook probado con renderHook, donde no hay ni componente ni interacción que lo envuelva.
La versión más difícil: el aviso tras el final de la prueba
A veces el aviso aparece después de que la prueba haya pasado, en el informe. Significa que la aplicación siguió actualizando estado cuando ya no debía. Ese no es un problema de la prueba: es una fuga de la aplicación —un efecto que no cancela su petición al desmontar— y hay que arreglarlo en el componente. useBicicletas recibe el signal de queryFn precisamente por eso (07-06), y esa decisión es la que evita el aviso aquí.
- Simular la red: por qué a nivel de protocolo y no del módulo
Hay cuatro formas de evitar que una prueba llame a la API de verdad. No son equivalentes.
| Estrategia | Cómo | Qué se prueba realmente | Problema |
|---|---|---|---|
| Simular el módulo de la API | vi.mock('./consultas/api.js') |
El componente, con una API falsa | No se prueba la construcción de la URL, ni la serialización, ni el manejo de códigos HTTP. Si la API real cambia, la prueba sigue verde |
Simular fetch global |
global.fetch = vi.fn() |
Un poco más: la URL sí se ve | Hay que reimplementar Response, ok, json(), cabeceras… a mano y para cada caso |
| Interceptar la red (MSW) | Un manejador por ruta | Todo el camino: URL, método, cuerpo, cabeceras, códigos de estado | Requiere montar el servidor una vez |
API real (json-server) |
No simular nada | Todo, incluida la base de datos | Lento, requiere un proceso levantado, comparte estado entre pruebas |
MSW (Mock Service Worker) es la opción del proyecto porque intercepta a nivel de petición, no de módulo. En Node lo hace interceptando las interfaces de red; en el navegador, con un service worker. El código de la aplicación no se entera: fetch se llama de verdad, con su URL de verdad, y recibe una Response de verdad.
Las ventajas concretas para CicloUrbano:
- La prueba ejercita
useBicicletascompleto, incluida la construcción dehttp://localhost:3001/bicicletas?estacionId=est-01, la comprobación derespuesta.oky elrespuesta.json(). Un error de tecleo en la URL rompe la prueba, como debe ser. - Los mismos manejadores sirven en desarrollo, si un día se quiere trabajar sin backend, y en las pruebas de Cypress.
- No hay nada que sincronizar. Si
useCrearReservacambia el método dePOSTaPUT, el manejador no responde y la prueba falla. Convi.mockhabría seguido verde. - Provocar errores es trivial: devolver un 500 es una línea, y reproducirlo con la API real exigiría apagar
json-servera mitad de prueba.
- MSW v2: instalación y manejadores de CicloUrbano
Los manejadores son la descripción de la API falsa: qué responde cada ruta. En MSW v2 la API es http.get/http.post/… y HttpResponse.
// src/pruebas/manejadores.js
import { http, HttpResponse } from 'msw';
const API = 'http://localhost:3001';
// El dominio ficticio de CicloUrbano, en un solo sitio y exportado
// para que las pruebas puedan afirmar sobre los mismos datos.
export const BICICLETAS = [
{ id: 'bici-001', modelo: 'Urbana Clásica', tipo: 'urbana', estado: 'disponible', estacionId: 'est-01', precioHora: 2.5 },
{ id: 'bici-002', modelo: 'Eléctrica Pro', tipo: 'electrica', estado: 'alquilada', estacionId: 'est-01', precioHora: 4.0 },
{ id: 'bici-003', modelo: 'Carga Max', tipo: 'carga', estado: 'mantenimiento', estacionId: 'est-02', precioHora: 5.5 },
{ id: 'bici-004', modelo: 'Urbana Clásica', tipo: 'urbana', estado: 'disponible', estacionId: 'est-03', precioHora: 2.5 },
{ id: 'bici-005', modelo: 'Eléctrica Pro', tipo: 'electrica', estado: 'disponible', estacionId: 'est-02', precioHora: 4.0 }
];
export const ESTACIONES = [
{ id: 'est-01', nombre: 'Plaza Mayor', barrio: 'Centro', plazas: 20 },
{ id: 'est-02', nombre: 'Parque Norte', barrio: 'Norte', plazas: 15 },
{ id: 'est-03', nombre: 'Estación Central', barrio: 'Ensanche', plazas: 30 }
];
export const RESERVAS = [
{
id: 'res-01', bicicletaId: 'bici-002', usuario: 'usr-01',
fechaInicio: '2026-05-04T09:00', horas: 2, estado: 'activa'
}
];
export const manejadores = [
// GET /bicicletas · admite ?estacionId= y ?tipo= como json-server
http.get(`${API}/bicicletas`, ({ request }) => {
const url = new URL(request.url);
const estacionId = url.searchParams.get('estacionId');
const tipo = url.searchParams.get('tipo');
let resultado = BICICLETAS;
if (estacionId) resultado = resultado.filter((b) => b.estacionId === estacionId);
if (tipo && tipo !== 'todos') resultado = resultado.filter((b) => b.tipo === tipo);
return HttpResponse.json(resultado);
}),
// GET /bicicletas/:bicicletaId
http.get(`${API}/bicicletas/:bicicletaId`, ({ params }) => {
const bicicleta = BICICLETAS.find((b) => b.id === params.bicicletaId);
if (!bicicleta) {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json(bicicleta);
}),
http.get(`${API}/estaciones`, () => HttpResponse.json(ESTACIONES)),
http.get(`${API}/estaciones/:estacionId`, ({ params }) => {
const estacion = ESTACIONES.find((e) => e.id === params.estacionId);
return estacion
? HttpResponse.json(estacion)
: new HttpResponse(null, { status: 404 });
}),
http.get(`${API}/reservas`, ({ request }) => {
const usuario = new URL(request.url).searchParams.get('usuario');
const resultado = usuario ? RESERVAS.filter((r) => r.usuario === usuario) : RESERVAS;
return HttpResponse.json(resultado);
}),
// POST /reservas · devuelve lo recibido con su identificador, como json-server
http.post(`${API}/reservas`, async ({ request }) => {
const nueva = await request.json();
return HttpResponse.json({ id: nueva.id ?? 'res-nueva', ...nueva }, { status: 201 });
}),
// PATCH /reservas/:id · confirmar y cancelar
http.patch(`${API}/reservas/:reservaId`, async ({ params, request }) => {
const cambios = await request.json();
const reserva = RESERVAS.find((r) => r.id === params.reservaId);
if (!reserva) return new HttpResponse(null, { status: 404 });
return HttpResponse.json({ ...reserva, ...cambios });
})
];Las claves de esta API:
| Elemento | Qué hace |
|---|---|
http.get(url, resolutor) |
Declara qué responder a un GET de esa URL |
:parametro en la ruta |
Segmento variable; llega en params |
({ request, params }) |
El contexto del resolutor: la petición y los parámetros de ruta |
HttpResponse.json(datos) |
Respuesta 200 con cuerpo JSON |
HttpResponse.json(d, { status }) |
Con otro código de estado |
new HttpResponse(null, { status: 500 }) |
Respuesta sin cuerpo |
await request.json() |
El cuerpo de la petición, para verificar qué se envió |
Dos decisiones deliberadas:
- Los datos se exportan. Así una prueba puede escribir
expect(await screen.findAllByRole('article')).toHaveLength(BICICLETAS.length)sin duplicar el número 5 en veinte sitios. - Los manejadores replican el comportamiento de
json-server, incluidos los filtros por cadena de consulta y el201delPOST. Cuanto más se parezca la simulación a la API real, menos posibilidades de que la prueba pase y la aplicación falle.
servidor.js y el enganche en configuracion.js
servidor.js y el enganche en configuracion.js// src/pruebas/servidor.js
import { setupServer } from 'msw/node';
import { manejadores } from './manejadores.js';
// En Node (Vitest) se usa setupServer. En el navegador sería setupWorker.
export const servidor = setupServer(...manejadores);// src/pruebas/configuracion.js · versión completa del módulo
import '@testing-library/jest-dom/vitest';
import { afterEach, afterAll, beforeAll } from 'vitest';
import { cleanup } from '@testing-library/react';
import { servidor } from './servidor.js';
beforeAll(() => {
// onUnhandledRequest: 'error' → cualquier petición sin manejador ROMPE la prueba.
// Es lo que se quiere: una petición inesperada es un fallo, no algo que ignorar.
servidor.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
cleanup();
localStorage.clear();
// Deshace los server.use() de la prueba que acaba de terminar:
// sin esto, un 500 forzado en una prueba contaminaría todas las siguientes.
servidor.resetHandlers();
});
afterAll(() => {
servidor.close();
});Los tres ganchos son un trío inseparable, y cada uno previene un fallo concreto:
| Gancho | Qué previene si falta |
|---|---|
servidor.listen() en beforeAll |
Sin él, las peticiones salen de verdad: la prueba depende de que json-server esté levantado |
servidor.resetHandlers() en afterEach |
Sin él, un server.use con un error 500 se queda activo y rompe pruebas posteriores de forma aparentemente aleatoria |
servidor.close() en afterAll |
Sin él, el proceso de Node puede quedarse colgado al terminar la suite |
Y onUnhandledRequest: 'error' merece defensa propia: convierte en fallo cualquier petición para la que no haya manejador. Puede parecer severo, pero es la única forma de enterarse de que un componente está llamando a un endpoint que nadie previó. Con la opción por defecto ('warn'), esa petición saldría a la red real, tardaría lo que tarde y probablemente fallaría con un mensaje incomprensible.
flowchart LR
A["Componente"] -->|"fetch('http://localhost:3001/bicicletas')"| B["Capa de red de Node"]
B --> C{"¿MSW tiene manejador?"}
C -- Sí --> D["Resolutor de manejadores.js"]
D --> E["HttpResponse.json(BICICLETAS)"]
E --> A
C -- No --> F["onUnhandledRequest: 'error'<br/>la prueba falla"]
- Probar componentes con TanStack Query
renderizar (09-03) ya crea un QueryClient nuevo en cada llamada con retry: false. Las dos decisiones son imprescindibles, y conviene entender por qué.
Un cliente por prueba. El QueryClient es una caché. Si se comparte entre pruebas, la segunda encuentra los datos de la primera ya cacheados y no hace la petición, así que:
- Una prueba que quiere comprobar el estado de carga nunca lo ve: el dato ya está.
- Una prueba que fuerza un 500 con
server.useno lo observa: se sirve la respuesta cacheada. - El resultado depende del orden de ejecución, que es la definición de prueba inestable.
retry: false. Por defecto, clienteConsultas reintenta dos veces con retroceso exponencial (07-06). En una prueba de error, eso significa esperar el primer fallo, un segundo, el segundo fallo, dos segundos… y agotar el tiempo de espera de 1.000 ms mucho antes de que aparezca el mensaje. Es la causa número uno de «mi prueba de error se cuelga y no entiendo por qué».
// src/pruebas/utilidades.jsx (recordatorio del apartado 13 de 09-03)
export function crearClienteDePrueba() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity, staleTime: 0 },
mutations: { retry: false }
}
});
}
- Los tres estados de la interfaz: cargando, éxito y error
Toda pantalla que pida datos tiene tres estados, y los tres merecen prueba. El de error es el que nadie comprueba a mano y el que más falla.
// src/paginas/PaginaCatalogo.test.jsx
import { renderizar, screen, waitForElementToBeRemoved, userEvent } from '../pruebas/utilidades.jsx';
import { servidor } from '../pruebas/servidor.js';
import { BICICLETAS } from '../pruebas/manejadores.js';
import { http, HttpResponse, delay } from 'msw';
import PaginaCatalogo from './PaginaCatalogo.jsx';
const API = 'http://localhost:3001';
describe('PaginaCatalogo', () => {
test('muestra el esqueleto mientras cargan las bicicletas', () => {
renderizar(<PaginaCatalogo />);
// Aserción SÍNCRONA, inmediatamente tras renderizar: aquí aún no ha llegado nada
expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();
expect(screen.queryByRole('article')).not.toBeInTheDocument();
});
test('sustituye el esqueleto por la lista cuando llegan los datos', async () => {
renderizar(<PaginaCatalogo />);
await waitForElementToBeRemoved(() => screen.queryByTestId('esqueleto-pagina'));
expect(screen.getAllByRole('article')).toHaveLength(BICICLETAS.length);
expect(screen.getByRole('heading', { name: 'Carga Max' })).toBeInTheDocument();
});
test('muestra cada bicicleta con su estado y su precio', async () => {
renderizar(<PaginaCatalogo />);
await screen.findByRole('heading', { name: 'Carga Max' });
// Una sola espera; el resto ya está en el DOM y se comprueba de forma síncrona
expect(screen.getByText('En mantenimiento')).toBeInTheDocument();
expect(screen.getAllByText('Disponible')).toHaveLength(3);
expect(screen.getByText(/5,50\s*€/)).toBeInTheDocument();
});
});La primera prueba es la única de la lección con una aserción síncrona después de renderizar, y es correcta precisamente porque comprueba el estado inicial: en ese instante la petición está en vuelo y el componente pinta el esqueleto. Si alguien eliminara el estado de carga y la pantalla se quedara en blanco, esta prueba lo detectaría.
server.use: sobrescribir un manejador en una prueba concreta
server.use: sobrescribir un manejador en una prueba concretaLos manejadores de manejadores.js son el caso feliz. Para el resto, servidor.use(...) añade un manejador que tiene prioridad sobre los de base y que dura solo hasta el resetHandlers() del afterEach.
Provocar un error del servidor
test('muestra un mensaje de error y un botón de reintento si la API falla', async () => {
servidor.use(
http.get(`${API}/bicicletas`, () => new HttpResponse(null, { status: 500 }))
);
renderizar(<PaginaCatalogo />);
// El mensaje es lo que ve el usuario; el "500" es un detalle que no debe filtrarse
expect(await screen.findByRole('alert')).toHaveTextContent(/no se han podido cargar/i);
expect(screen.getByRole('button', { name: /Reintentar/ })).toBeInTheDocument();
expect(screen.queryByRole('article')).not.toBeInTheDocument();
});Recuperarse tras el error
Esta es la prueba que de verdad justifica el botón de reintento, y es imposible de hacer a mano con comodidad:
test('el botón de reintento vuelve a pedir los datos y pinta la lista', async () => {
// Primer intento: falla. Los manejadores de `use` con `{ once: true }` se
// consumen tras responder una vez, así que el segundo intento cae en el de base.
servidor.use(
http.get(`${API}/bicicletas`, () => new HttpResponse(null, { status: 500 }), { once: true })
);
const usuario = userEvent.setup();
renderizar(<PaginaCatalogo />);
await screen.findByRole('alert');
await usuario.click(screen.getByRole('button', { name: /Reintentar/ }));
expect(await screen.findByRole('heading', { name: 'Carga Max' })).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});El caso de la lista vacía
Un estado que se olvida siempre y que produce pantallas en blanco en producción:
test('muestra un mensaje propio cuando no hay bicicletas', async () => {
servidor.use(
http.get(`${API}/bicicletas`, () => HttpResponse.json([]))
);
renderizar(<PaginaCatalogo />);
expect(await screen.findByText(/No hay bicicletas que coincidan/)).toBeInTheDocument();
expect(screen.queryByRole('article')).not.toBeInTheDocument();
});Una respuesta lenta
delay de MSW permite comprobar que el estado de carga se sostiene mientras dura la espera:
test('mantiene el esqueleto mientras la respuesta tarda', async () => {
servidor.use(
http.get(`${API}/bicicletas`, async () => {
await delay(200);
return HttpResponse.json(BICICLETAS);
})
);
renderizar(<PaginaCatalogo />);
expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();
// Y acaba llegando: se espera al resultado, no a los 200 ms
expect(await screen.findByRole('heading', { name: 'Carga Max' })).toBeInTheDocument();
});Fíjate en que ni siquiera aquí se espera un tiempo fijo. delay(200) está en el manejador, del lado del servidor simulado; la prueba sigue esperando al resultado. Es la diferencia entre controlar la latencia y adivinarla.
Verificar el filtro por cadena de consulta
test('pide solo las bicicletas del tipo indicado en la URL', async () => {
renderizar(<PaginaCatalogo />, { ruta: '/?tipo=electrica' });
await screen.findByRole('heading', { name: 'Eléctrica Pro' });
// El manejador filtra por ?tipo=, así que solo deben llegar las dos eléctricas
expect(screen.getAllByRole('article')).toHaveLength(2);
expect(screen.queryByRole('heading', { name: 'Carga Max' })).not.toBeInTheDocument();
});Esta prueba recorre la cadena completa: la URL → useSearchParams → la clave de consulta → la URL de la petición → el filtro del manejador → la lista pintada. Ninguna prueba unitaria puede cubrir eso, y es exactamente el tipo de cableado que se rompe en una refactorización.
- Probar una mutación completa
Una mutación tiene tres cosas que verificar, y la tercera es la que más se olvida: qué se envía, qué se muestra y qué se refresca después.
// src/paginas/PaginaNuevaReserva.test.jsx
import { renderizar, screen, userEvent, waitFor } from '../pruebas/utilidades.jsx';
import { servidor } from '../pruebas/servidor.js';
import { http, HttpResponse, delay } from 'msw';
import PaginaNuevaReserva from './PaginaNuevaReserva.jsx';
const API = 'http://localhost:3001';
const SESION_ANA = {
sesion: {
usuario: { id: 'usr-01', nombre: 'Ana Ribera', correo: '[email protected]', rol: 'cliente' },
cargando: false,
error: null
}
};
describe('PaginaNuevaReserva', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date('2026-05-04T08:00:00'));
});
afterEach(() => vi.useRealTimers());
test('envía la reserva con el cuerpo correcto y avisa del éxito', async () => {
// Espía del cuerpo enviado: el manejador lo captura para poder afirmar sobre él
let cuerpoRecibido = null;
servidor.use(
http.post(`${API}/reservas`, async ({ request }) => {
cuerpoRecibido = await request.json();
return HttpResponse.json({ ...cuerpoRecibido }, { status: 201 });
})
);
const usuario = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
renderizar(<PaginaNuevaReserva />, { estadoInicial: SESION_ANA });
// Las bicicletas del selector llegan de la API: hay que esperarlas
await screen.findByRole('option', { name: /Urbana Clásica/ });
await usuario.selectOptions(screen.getByLabelText('Bicicleta'), 'bici-001');
await usuario.type(screen.getByLabelText('Inicio de la reserva'), '2026-05-04T10:00');
await usuario.clear(screen.getByLabelText('Duración (horas)'));
await usuario.type(screen.getByLabelText('Duración (horas)'), '3');
await usuario.click(screen.getByRole('checkbox', { name: /Acepto las condiciones/ }));
await usuario.click(screen.getByRole('button', { name: 'Crear reserva' }));
// 1) Qué se envió
await waitFor(() => expect(cuerpoRecibido).not.toBeNull());
expect(cuerpoRecibido).toMatchObject({
bicicletaId: 'bici-001',
usuario: 'usr-01',
fechaInicio: '2026-05-04T10:00',
horas: 3,
estado: 'activa'
});
// 2) Qué ve el usuario
expect(await screen.findByRole('status')).toHaveTextContent(/Reserva creada/);
});
test('deshabilita el botón mientras se envía', async () => {
servidor.use(
http.post(`${API}/reservas`, async ({ request }) => {
await delay(100);
return HttpResponse.json(await request.json(), { status: 201 });
})
);
const usuario = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
renderizar(<PaginaNuevaReserva />, { estadoInicial: SESION_ANA });
await rellenarFormularioValido(usuario);
await usuario.click(screen.getByRole('button', { name: 'Crear reserva' }));
expect(screen.getByRole('button', { name: /Creando/ })).toBeDisabled();
expect(await screen.findByRole('status')).toHaveTextContent(/Reserva creada/);
});
test('muestra el error sin perder los datos del formulario si el envío falla', async () => {
servidor.use(
http.post(`${API}/reservas`, () => new HttpResponse(null, { status: 500 }))
);
const usuario = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
renderizar(<PaginaNuevaReserva />, { estadoInicial: SESION_ANA });
await rellenarFormularioValido(usuario);
await usuario.click(screen.getByRole('button', { name: 'Crear reserva' }));
expect(await screen.findByRole('alert')).toHaveTextContent(/No se ha podido crear la reserva/);
// Y lo importante: el trabajo del usuario no se pierde
expect(screen.getByLabelText('Duración (horas)')).toHaveValue(3);
});
});Comprobar la invalidación
La parte que casi nadie prueba y que es la razón de ser del onSuccess de useCrearReserva: tras crear una reserva, la lista se recarga.
test('la lista de reservas se refresca tras crear una nueva', async () => {
let peticionesDeReservas = 0;
const reservas = [];
servidor.use(
http.get(`${API}/reservas`, () => {
peticionesDeReservas += 1;
return HttpResponse.json(reservas);
}),
http.post(`${API}/reservas`, async ({ request }) => {
const nueva = await request.json();
reservas.push(nueva); // el "servidor" guarda de verdad
return HttpResponse.json(nueva, { status: 201 });
})
);
const usuario = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
renderizar(<PaginaReservas />, { estadoInicial: SESION_ANA });
expect(await screen.findByText(/No tienes reservas/)).toBeInTheDocument();
expect(peticionesDeReservas).toBe(1);
await usuario.click(screen.getByRole('button', { name: /Nueva reserva/ }));
await rellenarFormularioValido(usuario);
await usuario.click(screen.getByRole('button', { name: 'Crear reserva' }));
// La invalidación de claves.reservas.todas() provoca una segunda petición…
await waitFor(() => expect(peticionesDeReservas).toBe(2));
// …y la reserva aparece en la lista
expect(await screen.findByText('Urbana Clásica')).toBeInTheDocument();
});El manejador con estado (reservas.push) convierte a MSW en un servidor de verdad, en miniatura. Es lo que permite verificar el ciclo completo: crear, invalidar, recargar, pintar. Y comprobar el número de peticiones es aquí legítimo, porque la recarga tras la mutación sí es comportamiento observable: si desaparece, el usuario ve una lista desactualizada.
renderHook: probar hooks personalizados
renderHook: probar hooks personalizadosUn hook no se puede llamar fuera de un componente. renderHook monta un componente mínimo cuyo único trabajo es llamar al hook y exponer su valor en resultado.current.
// src/hooks/useAlternar.test.js
import { renderHook, act } from '@testing-library/react';
import { describe, test, expect } from 'vitest';
import { useAlternar } from './useAlternar.js';
describe('useAlternar', () => {
test('empieza en false por defecto', () => {
const { result } = renderHook(() => useAlternar());
const [valor] = result.current;
expect(valor).toBe(false);
});
test('respeta el valor inicial recibido', () => {
const { result } = renderHook(() => useAlternar(true));
expect(result.current[0]).toBe(true);
});
test('alternar invierte el valor', () => {
const { result } = renderHook(() => useAlternar(false));
// act: sin él, React avisa de que la actualización no está envuelta,
// y result.current no reflejaría el nuevo valor
act(() => result.current[1].alternar());
expect(result.current[0]).toBe(true);
act(() => result.current[1].alternar());
expect(result.current[0]).toBe(false);
});
test('activar y desactivar fijan el valor con independencia del anterior', () => {
const { result } = renderHook(() => useAlternar(false));
act(() => result.current[1].activar());
act(() => result.current[1].activar());
expect(result.current[0]).toBe(true);
act(() => result.current[1].desactivar());
expect(result.current[0]).toBe(false);
});
test('las tres acciones mantienen su identidad entre renders', () => {
const { result } = renderHook(() => useAlternar(false));
const accionesIniciales = result.current[1];
act(() => result.current[1].alternar());
// useCallback con [] las estabiliza: es la promesa del hook (05-06)
expect(result.current[1]).toBe(accionesIniciales);
});
});Aquí está el único uso legítimo de act a mano que se anunció en el apartado 3: llamar directamente a un actualizador de estado sin que medie ni interacción ni petición. Sin act, React no procesa la actualización antes de devolver el control y result.current sigue con el valor antiguo.
La última prueba merece atención: verifica la estabilidad de identidad que useCallback con dependencias vacías garantiza. Es comportamiento observable, no implementación, porque de esa estabilidad depende que un efecto que reciba alternar en sus dependencias no se reejecute en bucle. Si alguien quita el useCallback, esta prueba falla y explica por qué.
Opciones útiles de renderHook
// Cambiar las props del hook entre renders
const { result, rerender } = renderHook(({ valor }) => useValorPrevio(valor), {
initialProps: { valor: 'urbana' }
});
expect(result.current).toBeUndefined();
rerender({ valor: 'electrica' });
expect(result.current).toBe('urbana'); // el valor anterior
// Envolver el hook con proveedores: para hooks que usan contexto o Redux
const { result } = renderHook(() => useTema(), { wrapper: ProveedorTema });
expect(result.current.tema).toBe('claro');
useDebounce: temporizadores falsos dentro de React
useDebounce: temporizadores falsos dentro de ReactEn 09-02 se probó el mecanismo del retardo como función pura. Ahora toca el hook, y aparece la dificultad de combinar los temporizadores falsos con las actualizaciones de React.
// src/hooks/useDebounce.test.js
import { renderHook, act } from '@testing-library/react';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { useDebounce } from './useDebounce.js';
describe('useDebounce', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
test('devuelve el valor inicial de inmediato', () => {
const { result } = renderHook(() => useDebounce('urbana', 400));
expect(result.current).toBe('urbana');
});
test('no propaga el valor nuevo antes de que venza el retardo', () => {
const { result, rerender } = renderHook(({ v }) => useDebounce(v, 400), {
initialProps: { v: 'urb' }
});
rerender({ v: 'urbana' });
// El efecto ha programado el temporizador, pero aún no ha vencido
expect(result.current).toBe('urb');
act(() => vi.advanceTimersByTime(399));
expect(result.current).toBe('urb');
});
test('propaga el valor cuando se cumple el retardo', () => {
const { result, rerender } = renderHook(({ v }) => useDebounce(v, 400), {
initialProps: { v: 'urb' }
});
rerender({ v: 'urbana' });
// act envuelve el avance del reloj: el setTimeout vence DENTRO de act,
// así que React procesa el setState resultante antes de devolver el control
act(() => vi.advanceTimersByTime(400));
expect(result.current).toBe('urbana');
});
test('seis cambios rápidos producen un solo valor final', () => {
const { result, rerender } = renderHook(({ v }) => useDebounce(v, 400), {
initialProps: { v: 'u' }
});
for (const v of ['ur', 'urb', 'urba', 'urban', 'urbana']) {
rerender({ v });
act(() => vi.advanceTimersByTime(100)); // 100 ms entre teclas
}
// Han pasado 500 ms en total, pero nunca 400 seguidos sin cambios
expect(result.current).toBe('u');
act(() => vi.advanceTimersByTime(400));
expect(result.current).toBe('urbana');
});
test('el temporizador pendiente se cancela al desmontar', () => {
const { rerender, unmount } = renderHook(({ v }) => useDebounce(v, 400), {
initialProps: { v: 'urb' }
});
rerender({ v: 'urbana' });
unmount();
// La limpieza del efecto ha llamado a clearTimeout: no queda nada pendiente
expect(vi.getTimerCount()).toBe(0);
});
});Las tres reglas de esta combinación:
act(() => vi.advanceTimersByTime(n)), siempre junto. Avanzar el reloj hace vencer unsetTimeoutque llama asetValorRetrasado; sinact, esa actualización queda fuera del control de React,result.currentno se refresca y aparece el aviso del apartado 3.vi.useRealTimers()en elafterEach, sin excepción. Los temporizadores falsos que sobreviven a la prueba cuelgan las siguientes.- Temporizadores falsos y
userEventse llevan mal por defecto.userEventespera con temporizadores reales entre eventos; si los has congelado, se bloquea. Las dos soluciones ya usadas en esta lección:vi.useFakeTimers({ shouldAdvanceTime: true }), o pasaruserEvent.setup({ advanceTimers: vi.advanceTimersByTime }).
La última prueba, la del desmontaje, verifica la limpieza del efecto —el return () => clearTimeout(identificador) de 05-02—. Es una prueba de una fuga de memoria, y no hay ninguna otra forma razonable de comprobarla.
useAlmacenLocal y el almacenamiento del navegador
useAlmacenLocal y el almacenamiento del navegadorjsdom proporciona un localStorage funcional, así que no hay que simular nada: basta con dejarlo limpio entre pruebas, cosa que ya hace configuracion.js.
// src/hooks/useAlmacenLocal.test.js
import { renderHook, act } from '@testing-library/react';
import { describe, test, expect, vi, afterEach } from 'vitest';
import { useAlmacenLocal } from './useAlmacenLocal.js';
describe('useAlmacenLocal', () => {
afterEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
test('usa el valor inicial cuando no hay nada guardado', () => {
const { result } = renderHook(() => useAlmacenLocal('ciclourbano:tema', 'claro'));
expect(result.current[0]).toBe('claro');
});
test('lee el valor guardado en el primer render, sin parpadeo', () => {
localStorage.setItem('ciclourbano:tema', JSON.stringify('oscuro'));
const { result } = renderHook(() => useAlmacenLocal('ciclourbano:tema', 'claro'));
// 'oscuro' en el PRIMER valor: la lectura va en el inicializador perezoso (05-06),
// no en un efecto. Si estuviera en un efecto, aquí veríamos 'claro'.
expect(result.current[0]).toBe('oscuro');
});
test('guarda el valor nuevo en el almacenamiento', () => {
const { result } = renderHook(() => useAlmacenLocal('ciclourbano:tema', 'claro'));
act(() => result.current[1]('oscuro'));
expect(result.current[0]).toBe('oscuro');
expect(JSON.parse(localStorage.getItem('ciclourbano:tema'))).toBe('oscuro');
});
test('cae al valor inicial si lo guardado no es JSON válido', () => {
localStorage.setItem('ciclourbano:tema', '{{{esto no es json');
const { result } = renderHook(() => useAlmacenLocal('ciclourbano:tema', 'claro'));
expect(result.current[0]).toBe('claro'); // el try/catch hace su trabajo
});
test('no rompe si el almacenamiento lanza al escribir (cuota o modo privado)', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('QuotaExceededError');
});
const { result } = renderHook(() => useAlmacenLocal('ciclourbano:tema', 'claro'));
// La aplicación sigue funcionando aunque no se pueda persistir
expect(() => act(() => result.current[1]('oscuro'))).not.toThrow();
expect(result.current[0]).toBe('oscuro');
});
});Las dos últimas pruebas son la razón de existir de los try/catch que 05-06 defendía como «no paranoia». Provocar una cuota agotada a mano requiere llenar el almacenamiento del navegador; con vi.spyOn(Storage.prototype, 'setItem') son tres líneas. Y la del JSON corrupto reproduce un caso real: una versión anterior de la aplicación guardó otra cosa bajo la misma clave.
- Probar un componente que navega tras una operación
Combina la navegación de 09-03 con la asincronía de esta lección: tras crear la reserva, la aplicación lleva al usuario a /reservas.
test('lleva a /reservas después de crear la reserva', async () => {
const usuario = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const { enrutador } = renderizar(null, {
ruta: '/reservas/nueva',
estadoInicial: SESION_ANA,
rutas: [
{ path: '/reservas/nueva', element: <PaginaNuevaReserva /> },
{ path: '/reservas', element: <h1>Tus reservas</h1> }
]
});
await rellenarFormularioValido(usuario);
await usuario.click(screen.getByRole('button', { name: 'Crear reserva' }));
// Se espera al DESTINO, no a un tiempo
expect(await screen.findByRole('heading', { name: 'Tus reservas' })).toBeInTheDocument();
expect(enrutador.state.location.pathname).toBe('/reservas');
});La ruta de destino se sustituye por un componente mínimo por el mismo motivo que en 09-03: la prueba verifica que se navega, y montar la página real traería sus propias consultas y convertiría un fallo de navegación en un fallo de datos.
- Probar
LimiteDeError y silenciar el ruido esperado
LimiteDeError y silenciar el ruido esperadoLimiteDeError es la única clase del proyecto (04-05). Probarlo tiene una particularidad: React siempre escribe el error en la consola, incluso cuando el límite lo captura correctamente. Si no se silencia, la salida de la suite se llena de trazas rojas que parecen fallos y no lo son.
// src/componentes/LimiteDeError.test.jsx
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import LimiteDeError from './LimiteDeError.jsx';
// Componente que explota bajo demanda
function ComponenteQueFalla({ fallar = true }) {
if (fallar) throw new Error('La flota no está disponible');
return <p>Contenido correcto</p>;
}
describe('LimiteDeError', () => {
let espiaConsola;
beforeEach(() => {
// React escribe el error capturado: es ruido ESPERADO, no un fallo
espiaConsola = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
test('deja pasar a los hijos cuando no hay error', () => {
render(
<LimiteDeError titulo="Algo ha fallado">
<ComponenteQueFalla fallar={false} />
</LimiteDeError>
);
expect(screen.getByText('Contenido correcto')).toBeInTheDocument();
expect(espiaConsola).not.toHaveBeenCalled();
});
test('muestra la interfaz de reserva cuando un hijo lanza', () => {
render(
<LimiteDeError titulo="CicloUrbano no está disponible ahora mismo">
<ComponenteQueFalla />
</LimiteDeError>
);
expect(screen.getByRole('heading', { name: /no está disponible/ })).toBeInTheDocument();
expect(screen.queryByText('Contenido correcto')).not.toBeInTheDocument();
});
test('notifica el error al servicio de monitorización', () => {
const alRegistrar = vi.fn();
render(
<LimiteDeError titulo="Error" alRegistrar={alRegistrar}>
<ComponenteQueFalla />
</LimiteDeError>
);
expect(alRegistrar).toHaveBeenCalledTimes(1);
expect(alRegistrar).toHaveBeenCalledWith(
expect.objectContaining({ message: 'La flota no está disponible' }),
expect.anything() // la información del componente que React pasa como segundo argumento
);
});
test('el botón de reintento vuelve a montar los hijos', async () => {
const usuario = userEvent.setup();
function Contenedor() {
const [fallar, setFallar] = useState(true);
return (
<>
<button type="button" onClick={() => setFallar(false)}>Arreglar</button>
<LimiteDeError titulo="Error">
<ComponenteQueFalla fallar={fallar} />
</LimiteDeError>
</>
);
}
render(<Contenedor />);
expect(screen.getByRole('heading', { name: 'Error' })).toBeInTheDocument();
await usuario.click(screen.getByRole('button', { name: 'Arreglar' }));
await usuario.click(screen.getByRole('button', { name: /Reintentar/ }));
expect(screen.getByText('Contenido correcto')).toBeInTheDocument();
});
});Sobre el silenciado, dos precisiones importantes:
- Se silencia solo en las pruebas que provocan el error a propósito, nunca de forma global en
configuracion.js. Unconsole.errorglobal desactivaría también los avisos legítimos de React —claves duplicadas, hooks mal usados, props no válidas—, que son información valiosa. vi.restoreAllMocks()en elafterEaches obligatorio. Sin él, la consola queda muda para el resto del fichero.
Y fíjate en la primera prueba: expect(espiaConsola).not.toHaveBeenCalled() comprueba que en el camino feliz no hay ningún aviso. Es una comprobación barata que detecta claves duplicadas y otros avisos de React que de otro modo pasarían inadvertidos.
- Cómo no escribir pruebas inestables
Recapitulación operativa de todo lo anterior, en forma de reglas:
| Regla | Por qué | Cómo se aplica en CicloUrbano |
|---|---|---|
| Nunca esperes un tiempo fijo | Lento siempre, insuficiente a veces | findBy*, waitFor, waitForElementToBeRemoved |
| Espera a lo que se ve, no a lo que ocurre por dentro | El estado interno puede cambiar sin que la interfaz lo refleje | await screen.findByRole('heading', …) |
| Un cliente y un almacén nuevos por prueba | La caché compartida hace que el resultado dependa del orden | renderizar los crea siempre |
retry: false en las consultas |
Los reintentos agotan el tiempo de espera en las pruebas de error | crearClienteDePrueba |
resetHandlers() tras cada prueba |
Un server.use con un 500 contaminaría las siguientes |
afterEach de configuracion.js |
onUnhandledRequest: 'error' |
Una petición imprevista sale a la red real y falla de forma incomprensible | servidor.listen |
| Limpia el almacenamiento entre pruebas | useAlmacenLocal y el tema persisten |
localStorage.clear() en afterEach |
| Congela la fecha, no el reloj entero | Las reglas que dependen de «ahora» caducan | setSystemTime + shouldAdvanceTime: true |
Una aserción por waitFor, sin efectos dentro |
La función se reintenta muchas veces | Espera una vez, afirma en síncrono después |
| No compartas variables mutables entre pruebas | El orden de ejecución pasa a importar | Escenario nuevo en beforeEach |
Y el diagnóstico de siempre cuando aparezca una prueba intermitente: ejecútala en solitario y luego dentro de la suite. Si pasa sola y falla acompañada, el problema es de aislamiento (caché, manejadores, almacenamiento). Si falla en ambos casos de forma intermitente, el problema es de espera.
Errores Comunes y Consejos
- Olvidar el
awaitdelante defindBy. La aserción recibe una promesa, que siempre es truthy, así queexpect(promesa).toBeInTheDocument()da un error críptico o pasa por accidente. Regla:findBysiempre conawait. - Usar
getByjusto después derenderesperando datos del servidor. Falla siempre, porque el primer render es el de carga. Es el error del apartado 1, y el volcado del DOM lo delata: aparece el esqueleto. - Meter interacciones dentro de
waitFor. Se ejecutan tantas veces como reintentos haga la función. El clic fuera, la aserción dentro. - Silenciar el aviso de
actenvolviendo todo a mano. El aviso es un síntoma; la causa suele ser una espera que falta. Añade la espera y el aviso desaparece solo. - Compartir el
QueryCliententre pruebas. Produce el fallo más desconcertante del módulo: pruebas que pasan solas y fallan juntas, o al revés.renderizarlo evita, pero no lo esquives creando el cliente en el ámbito deldescribe. - Olvidar
resetHandlers(). Unserver.usecon un 500 sobrevive a la prueba y rompe otras que no tienen nada que ver. Está enconfiguracion.js; no lo quites. - Simular
fetchen lugar de usar MSW. Obliga a reimplementarResponse,ok,json()y las cabeceras, y deja sin probar la construcción de la URL, que es justo donde están las erratas. - Probar solo el camino feliz. El estado de error y la lista vacía son los que producen pantallas en blanco en producción, y son los más fáciles de probar con
server.use. Si solo vas a añadir una prueba a una página, que sea la del error. - Consejo: cuando una prueba asíncrona falle, mira el volcado del DOM que acompaña al error. Suele decir exactamente en qué estado se quedó: esqueleto (no llegó el dato), alerta (llegó un error), vacío (no se montó nada).
- Consejo: usa manejadores con estado para las mutaciones. Un array que el
POSTrellena y elGETlee convierte a MSW en un servidor de verdad en miniatura, y permite probar el ciclo completo de invalidación. - Consejo: exporta los datos de
manejadores.jsy afirma sobre ellos.toHaveLength(BICICLETAS.length)no se rompe el día que alguien añada una sexta bicicleta al escenario.
Ejercicios
Ejercicio 1. Escribe la suite de PaginaDetalleEstacion para la ruta /estaciones/est-01. Cubre los cuatro escenarios: el estado de carga, el éxito (nombre «Plaza Mayor», barrio «Centro», 20 plazas y las dos bicicletas de esa estación), el error 500 del endpoint de estaciones, y el 404 cuando el identificador no existe. Indica qué herramienta de espera usas en cada caso y por qué.
Ejercicio 2. Esta prueba pasa a veces y falla otras. Identifica cuatro problemas y reescríbela.
test('crea una reserva', async () => {
const cliente = new QueryClient();
render(
<QueryClientProvider client={cliente}>
<PaginaNuevaReserva />
</QueryClientProvider>
);
await new Promise((r) => setTimeout(r, 300));
fireEvent.change(screen.getByLabelText('Bicicleta'), { target: { value: 'bici-001' } });
fireEvent.click(screen.getByText('Crear reserva'));
await waitFor(() => {
expect(screen.getByText('Reserva creada')).toBeInTheDocument();
expect(screen.getByLabelText('Bicicleta')).toHaveValue('');
});
});Ejercicio 3. Escribe las pruebas de useEstadoConexion, el hook que devuelve true o false según navigator.onLine y que se suscribe a los eventos online y offline del navegador. Cubre: el valor inicial, la reacción a cada evento, y que los escuchadores se retiran al desmontar. Pista: los eventos del navegador se disparan con window.dispatchEvent(new Event('offline')) y navigator.onLine se sustituye con vi.spyOn(navigator, 'onLine', 'get').
Soluciones
Solución 1.
// src/paginas/PaginaDetalleEstacion.test.jsx
import { renderizar, screen, waitForElementToBeRemoved } from '../pruebas/utilidades.jsx';
import { servidor } from '../pruebas/servidor.js';
import { http, HttpResponse, delay } from 'msw';
import PaginaDetalleEstacion from './PaginaDetalleEstacion.jsx';
const API = 'http://localhost:3001';
const RUTAS = [{ path: '/estaciones/:estacionId', element: <PaginaDetalleEstacion /> }];
describe('PaginaDetalleEstacion', () => {
test('muestra el esqueleto mientras carga', () => {
servidor.use(
http.get(`${API}/estaciones/:estacionId`, async () => {
await delay(100);
return HttpResponse.json({ id: 'est-01', nombre: 'Plaza Mayor', barrio: 'Centro', plazas: 20 });
})
);
renderizar(null, { ruta: '/estaciones/est-01', rutas: RUTAS });
// Aserción SÍNCRONA: es el estado inicial, no hay nada que esperar
expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();
});
test('muestra los datos de la estación y su flota', async () => {
renderizar(null, { ruta: '/estaciones/est-01', rutas: RUTAS });
// waitForElementToBeRemoved: se comprueba la TRANSICIÓN carga → contenido
await waitForElementToBeRemoved(() => screen.queryByTestId('esqueleto-pagina'));
expect(screen.getByRole('heading', { name: 'Plaza Mayor' })).toBeInTheDocument();
expect(screen.getByText('Centro')).toBeInTheDocument();
expect(screen.getByText(/20 plazas/)).toBeInTheDocument();
// est-01 tiene bici-001 y bici-002 en el escenario de manejadores.js
expect(screen.getAllByRole('article')).toHaveLength(2);
expect(screen.getByRole('heading', { name: 'Eléctrica Pro' })).toBeInTheDocument();
});
test('muestra un mensaje de error si la API falla', async () => {
servidor.use(
http.get(`${API}/estaciones/:estacionId`, () => new HttpResponse(null, { status: 500 }))
);
renderizar(null, { ruta: '/estaciones/est-01', rutas: RUTAS });
// findByRole: se espera a que APAREZCA la alerta
expect(await screen.findByRole('alert')).toHaveTextContent(/no se ha podido cargar la estación/i);
expect(screen.queryByRole('article')).not.toBeInTheDocument();
});
test('muestra «estación no encontrada» ante un 404', async () => {
renderizar(null, { ruta: '/estaciones/est-99', rutas: RUTAS });
// El manejador de base ya devuelve 404 para un id inexistente: no hace falta server.use
expect(await screen.findByText(/Esa estación no existe/)).toBeInTheDocument();
});
});Las herramientas de espera y su motivo:
| Escenario | Herramienta | Por qué |
|---|---|---|
| Carga | Ninguna (síncrona) | Es el estado inmediatamente posterior al render |
| Éxito | waitForElementToBeRemoved |
Comprueba la transición completa: el esqueleto se va |
| Error | findByRole('alert') |
Se espera a que aparezca un elemento concreto |
| 404 | findByText |
Igual, y sin server.use porque el manejador de base ya lo cubre |
Solución 2. Los cuatro problemas:
new QueryClient()con las opciones por defecto. Reintenta las consultas fallidas, así que cualquier prueba de error agotaría el tiempo de espera. Y al crearse aquí, sinretry: false, la mutación también reintenta.await new Promise(r => setTimeout(r, 300)). Espera fija: lenta siempre, insuficiente a veces, y no dice qué espera. Es la causa directa de la intermitencia.fireEventen lugar deuserEvent.fireEvent.changeasigna el valor sin disparar la secuencia real, yfireEvent.clickpulsaría el botón aunque estuviera deshabilitado durante el envío. Además, el formulario no se rellena entero, así que la validación lo rechazaría.- Dos aserciones dentro de un
waitFor, ygetByText('Crear reserva')en lugar de una consulta por rol. Lo primero hace el fallo indiagnosticable; lo segundo encontraría cualquier texto suelto que coincida, no necesariamente el botón.
Reescrita:
test('crea la reserva y limpia el formulario', async () => {
const usuario = userEvent.setup();
renderizar(<PaginaNuevaReserva />, { estadoInicial: SESION_ANA });
// Se espera A LOS DATOS, no a un tiempo: las opciones llegan de la API
await screen.findByRole('option', { name: /Urbana Clásica/ });
await usuario.selectOptions(screen.getByLabelText('Bicicleta'), 'bici-001');
await usuario.type(screen.getByLabelText('Inicio de la reserva'), '2026-05-04T10:00');
await usuario.clear(screen.getByLabelText('Duración (horas)'));
await usuario.type(screen.getByLabelText('Duración (horas)'), '2');
await usuario.click(screen.getByRole('checkbox', { name: /Acepto las condiciones/ }));
await usuario.click(screen.getByRole('button', { name: 'Crear reserva' }));
// Una sola espera; el resto en síncrono
expect(await screen.findByRole('status')).toHaveTextContent(/Reserva creada/);
expect(screen.getByLabelText('Bicicleta')).toHaveValue('');
});renderizar aporta el QueryClient con retry: false, el almacén con la sesión de Ana y el enrutador, resolviendo el primer problema sin escribir nada.
Solución 3.
// src/hooks/useEstadoConexion.test.js
import { renderHook, act } from '@testing-library/react';
import { describe, test, expect, vi, afterEach } from 'vitest';
import { useEstadoConexion } from './useEstadoConexion.js';
describe('useEstadoConexion', () => {
afterEach(() => vi.restoreAllMocks());
test('devuelve true cuando el navegador está conectado', () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
const { result } = renderHook(() => useEstadoConexion());
expect(result.current).toBe(true);
});
test('devuelve false cuando el navegador arranca sin conexión', () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
const { result } = renderHook(() => useEstadoConexion());
expect(result.current).toBe(false);
});
test('pasa a false al recibir el evento offline', () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
const { result } = renderHook(() => useEstadoConexion());
// act: el evento provoca un setState fuera de cualquier interacción
act(() => {
window.dispatchEvent(new Event('offline'));
});
expect(result.current).toBe(false);
});
test('vuelve a true al recibir el evento online', () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
const { result } = renderHook(() => useEstadoConexion());
act(() => {
window.dispatchEvent(new Event('online'));
});
expect(result.current).toBe(true);
});
test('retira los escuchadores al desmontar', () => {
const espiaQuitar = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useEstadoConexion());
unmount();
expect(espiaQuitar).toHaveBeenCalledWith('online', expect.any(Function));
expect(espiaQuitar).toHaveBeenCalledWith('offline', expect.any(Function));
});
});Notas sobre las decisiones:
vi.spyOn(navigator, 'onLine', 'get')con el tercer argumento'get'intercepta el captador de la propiedad, que es lo que permite controlarla:navigator.onLinees de solo lectura y una asignación directa no funciona.actalrededor deldispatchEventes necesario porque el escuchador llama a un actualizador de estado fuera de cualquier interacción deuserEvent. Es el mismo caso del apartado 11.- La última prueba es la excepción que confirma la regla. Espiar
removeEventListeneres afirmar sobre la implementación, y en general se evita. Se justifica aquí porque la fuga de un escuchador no tiene ninguna manifestación observable desde fuera del componente, y es un fallo real que se acumula al montar y desmontar la pantalla muchas veces. Cuando no hay comportamiento observable que verificar y el riesgo es real, es aceptable bajar un escalón; pero conviene saber que se está bajando.
Conclusión
Esta lección ha cubierto el terreno donde se rompe la mayoría de las pruebas de front-end, y ha dejado montada toda la infraestructura de red simulada del proyecto.
Lo esencial. Una prueba ingenua sobre código asíncrono falla siempre, porque la aserción se ejecuta en el primer render, cuando el componente aún pinta el esqueleto. La solución nunca es un setTimeout manual —lento, inestable y mudo sobre lo que espera— sino esperar al resultado con las tres herramientas de Testing Library: findBy* por defecto, waitFor cuando lo que cambia no es la presencia de un elemento, y waitForElementToBeRemoved para verificar que el indicador de carga se va. Con las dos reglas de waitFor: sin efectos secundarios dentro, y una sola aserción por llamada. El patrón que se repite en toda la lección es una espera al principio, aserciones síncronas después.
El aviso act(...) ya no es un misterio: significa que hubo un cambio de estado fuera del control de la prueba, casi siempre porque faltaba una espera. render, userEvent, findBy y waitFor ya envuelven su trabajo en act, así que envolver más a mano enmascara la causa en vez de arreglarla. El único uso legítimo de act explícito es llamar directamente al actualizador de un hook en renderHook, o hacer vencer un temporizador falso.
Para la red, la decisión es interceptar a nivel de protocolo y no de módulo: vi.mock de la capa de API deja sin probar la URL, la serialización y los códigos de estado, y sigue verde cuando la API real cambia. MSW v2 hace que fetch se llame de verdad y reciba una Response de verdad. Quedan fijados src/pruebas/manejadores.js —con http.get/http.post/http.patch para /bicicletas, /bicicletas/:id, /estaciones, /estaciones/:id y /reservas, replicando los filtros por cadena de consulta de json-server y exportando BICICLETAS, ESTACIONES y RESERVAS— y src/pruebas/servidor.js con setupServer. El enganche en configuracion.js es un trío inseparable: listen({ onUnhandledRequest: 'error' }) en beforeAll, resetHandlers() en afterEach y close() en afterAll; sin el segundo, un 500 forzado contamina las pruebas siguientes de forma aparentemente aleatoria.
Con servidor.use se provocan a voluntad los escenarios que nadie comprueba a mano: un error 500 con su mensaje y su botón de reintento, un manejador { once: true } para verificar que el reintento se recupera, una lista vacía con su mensaje propio, y una respuesta lenta con delay —controlando la latencia en el servidor simulado y esperando siempre al resultado, nunca al reloj—. Para TanStack Query, las dos reglas son un QueryClient nuevo por prueba (una caché compartida hace que el resultado dependa del orden de ejecución) y retry: false (con los reintentos por defecto, toda prueba de error agota el tiempo de espera). Y una mutación se prueba entera: qué cuerpo se envía —capturándolo en el manejador—, qué ve el usuario mientras se envía y al terminar, qué pasa si falla, y sobre todo que la lista se refresca tras la invalidación, contando peticiones sobre un manejador con estado que actúa como un servidor en miniatura.
Los hooks personalizados se prueban con renderHook, que expone el valor en result.current y admite rerender con nuevas props y un wrapper con proveedores. useAlternar ha mostrado el uso de act y una prueba que sí merece la pena sobre identidades: que las acciones son estables entre renders, porque de eso depende que un efecto no se reejecute en bucle. useDebounce ha combinado temporizadores falsos con React —act(() => vi.advanceTimersByTime(n)) siempre juntos, useRealTimers en el afterEach, y shouldAdvanceTime: true o advanceTimers para que userEvent no se bloquee—, incluida la prueba de que el temporizador pendiente se cancela al desmontar. useAlmacenLocal ha justificado sus try/catch provocando un JSON corrupto y una cuota agotada con vi.spyOn(Storage.prototype, 'setItem'). Y LimiteDeError ha enseñado a silenciar el ruido esperado de la consola solo en las pruebas que provocan el error, nunca de forma global, con restoreAllMocks obligatorio.
Todo ello se resume en la tabla anti-inestabilidad: nunca un tiempo fijo, esperar a lo que se ve, cliente y almacén nuevos por prueba, retry: false, resetHandlers, onUnhandledRequest: 'error', almacenamiento limpio, fecha congelada con el reloj avanzando, una aserción por waitFor y ninguna variable mutable compartida. Y el diagnóstico de una prueba intermitente: ejecútala sola y luego acompañada; si pasa sola y falla en la suite, el problema es de aislamiento; si falla siempre a ratos, es de espera.
Aun con todo esto, hay cosas que jsdom no puede ver. Un botón «Reservar» tapado por un banner de cookies con position: fixed sigue siendo pulsable en estas pruebas. Una redirección tras el acceso que lleva a la ruta equivocada pasaría desapercibida si cada página se prueba por separado. Una sesión que no persiste al recargar no se manifiesta en un enrutador en memoria. Para eso hace falta un navegador de verdad, la aplicación entera arrancada y json-server respondiendo. La próxima lección monta Cypress, decide qué tres flujos de CicloUrbano merecen ese coste —identificarse, reservar y cancelar—, explica por qué cy.get(...) no devuelve un elemento y nunca se usa await, controla la red con cy.intercept, aísla las pruebas con cy.session y una base de datos reiniciable, y cierra con un flujo de GitHub Actions que lanza todo el módulo en cada cambio. La próxima lección es Pruebas de Extremo a Extremo con Cypress.
Curso de React
Módulo 1: Introducción a React
- ¿Qué es React?
- Configuración del Entorno de Desarrollo
- Hola Mundo en React
- JSX: Extensión de Sintaxis de JavaScript
- Cómo Renderiza React: Virtual DOM y Reconciliación
Módulo 2: Componentes de React
- Entendiendo los Componentes
- Componentes Funcionales vs de Clase
- Props: Pasando Datos a Componentes
- State: Gestión del Estado del Componente
- Estilos en los Componentes: CSS, Módulos y Utilidades
Módulo 3: Trabajando con Eventos
- Manejo de Eventos en React
- Renderizado Condicional
- Listas y Claves
- Formularios y Componentes Controlados
- Validación de Formularios y Componentes No Controlados
- Accesibilidad en Componentes Interactivos
Módulo 4: Conceptos Avanzados de Componentes
- Elevando el Estado
- Composición vs Herencia
- Métodos del Ciclo de Vida de React
- Hooks: Introducción y Uso Básico
- Límites de Error: Capturar Fallos en la Interfaz
Módulo 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef y Acceso al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalizados
Módulo 6: Enrutamiento en React
- Introducción a React Router
- Configuración de React Router
- Rutas Anidadas
- Navegación Programática
- Rutas Protegidas y Control de Acceso
Módulo 7: Gestión del Estado
- Introducción a la Gestión del Estado
- API de Contexto
- Redux: Introducción y Configuración
- Redux: Acciones y Reductores
- Redux: Conectando a React
- Estado del Servidor: Peticiones, Caché y Sincronización
Módulo 8: Optimización del Rendimiento
- Técnicas de Optimización del Rendimiento en React
- Memorización con React.memo
- Hooks useMemo y useCallback
- División de Código y Carga Perezosa
- Medir el Rendimiento con React DevTools Profiler
Módulo 9: Pruebas en React
- Introducción a las Pruebas
- Pruebas Unitarias con Jest
- Pruebas de Componentes con React Testing Library
- Pruebas de Código Asíncrono y Simulación de APIs
- Pruebas de Extremo a Extremo con Cypress
Módulo 10: Temas Avanzados
- Renderizado del Lado del Servidor (SSR) con Next.js
- Generación de Sitios Estáticos (SSG) con Next.js
- Suspense y React Server Components
- TypeScript con React
- React Native: Creación de Aplicaciones Móviles
