Fundamentos de React Testing Library
Prueba tus componentes como interactúan los usuarios - consultando elementos accesibles, no detalles de implementación.
Busca en todas las páginas de la documentación
Prueba tus componentes como interactúan los usuarios - consultando elementos accesibles, no detalles de implementación.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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:
userEvent.setup() para escritura y clics realistaswaitFor y findBy para afirmaciones asíncronasrender() monta tu componente en el document.body de jsdom y devuelve utilidades para la limpieza (automática con Testing Library moderno)screen es un objeto de conveniencia que contiene todas las consultas vinculadas a document.body - no necesitas destructurar desde render()getBy (lanza un error si no se encuentra), queryBy (devuelve null si no se encuentra), findBy (espera y reintenta)userEvent simula secuencias completas de interacción del usuario, incluyendo foco, keydown, pulsación de tecla, keyup, input y eventos change - fireEvent solo despacha un único eventowaitFor reintenta 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 | 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 |
| 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í |
// 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>
),
});
}Usar fireEvent en lugar de userEvent - fireEvent.change no simula escritura real (sin foco, sin pulsaciones de tecla). Solución: Usa siempre userEvent.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 envuelve render, userEvent y waitFor en act() por ti. Solución: Elimina las llamadas manuales a act() a menos que provoques actualizaciones de state fuera de las utilidades de Testing Library.
No esperar las llamadas a userEvent - Los métodos de userEvent son asíncronos en v14+. Solución: Siempre await en cada user.type(), user.click(), etc.
getBy para elementos que pueden no existir - getBy lanza un error si falta el elemento, lo que hace fallar la prueba. Solución: Usa queryBy cuando afirmes que algo NO está en el documento: expect(screen.queryByText("Error")).not.toBeInTheDocument().
| 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 |
fireEvent.change despacha un único evento y omite foco, keydown, pulsación de tecla y keyup.userEvent simula la secuencia completa de interacción que dispararía un usuario real.userEvent.setup() y await en sus métodos.getByRole - rol ARIA accesible (mejor)getByLabelText - labels de campos de formulariogetByPlaceholderText - placeholders de inputsgetByText - contenido de texto visiblegetByTestId - último recursogetBy lanza un error si no se encuentra el elemento (síncrono).queryBy devuelve null si no se encuentra - úsalo para afirmar ausencia.findBy espera y reintenta hasta encontrarlo o agotar el tiempo (async).expect(screen.queryByText("Error")).not.toBeInTheDocument();Usa queryBy (no getBy) porque getBy lanza un error cuando falta el elemento.
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.
Usa waitFor o findBy:
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalled();
});
// or
const message = await screen.findByText(/success/i);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.
function renderWithProviders(ui: React.ReactElement) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider>{children}</ThemeProvider>
),
});
}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.
const { container, rerender, unmount } = render(<MyComponent />);El tipo de retorno se infiere automáticamente. container es HTMLElement, rerender acepta ReactElement, y unmount devuelve void.
Ambos usan 1000 ms por defecto. Puedes personalizar el tiempo de espera:
await screen.findByText(/data/i, {}, { timeout: 3000 });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 });Revisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥