Pruebas en React
Patrones completos de pruebas en React para pruebas de componentes centradas en el comportamiento, pruebas de hooks personalizados, aserciones de accesibilidad y simulación de red a nivel de red.
Cuándo activarlo
- Escribir pruebas para componentes, hooks personalizados o páginas de React
- Añadir cobertura de pruebas a componentes antiguos sin probar
- Migrar desde Enzyme o patrones de la era de componentes de clase a React Testing Library
- Configurar Vitest o Jest para un nuevo proyecto React
- Simular peticiones HTTP en pruebas
- Verificar violaciones de accesibilidad
- Decidir qué pruebas pertenecen a RTL vs Playwright Component Testing vs E2E completo
Principio fundamental
Prueba lo que el usuario ve y hace, no los detalles de implementación.
Una prueba debe:
- Renderizar el componente con los mismos proveedores que tiene en producción
- Interactuar con él mediante consultas accesibles (
role,label) yuserEvent - Verificar la salida visible y los efectos secundarios observables (callback ejecutado, petición enviada)
Una prueba NO debe:
- Inspeccionar el estado del componente, las props pasadas a hijos, ni qué hooks se llamaron
- Simular el propio React o hooks del framework
- Verificar el número de renderizados o la estructura del DOM más allá de lo que afecta a los usuarios
Elección de la librería
| Ejecutor | Cuándo | Nota |
|---|---|---|
| Vitest | Vite, Remix, configuraciones modernas | Más rápido, ESM nativo, API compatible con Jest |
| Jest | Next.js, CRA, repositorios establecidos | Predeterminado para muchos proyectos React |
| Playwright Component Testing | Se necesita un motor de navegador real | Usar cuando a JSDOM le falte la característica requerida |
| Cypress Component Testing | Navegador real, Cypress ya en uso | Alternativa a Playwright CT |
Elige uno. No ejecutes RTL + Vitest Y Playwright CT en el mismo repositorio a menos que tengas una separación de carriles clara.
Prioridad de consultas
React Testing Library expone consultas en tres niveles: úsalas de arriba abajo:
- Accesible para todos:
getByRole,getByLabelText,getByPlaceholderText,getByText,getByDisplayValue - Semántico:
getByAltText,getByTitle - IDs de prueba (escape):
getByTestId
// Mejor
screen.getByRole("button", { name: /guardar/i });
// Aceptable para inputs
screen.getByLabelText("Correo electrónico");
// Último recurso
screen.getByTestId("btn-guardar");
Variantes:
getBy*— lanza una excepción si no hay coincidenciaqueryBy*— devuelvenull(útil para "verificar ausencia")findBy*— asíncrono, devuelve una Promesa (útil para elementos que aparecen después de trabajo asíncrono)
Interacción del usuario con userEvent
import userEvent from "@testing-library/user-event";
test("envía el formulario", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<FormularioUsuario onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Correo electrónico"), "[email protected]");
await user.click(screen.getByRole("button", { name: /guardar/i }));
expect(onSubmit).toHaveBeenCalledWith({ correo: "[email protected]" });
});
- Siempre usa
awaiten las llamadas a userEvent - Llama a
userEvent.setup()una vez por prueba, reutiliza eluserdevuelto userEventsimula una secuencia real del navegador;fireEventdespacha un único evento sintético — prefiereuserEvent
Patrones asíncronos
// Elemento que aparece después de trabajo asíncrono
expect(await screen.findByText("Cargado")).toBeInTheDocument();
// Verificación de efecto secundario
await waitFor(() => expect(guardarSpy).toHaveBeenCalled());
// Elemento que debe desaparecer
await waitForElementToBeRemoved(() => screen.queryByText("Cargando..."));
Nunca uses setTimeout + verificación — es inestable. Usa los matchers anteriores.
Simulación de red con MSW
Mock Service Worker simula a nivel de red. El componente, los hooks y la librería fetch se comportan exactamente como en producción.
Configuración
// test/setup.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/usuarios/:id", ({ params }) =>
HttpResponse.json({ id: params.id, nombre: "Alicia" }),
),
http.post("/api/usuarios", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "nuevo-id", ...body }, { status: 201 });
}),
];
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Configura onUnhandledRequest: "error" para que cualquier petición no simulada falle la prueba de forma ruidosa — las aprobaciones silenciosas son peores que las fallas.
Anulación por prueba
test("muestra error en 500", async () => {
server.use(
http.get("/api/usuarios/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<PaginaUsuario id="1" />);
expect(await screen.findByText(/algo salió mal/i)).toBeInTheDocument();
});
Envoltorio de proveedores
Envuelve los proveedores una vez en un test-utils.tsx:
// test-utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function renderConProveedores(
ui: React.ReactElement,
options?: RenderOptions,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={temaClaro}>
<MemoryRouter>{ui}</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>,
options,
);
}
export * from "@testing-library/react";
Luego importa import { renderConProveedores, screen } from "test-utils" en cada archivo de prueba.
Pruebas de hooks personalizados
import { renderHook, act } from "@testing-library/react";
test("useContador incrementa y decrementa", () => {
const { result } = renderHook(() => useContador(0));
expect(result.current.cuenta).toBe(0);
act(() => result.current.incrementar());
expect(result.current.cuenta).toBe(1);
act(() => result.current.decrementar());
expect(result.current.cuenta).toBe(0);
});
test("useContador acepta valor inicial", () => {
const { result } = renderHook(() => useContador(10));
expect(result.current.cuenta).toBe(10);
});
test("useUsuario obtiene datos del usuario", async () => {
// Instancia QueryClient UNA VEZ por prueba fuera del wrapper para que sobreviva a re-renderizados.
// Crearlo dentro del closure del wrapper reinicia el estado de caché en cada renderizado, produciendo pruebas inestables.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUsuario("1"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ id: "1", nombre: "Alicia" });
});
- Envuelve las llamadas que cambian el estado en
act - Prueba a través de la API pública del hook solamente
- Para hooks que usan contexto, pasa un
wrapper
Aserciones de accesibilidad
import { axe, toHaveNoViolations } from "jest-axe"; // o vitest-axe
expect.extend(toHaveNoViolations);
test("TarjetaUsuario no tiene violaciones de accesibilidad", async () => {
const { container } = render(<TarjetaUsuario usuario={usuarioSimulado} />);
expect(await axe(container)).toHaveNoViolations();
});
Ejecuta axe en pruebas de componentes para cada componente interactivo. Detecta:
- Etiquetas faltantes en inputs de formularios
- Uso inválido de ARIA
- Contraste de color pobre (limitado — JSDOM no tiene un motor CSS real, por lo que esto funciona solo para estilos en línea; el contraste visual pertenece a Playwright)
- Texto alternativo faltante en imágenes
- Violaciones de orden de encabezados
Enlace cruzado: skills/accessibility/SKILL.md para el manual más amplio de pruebas de accesibilidad.
Cuándo NO usar pruebas de instantáneas
Las instantáneas de la salida renderizada:
- Se rompen con cada cambio de estilo
- Se aprueban mecánicamente durante la revisión
- Prueban detalle de implementación (estructura del DOM), no comportamiento
Usos aceptables de instantáneas:
- Funciones puras de serialización de datos (
formatearFactura(factura)-> cadena estable) - Archivos de configuración generados (ej., salida de configuración de webpack)
Para regresión visual en componentes, usa capturas de pantalla de Playwright/Cypress o Percy/Chromatic — diferencias visuales reales, no cadenas de DOM.
Cuándo usar Playwright / Cypress
JSDOM (usado por Vitest/Jest) no puede:
- Renderizar diseño real (flexbox, grid, consultas de viewport)
- Ejecutar animaciones nativas del navegador, transiciones CSS
- Probar comportamiento de scroll, arrastrar y soltar, pegar desde portapapeles
- Manejar iframes, ventanas emergentes, descargas, flujos de origen cruzado
- Ejecutar red real en un entorno controlado con soporte completo de DevTools
Para cualquiera de esos, usa Playwright Component Testing (prueba de componente en navegador real) o E2E completo. Consulta e2e-testing skill.
Límite de decisión:
- Un hook, un componente de presentación, un formulario con lógica -> RTL
- Un componente cuyo diseño importa o que usa APIs del navegador no presentes en JSDOM -> Playwright CT
- Un flujo de usuario completo a través de múltiples páginas -> Playwright/Cypress E2E
Objetivos de cobertura
| Capa | Objetivo |
|---|---|
| Utilidades puras | >=90% |
| Hooks personalizados | >=85% |
| Componentes de presentación | >=80% — comportamiento, no líneas |
| Componentes contenedores | >=70% — caminos felices + estados de error |
| Páginas | E2E cubiertas por separado; mínimo prueba de humo |
Configura mediante vitest.config.ts / jest.config.js:
// vitest.config.ts
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
}
Anti-patrones
container.querySelector("...")— evita consultas de accesibilidad, permite que las pruebas pasen cuando los usuarios reales fallarían- Verificar el número de renderizados — detalle de implementación
jest.mock("react", ...)— nunca simules React. Refactoriza el componente en su lugar- Simular componentes hijos por defecto — prueba la integración, no el aislamiento. Simula solo cuando el hijo tiene efectos secundarios pesados
- Ignorar advertencias de
act()— señalan errores reales (actualización de estado después de desmontar, envoltura asíncrona faltante) - Compartir estado mutable entre pruebas — falla cuando el orden de las pruebas cambia
- Pruebas que pasan con
it.skip()eliminado — tu prueba no verifica realmente lo que crees
Flujo de trabajo TDD
ROJO -> Escribir prueba fallida para el siguiente requisito
VERDE -> Escribir código mínimo del componente para que pase
REFACTORIZAR -> Mejorar el componente, las pruebas permanecen en verde
REPETIR -> Siguiente requisito
Para componentes nuevos:
- Define el tipo de prop y la firma del componente
- Escribe la primera prueba para el caso más simple
- Verifica que falla por la razón correcta
- Implementa lo suficiente para que pase
- Añade el siguiente caso de prueba
- Refactoriza cuando la tercera prueba similar revele un patrón
Comandos de prueba
# Vitest
vitest # modo observador
vitest run # ejecución única
vitest run --coverage # con cobertura
vitest run ruta/al/archivo.test.tsx # archivo único
# Jest
jest --watch
jest --coverage
jest ruta/al/archivo.test.tsx
# Modo CI
CI=true vitest run --coverage
Relacionado
- Reglas: rules/react/testing.md
- Habilidades: react-patterns, accessibility, e2e-testing, tdd-workflow
- Agentes:
react-reviewer(revisa la calidad de las pruebas durante la revisión de código),tdd-guide(aplica el proceso TDD) - Comandos:
/react-test,/react-review
Ejemplos
Envío de formulario con MSW y userEvent
test("envía formulario de usuario y muestra éxito", async () => {
server.use(
http.post("/api/usuarios", () =>
HttpResponse.json({ id: "1", nombre: "Alicia" }, { status: 201 }),
),
);
const user = userEvent.setup();
renderConProveedores(<FormularioUsuario />);
await user.type(screen.getByLabelText("Nombre"), "Alicia");
await user.type(screen.getByLabelText("Correo electrónico"), "[email protected]");
await user.click(screen.getByRole("button", { name: /guardar/i }));
expect(await screen.findByText(/guardado exitosamente/i)).toBeInTheDocument();
});
Probar un límite de error
function Roto() {
throw new Error("explosión");
}
test("el límite de error renderiza la alternativa", () => {
// Suprimir el ruido de console.error de React para el error esperado, luego restaurar para que
// el espía no se filtre entre pruebas y oculte errores reales en otros lugares.
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<LimiteError fallback={<div>Algo salió mal</div>}>
<Roto />
</LimiteError>,
);
expect(screen.getByText("Algo salió mal")).toBeInTheDocument();
} finally {
errorSpy.mockRestore();
}
});
Probar un límite de Suspense
test("muestra carga y luego contenido", async () => {
renderConProveedores(
<Suspense fallback={<div>Cargando...</div>}>
<DetalleUsuario id="1" />
</Suspense>,
);
expect(screen.getByText("Cargando...")).toBeInTheDocument();
expect(await screen.findByText("Alicia")).toBeInTheDocument();
});