Fundamentos de React Testing Library
Prueba tus componentes como interactúan los usuarios — consultando elementos accesibles, no detalles de implementación.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
// 1. Render the component
render(<LoginForm onSubmit={handleSubmit} />);
// 2. Query elements (by priority)
screen.getByRole("textbox", { name: /email/i }); // Best: accessible role
screen.getByLabelText(/password/i); // Good: label association
screen.getByText(/submit/i); // OK: visible text
screen.getByTestId("login-form"); // Last resort: test ID
// 3. Interact with userEvent (not fireEvent)
const user = userEvent.setup();
await user.type(screen.getByRole("textbox", { name: /email/i }), "alice@example.com");
await user.click(screen.getByRole("button", { name: /submit/i }));
// 4. Assert outcomes
expect(screen.getByText(/welcome/i)).toBeInTheDocument();
// 5. Async queries
const message = await screen.findByText(/success/i); // waits up to 1s
await waitFor(() => expect(handleSubmit).toHaveBeenCalled());Cuándo usarlo: Cada vez que escribas una prueba de componente. Testing Library es el estándar para probar componentes React desde la perspectiva del usuario.
Ejemplo funcional
// src/components/login-form.tsx
"use client";
import { useState } from "react";
interface LoginFormProps {
onSubmit: (data: { email: string; password: string }) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
await onSubmit({ email, password });
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} aria-label="Login">
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? "Logging in..." : "Log in"}
</button>
</form>
);
}// src/components/login-form.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect } from "vitest";
import { LoginForm } from "./login-form";
describe("LoginForm", () => {
const user = userEvent.setup();
it("submits email and password", async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined);
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), "alice@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /log in/i }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: "alice@example.com",
password: "password123",
});
});
});
it("shows error on failed submission", async () => {
const handleSubmit = vi.fn().mockRejectedValue(new Error("Invalid credentials"));
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), "alice@example.com");
await user.type(screen.getByLabelText(/password/i), "wrong");
await user.click(screen.getByRole("button", { name: /log in/i }));
expect(await screen.findByRole("alert")).toHaveTextContent("Invalid credentials");
});
it("disables button while loading", async () => {
const handleSubmit = vi.fn(() => new Promise(() => {})); // never resolves
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), "alice@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /log in/i }));
expect(screen.getByRole("button")).toBeDisabled();
expect(screen.getByRole("button")).toHaveTextContent("Logging in...");
});
});Lo que demuestra esto:
- Consultas por labels y roles accesibles
userEvent.setup()para escritura y clics realistas- Pruebas de estados de éxito, error y carga
waitForyfindBypara afirmaciones asíncronas
Profundización
Cómo funciona
render()monta tu componente en eldocument.bodydejsdomy devuelve utilidades para la limpieza (automática con Testing Library moderno)screenes un objeto de conveniencia que contiene todas las consultas vinculadas adocument.body— no necesitas destructurar desderender()- Las consultas vienen en tres variantes:
getBy(lanza un error si no se encuentra),queryBy(devuelve null si no se encuentra),findBy(espera y reintenta) userEventsimula secuencias completas de interacción del usuario, incluyendo foco, keydown, pulsación de tecla, keyup, input y eventos change —fireEventsolo despacha un único eventowaitForreintenta su callback hasta que pasa o se agota el tiempo (1000 ms por defecto) — úsalo para afirmaciones que dependen de actualizaciones de state asíncronas
Prioridad de consultas
| Prioridad | Consulta | Usar cuando |
|---|---|---|
| 1 | getByRole | El elemento tiene un rol ARIA implícito o explícito |
| 2 | getByLabelText | Campos de formulario con labels asociados |
| 3 | getByPlaceholderText | No hay label disponible (prefiere labels) |
| 4 | getByText | Elementos no interactivos con texto visible |
| 5 | getByDisplayValue | Campos de formulario rellenados |
| 6 | getByAltText | Imágenes con texto alt |
| 7 | getByTitle | Elementos con atributo title |
| 8 | getByTestId | Último recurso cuando ninguna consulta semántica funciona |
Variantes de consultas
| Variante | Sin coincidencia | 1 coincidencia | Varias | Async |
|---|---|---|---|---|
getBy | lanza error | devuelve | lanza error | no |
queryBy | null | devuelve | lanza error | no |
findBy | lanza error | devuelve | lanza error | sí |
getAllBy | lanza error | array | array | no |
queryAllBy | [] | array | array | no |
findAllBy | lanza error | array | array | sí |
Notas de TypeScript
// The render result is typed -- you can access container and other utilities
const { container, rerender, unmount } = render(<MyComponent />);
// Custom render with wrapper for providers
function renderWithProviders(ui: React.ReactElement) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider>{children}</ThemeProvider>
),
});
}Errores comunes
-
Usar
fireEventen lugar deuserEvent—fireEvent.changeno simula escritura real (sin foco, sin pulsaciones de tecla). Solución: Usa siempreuserEvent.setup()y sus métodos (type,click,selectOptions). -
Consultar por test ID cuando funciona una consulta por rol — Los test IDs acoplan las pruebas a la implementación. Solución: Consulta la lista de roles ARIA — la mayoría de elementos tienen roles implícitos.
-
Envolver manualmente en
act()— Testing Library moderno envuelverender,userEventywaitForenact()por ti. Solución: Elimina las llamadas manuales aact()a menos que provoques actualizaciones de state fuera de las utilidades de Testing Library. -
No esperar las llamadas a
userEvent— Los métodos deuserEventson asíncronos en v14+. Solución: Siempreawaiten cadauser.type(),user.click(), etc. -
getBypara elementos que pueden no existir —getBylanza un error si falta el elemento, lo que hace fallar la prueba. Solución: UsaqueryBycuando afirmes que algo NO está en el documento:expect(screen.queryByText("Error")).not.toBeInTheDocument().
Alternativas
| Alternativa | Usar cuando | No usar cuando |
|---|---|---|
| Enzyme | Pruebas heredadas de componentes de clase de React (no recomendado para código nuevo) | Usas React 18+ o componentes de función |
| Playwright Component Testing | Necesitas renderizado en navegador real para pruebas visuales o de layout | Quieres pruebas unitarias rápidas en Node |
| Storybook interaction testing | Ya tienes stories y quieres probar interacciones dentro de ellas | Necesitas una suite completa con mocking y cobertura |
FAQs
¿Por qué debería usar userEvent en lugar de fireEvent?
fireEvent.changedespacha un único evento y omite foco, keydown, pulsación de tecla y keyup.userEventsimula la secuencia completa de interacción que dispararía un usuario real.- Usa siempre
userEvent.setup()yawaiten sus métodos.
¿Cuál es el orden de prioridad de consultas en Testing Library?
getByRole— rol ARIA accesible (mejor)getByLabelText— labels de campos de formulariogetByPlaceholderText— placeholders de inputsgetByText— contenido de texto visiblegetByTestId— último recurso
¿Cuál es la diferencia entre getBy, queryBy y findBy?
getBylanza un error si no se encuentra el elemento (síncrono).queryBydevuelvenullsi no se encuentra — úsalo para afirmar ausencia.findByespera y reintenta hasta encontrarlo o agotar el tiempo (async).
¿Cómo afirmo que un elemento NO está en el documento?
expect(screen.queryByText("Error")).not.toBeInTheDocument();Usa queryBy (no getBy) porque getBy lanza un error cuando falta el elemento.
Trampa común: ¿Por qué recibo advertencias sobre act() al usar Testing Library?
Testing Library moderno envuelve render, userEvent y waitFor en act() automáticamente. Elimina las llamadas manuales a act() a menos que provoques actualizaciones de state fuera de las utilidades de Testing Library.
¿Cómo espero cambios de state asíncronos después de una interacción?
Usa waitFor o findBy:
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalled();
});
// or
const message = await screen.findByText(/success/i);Trampa común: ¿Qué pasa si olvido await en una llamada a userEvent?
En userEvent v14+, todos los métodos son async. Sin await, la prueba puede pasar antes de que termine la interacción, lo que provoca falsos positivos o fallos intermitentes.
¿Cómo creo una función render personalizada con providers?
function renderWithProviders(ui: React.ReactElement) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider>{children}</ThemeProvider>
),
});
}¿Qué me da screen que destructurar desde render() no?
screen está vinculado a document.body y proporciona todas las consultas sin necesidad de destructurar. Mantiene el código de prueba más limpio y consistente.
¿Cómo tipar el resultado de render en TypeScript?
const { container, rerender, unmount } = render(<MyComponent />);El tipo de retorno se infiere automáticamente. container es HTMLElement, rerender acepta ReactElement, y unmount devuelve void.
¿Cuál es el tiempo de espera por defecto de findBy y waitFor?
Ambos usan 1000 ms por defecto. Puedes personalizar el tiempo de espera:
await screen.findByText(/data/i, {}, { timeout: 3000 });¿Cuándo debería usar getByRole con la opción name?
Cuando varios elementos comparten el mismo rol. La opción name coincide con el nombre accesible (texto del label, aria-label o texto del botón):
screen.getByRole("button", { name: /submit/i });Relacionado
- Pruebas de componentes React — patrones para distintos tipos de componentes
- Pruebas de formularios — técnicas específicas para formularios
- Pruebas de comportamiento asíncrono — manejo de estados de carga y temporizadores
- Mocking en pruebas — simulación de dependencias