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

  1. Por qué la asincronía rompe una prueba ingenua
  2. Las herramientas de espera: findBy, waitFor y waitForElementToBeRemoved
  3. El aviso act(...), explicado de verdad
  4. Simular la red: por qué a nivel de protocolo y no del módulo
  5. MSW v2: instalación y manejadores de CicloUrbano
  6. servidor.js y el enganche en configuracion.js
  7. Probar componentes con TanStack Query
  8. Los tres estados de la interfaz: cargando, éxito y error
  9. server.use: sobrescribir un manejador en una prueba concreta
  10. Probar una mutación completa
  11. renderHook: probar hooks personalizados
  12. useDebounce: temporizadores falsos dentro de React
  13. useAlmacenLocal y el almacenamiento del navegador
  14. Probar un componente que navega tras una operación
  15. Probar LimiteDeError y silenciar el ruido esperado
  16. Cómo no escribir pruebas inestables

  1. 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.

  1. Las herramientas de espera: findBy, waitFor y waitForElementToBeRemoved

Testing 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.

  1. El aviso act(...), explicado de verdad

Tarde 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 desaparece

El ú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í.

  1. 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 useBicicletas completo, incluida la construcción de http://localhost:3001/bicicletas?estacionId=est-01, la comprobación de respuesta.ok y el respuesta.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 useCrearReserva cambia el método de POST a PUT, el manejador no responde y la prueba falla. Con vi.mock habría seguido verde.
  • Provocar errores es trivial: devolver un 500 es una línea, y reproducirlo con la API real exigiría apagar json-server a mitad de prueba.

  1. MSW v2: instalación y manejadores de CicloUrbano

npm install -D msw

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 el 201 del POST. Cuanto más se parezca la simulación a la API real, menos posibilidades de que la prueba pase y la aplicación falle.

  1. 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"]

  1. 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.use no 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 }
    }
  });
}

  1. 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.

  1. server.use: sobrescribir un manejador en una prueba concreta

Los 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.

  1. 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 es comportamiento observable: si desaparece, el usuario ve una lista desactualizada.

  1. renderHook: probar hooks personalizados

Un 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');

  1. useDebounce: temporizadores falsos dentro de React

En 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:

  1. act(() => vi.advanceTimersByTime(n)), siempre junto. Avanzar el reloj hace vencer un setTimeout que llama a setValorRetrasado; sin act, esa actualización queda fuera del control de React, result.current no se refresca y aparece el aviso del apartado 3.
  2. vi.useRealTimers() en el afterEach, sin excepción. Los temporizadores falsos que sobreviven a la prueba cuelgan las siguientes.
  3. Temporizadores falsos y userEvent se llevan mal por defecto. userEvent espera 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 pasar userEvent.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.

  1. useAlmacenLocal y el almacenamiento del navegador

jsdom 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.

  1. 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.

  1. Probar LimiteDeError y silenciar el ruido esperado

LimiteDeError 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. Un console.error global 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 el afterEach es 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.

  1. 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 await delante de findBy. La aserción recibe una promesa, que siempre es truthy, así que expect(promesa).toBeInTheDocument() da un error críptico o pasa por accidente. Regla: findBy siempre con await.
  • Usar getBy justo después de render esperando 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 act envolviendo 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 QueryClient entre pruebas. Produce el fallo más desconcertante del módulo: pruebas que pasan solas y fallan juntas, o al revés. renderizar lo evita, pero no lo esquives creando el cliente en el ámbito del describe.
  • Olvidar resetHandlers(). Un server.use con un 500 sobrevive a la prueba y rompe otras que no tienen nada que ver. Está en configuracion.js; no lo quites.
  • Simular fetch en lugar de usar MSW. Obliga a reimplementar Response, 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 POST rellena y el GET lee 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.js y 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:

  1. 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í, sin retry: false, la mutación también reintenta.
  2. 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.
  3. fireEvent en lugar de userEvent. fireEvent.change asigna el valor sin disparar la secuencia real, y fireEvent.click pulsarí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.
  4. Dos aserciones dentro de un waitFor, y getByText('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.onLine es de solo lectura y una asignación directa no funciona.
  • act alrededor del dispatchEvent es necesario porque el escuchador llama a un actualizador de estado fuera de cualquier interacción de userEvent. Es el mismo caso del apartado 11.
  • La última prueba es la excepción que confirma la regla. Espiar removeEventListener es 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

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