Pruebas en React (React Testing)
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
- Escribiendo pruebas para componentes React, hooks personalizados o páginas.
- Añadiendo cobertura de pruebas a componentes heredados no probados.
- Migrando de Enzyme o patrones de la era de clases a React Testing Library.
- Configurando Vitest o Jest para un nuevo proyecto React.
- Simulando peticiones HTTP en pruebas.
- Verificando violaciones de accesibilidad.
- Decidiendo 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 debería:
- Renderizar el componente con los mismos proveedores que tiene en producción.
- Interactuar con él mediante queries accesibles (role, label) y
userEvent. - Verificar la salida visible y los efectos secundarios observables (callback ejecutado, petición enviada).
Una prueba NO debería:
- Inspeccionar el estado del componente, las props pasadas a hijos o qué hooks se llamaron.
- Simular (mockear) 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 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 | Úsalo cuando JSDOM carezca de la funcionalidad requerida |
| Cypress Component Testing | Navegador real, Cypress ya en uso | Alternativa a Playwright CT |
Elige uno. No combines RTL + Vitest Y Playwright CT en el mismo repositorio a menos que tengas una separación clara de responsabilidades.
Prioridad de queries
React Testing Library expone queries en tres niveles. Úsalos de arriba abajo:
- Accesibles para todos:
getByRole,getByLabelText,getByPlaceholderText,getByText,getByDisplayValue - Semánticas:
getByAltText,getByTitle - Test IDs (vía de escape):
getByTestId
// Mejor
screen.getByRole("button", { name: /save/i });
// Aceptable para inputs
screen.getByLabelText("Email");
// Último recurso
screen.getByTestId("save-btn");
Variantes:
getBy*— lanza una excepción si no encuentra coincidencia.queryBy*— devuelvenull(úsalo para "verificar ausencia").findBy*— asíncrono, devuelve una Promesa (úsalo 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(<UserForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText("Email"), "[email protected]")
await user.click(screen.getByRole("button", { name: /save/i }))
expect(onSubmit).toHaveBeenCalledWith({ email: "[email protected]" })
})
- Siempre usa
awaiten las llamadas auserEvent. - Llama a
userEvent.setup()una vez por prueba y reutiliza el objetouserdevuelto. userEventsimula una secuencia real de 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(saveSpy).toHaveBeenCalled())
// Elemento que debería 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/users/:id", ({ params }) =>
HttpResponse.json({ id: params.id, name: "Alice" }),
),
http.post("/api/users", async ({ request }) => {
const body = await request.json()
return HttpResponse.json({ id: "new-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 haga fallar la prueba de forma ruidosa. Los pases silenciosos son peores que el rojo.
Anulación por prueba
test("muestra error en 500", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 }))
)
render(<UserPage id="1" />)
expect(await screen.findByText(/algo salió mal/i)).toBeInTheDocument()
})
Envoltorio de proveedores (Provider Wrapping)
Envuelve los proveedores una vez en un archivo test-utils.tsx:
// test-utils.tsx
import { render, RenderOptions } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={lightTheme}>
<MemoryRouter>{ui}</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>,
options,
)
}
export * from "@testing-library/react"
Luego importa import { renderWithProviders, screen } from "test-utils" en cada archivo de prueba.
Pruebas de hooks personalizados (Custom Hooks)
import { renderHook, act } from "@testing-library/react"
test("useCounter incrementa y decrementa", () => {
const { result } = renderHook(() => useCounter(0))
expect(result.current.count).toBe(0)
act(() => result.current.increment())
expect(result.current.count).toBe(1)
act(() => result.current.decrement())
expect(result.current.count).toBe(0)
})
test("useCounter acepta valor inicial", () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
test("useUser obtiene datos de usuario", async () => {
// Instancia QueryClient UNA VEZ por prueba fuera del wrapper para que sobreviva a los re-renderizados.
// Crearlo dentro del closure del wrapper restablece el 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(() => useUser("1"), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual({ id: "1", name: "Alice" })
})
- Envuelve las llamadas que cambian el estado en
act. - Prueba solo a través de la API pública del hook.
- Para hooks que usan contexto, pasa un
wrapper.
Aserciones de accesibilidad (Accessibility Assertions)
import { axe, toHaveNoViolations } from "jest-axe" // o vitest-axe
expect.extend(toHaveNoViolations)
test("UserCard no tiene violaciones de a11y", async () => {
const { container } = render(<UserCard user={mockUser} />)
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 deficiente (limitado; JSDOM no tiene un motor CSS real, por lo que 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 de pruebas de accesibilidad más amplio.
Cuándo NO usar pruebas instantáneas (Snapshot Tests)
Las instantáneas de la salida renderizada:
- Se rompen con cada cambio de estilo.
- Se aprueban sin revisión durante el code review.
- Prueban detalles de implementación (estructura del DOM), no comportamiento.
Usos aceptables de instantáneas:
- Funciones de serialización de datos puras (
formatInvoice(invoice)-> string estable). - Archivos de configuración generados (por ejemplo, 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 strings del DOM).
Cuándo usar Playwright / Cypress
JSDOM (usado por Vitest/Jest) no puede:
- Renderizar layout real (flexbox, grid, consultas de viewport).
- Ejecutar animaciones nativas del navegador, transiciones CSS.
- Probar comportamiento de scroll (desplazamiento), arrastrar y soltar, pegar desde el portapapeles.
- Manejar iframes, ventanas emergentes, descargas, flujos de origen cruzado (cross-origin).
- Ejecutar una red real en un entorno controlado con soporte completo de DevTools.
Para cualquiera de estos casos, usa Playwright Component Testing (prueba de componente en un 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 layout importa o que usa APIs del navegador que no están 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 | Cubiertas por E2E 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,
},
},
}
Antipatrones (Anti-Patterns)
container.querySelector("...")— evita las queries accesibles, 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 tenga efectos secundarios pesados.
- Ignorar las advertencias de
act()— señalan errores reales (actualización de estado después de desmontar, envoltura asíncrona faltante). - Compartir estado mutable entre pruebas — se vuelven inestables 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 -> Escribe una prueba fallida para el próximo requisito
VERDE -> Escribe el código mínimo del componente para que pase
REFACTORIZAR -> Mejora el componente, las pruebas siguen en verde
REPETIR -> Próximo requisito
Para componentes nuevos:
- Define el tipo de props 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 watch
vitest run # ejecución única
vitest run --coverage # con cobertura
vitest run path/to/file.test.tsx # archivo único
# Jest
jest --watch
jest --coverage
jest path/to/file.test.tsx
# Modo CI
CI=true vitest run --coverage
Relacionados
- Reglas: rules/react/testing.md
- Skills: 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 el formulario de usuario y muestra éxito", async () => {
server.use(
http.post("/api/users", () =>
HttpResponse.json({ id: "1", name: "Alice" }, { status: 201 })
),
)
const user = userEvent.setup()
renderWithProviders(<UserForm />)
await user.type(screen.getByLabelText("Nombre"), "Alice")
await user.type(screen.getByLabelText("Email"), "[email protected]")
await user.click(screen.getByRole("button", { name: /guardar/i }))
expect(await screen.findByText(/guardado correctamente/i)).toBeInTheDocument()
})
Prueba de un límite de error (Error Boundary)
function Roto() {
throw new Error("boom")
}
test("el límite de error renderiza el fallback", () => {
// Suprime el ruido de console.error de React para el error esperado, luego restaura 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(
<ErrorBoundary fallback={<div>Algo salió mal</div>}>
<Roto />
</ErrorBoundary>,
)
expect(screen.getByText("Algo salió mal")).toBeInTheDocument()
} finally {
errorSpy.mockRestore()
}
})
Prueba de un límite de Suspense
test("muestra cargando y luego contenido", async () => {
renderWithProviders(
<Suspense fallback={<div>Cargando...</div>}>
<UserDetail id="1" />
</Suspense>,
)
expect(screen.getByText("Cargando...")).toBeInTheDocument()
expect(await screen.findByText("Alice")).toBeInTheDocument()
})