Pruebas de componentes React
Prueba el renderizado de componentes, las interacciones del usuario, la UI condicional y el contenido dinámico con confianza.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
// Test rendering with props
render(<Alert severity="warning" message="Disk full" />);
expect(screen.getByRole("alert")).toHaveTextContent("Disk full");
// Test user interactions
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /delete/i }));
expect(screen.queryByText("Item 1")).not.toBeInTheDocument();
// Test conditional rendering
render(<Banner show={false} />);
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
// Test lists
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(3);
expect(within(items[0]).getByText("First")).toBeInTheDocument();Cuándo usarlo: Siempre que construyas un componente que renderice props, maneje interacciones o muestre UI condicional.
Ejemplo funcional
// src/components/todo-list.tsx
"use client";
import { useState } from "react";
interface Todo {
id: string;
text: string;
completed: boolean;
}
export function TodoList({ initialTodos = [] }: { initialTodos?: Todo[] }) {
const [todos, setTodos] = useState<Todo[]>(initialTodos);
const [input, setInput] = useState("");
function addTodo() {
if (!input.trim()) return;
setTodos((prev) => [
...prev,
{ id: crypto.randomUUID(), text: input.trim(), completed: false },
]);
setInput("");
}
function toggleTodo(id: string) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
}
function deleteTodo(id: string) {
setTodos((prev) => prev.filter((t) => t.id !== id));
}
const remaining = todos.filter((t) => !t.completed).length;
return (
<section aria-label="Todo list">
<form
onSubmit={(e) => {
e.preventDefault();
addTodo();
}}
>
<label htmlFor="new-todo">New todo</label>
<input
id="new-todo"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<button type="submit">Add</button>
</form>
{todos.length === 0 ? (
<p>No todos yet. Add one above!</p>
) : (
<>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span
style={{
textDecoration: todo.completed ? "line-through" : "none",
}}
>
{todo.text}
</span>
</label>
<button
onClick={() => deleteTodo(todo.id)}
aria-label={`Delete ${todo.text}`}
>
Delete
</button>
</li>
))}
</ul>
<p>{remaining} item{remaining !== 1 ? "s" : ""} remaining</p>
</>
)}
</section>
);
}// src/components/todo-list.test.tsx
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { TodoList } from "./todo-list";
// Mock crypto.randomUUID for deterministic IDs
beforeEach(() => {
let counter = 0;
vi.stubGlobal("crypto", {
randomUUID: () => `test-id-${++counter}`,
});
});
describe("TodoList", () => {
const user = userEvent.setup();
describe("empty state", () => {
it("shows empty message when no todos", () => {
render(<TodoList />);
expect(screen.getByText(/no todos yet/i)).toBeInTheDocument();
});
it("does not show the list", () => {
render(<TodoList />);
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});
});
describe("rendering with initial todos", () => {
const todos = [
{ id: "1", text: "Write tests", completed: false },
{ id: "2", text: "Fix bugs", completed: true },
];
it("renders all todos", () => {
render(<TodoList initialTodos={todos} />);
expect(screen.getAllByRole("listitem")).toHaveLength(2);
expect(screen.getByText("Write tests")).toBeInTheDocument();
expect(screen.getByText("Fix bugs")).toBeInTheDocument();
});
it("shows completed todos with checked checkbox", () => {
render(<TodoList initialTodos={todos} />);
const items = screen.getAllByRole("listitem");
const fixBugsItem = items[1];
expect(within(fixBugsItem).getByRole("checkbox")).toBeChecked();
});
it("shows remaining count", () => {
render(<TodoList initialTodos={todos} />);
expect(screen.getByText("1 item remaining")).toBeInTheDocument();
});
});
describe("adding todos", () => {
it("adds a new todo on form submit", async () => {
render(<TodoList />);
await user.type(screen.getByLabelText(/new todo/i), "Buy milk");
await user.click(screen.getByRole("button", { name: /add/i }));
expect(screen.getByText("Buy milk")).toBeInTheDocument();
expect(screen.getByLabelText(/new todo/i)).toHaveValue("");
});
it("does not add empty todos", async () => {
render(<TodoList />);
await user.click(screen.getByRole("button", { name: /add/i }));
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});
});
describe("toggling todos", () => {
it("toggles completion on checkbox click", async () => {
render(
<TodoList
initialTodos={[{ id: "1", text: "Test", completed: false }]}
/>
);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).not.toBeChecked();
await user.click(checkbox);
expect(checkbox).toBeChecked();
expect(screen.getByText("0 items remaining")).toBeInTheDocument();
});
});
describe("deleting todos", () => {
it("removes todo on delete click", async () => {
render(
<TodoList
initialTodos={[{ id: "1", text: "Test", completed: false }]}
/>
);
await user.click(screen.getByRole("button", { name: /delete test/i }));
expect(screen.queryByText("Test")).not.toBeInTheDocument();
expect(screen.getByText(/no todos yet/i)).toBeInTheDocument();
});
});
});Lo que demuestra esto:
- Pruebas del estado vacío, renderizado inicial y actualizaciones dinámicas
- Interacciones del usuario: escribir, hacer clic, envío de formularios
- Afirmaciones de renderizado condicional con
queryBy - Consultas con alcance mediante
within() aria-labelaccesible para consultas dirigidas al botón de eliminar
Análisis profundo
Cómo funciona
- Cada llamada a
render()crea un árbol DOM nuevo — las pruebas están aisladas por defecto within()limita las consultas a un elemento contenedor específico, útil para probar elementos individuales de una listaqueryBydevuelvenullen lugar de lanzar un error, lo que lo hace ideal para afirmar ausenciagetAllBydevuelve un array de elementos coincidentes — usa.toHaveLength()para afirmar el tamaño de listas- Testing Library limpia automáticamente después de cada prueba (desmonta y elimina nodos del DOM)
Variaciones
Pruebas con snapshot (úsalas con moderación):
it("matches snapshot", () => {
const { container } = render(
<Alert severity="error" message="Something broke" />
);
expect(container.firstChild).toMatchSnapshot();
});
// Snapshots are useful for detecting unintended UI changes
// but are brittle and often auto-updated without reviewSnapshots inline para salida pequeña:
it("renders correct class names", () => {
render(<Badge variant="success">OK</Badge>);
expect(screen.getByText("OK").className).toMatchInlineSnapshot(
`"badge badge-success"`
);
});Pruebas con rerender para cambios de props:
it("updates when severity changes", () => {
const { rerender } = render(<Alert severity="info" message="Note" />);
expect(screen.getByRole("alert")).toHaveClass("alert-info");
rerender(<Alert severity="error" message="Note" />);
expect(screen.getByRole("alert")).toHaveClass("alert-error");
});Notas de TypeScript
// Type-safe test factories for complex props
function createTodo(overrides: Partial<Todo> = {}): Todo {
return {
id: crypto.randomUUID(),
text: "Default todo",
completed: false,
...overrides,
};
}
// Use in tests
render(<TodoList initialTodos={[createTodo({ text: "Custom" })]} />);Errores comunes
-
Probar detalles de implementación — Comprobar state interno, métodos de instancia o nombres de clase CSS ata las pruebas a la estructura del código. Solución: Afirma lo que ve el usuario — contenido de texto, visibilidad, estado habilitado/deshabilitado.
-
Depender demasiado de snapshots — Los snapshots grandes son difíciles de revisar y se aprueban sin mirar al actualizarse. Solución: Usa snapshots solo para salida pequeña y estable. Prefiere afirmaciones explícitas.
-
Olvidar esperar userEvent — Todos los métodos de
userEventdevuelven promesas en v14+. Solución: Siempre hazawaito la prueba puede pasar antes de que la interacción termine. -
No usar
within()para elementos de lista —screen.getByText("Eliminar")coincide con el primer botón de eliminar de la página. Solución: Limita el alcance al elemento de lista específico conwithin()o usa atributosaria-labelúnicos. -
Probar demasiadas cosas en una sola prueba — Las pruebas que añaden, alternan y eliminan en un solo bloque
itson difíciles de depurar cuando fallan. Solución: Escribe pruebas enfocadas para cada comportamiento.
Alternativas
| Alternativa | Úsala cuando | No la uses cuando |
|---|---|---|
| Storybook + Chromatic | Necesitas pruebas de regresión visual junto a las stories de componentes | Solo necesitas pruebas de comportamiento |
| Playwright Component Testing | Necesitas renderizado en un navegador real (canvas, CSS complejo) | Las pruebas unitarias rápidas de componentes son suficientes |
| Solo pruebas con snapshot | Proteger contra cambios no deseados en componentes pequeños y estables | Los componentes cambian con frecuencia o los snapshots son grandes |
Preguntas frecuentes
¿Cómo pruebo que un elemento NO se renderiza (renderizado condicional)?
Usa queryBy, que devuelve null en lugar de lanzar un error:
render(<Banner show={false} />);
expect(screen.queryByRole("banner")).not.toBeInTheDocument();¿Cómo pruebo un elemento específico en una lista?
Usa within() para limitar las consultas a un contenedor:
const items = screen.getAllByRole("listitem");
expect(within(items[0]).getByText("First")).toBeInTheDocument();¿Cuándo debo usar pruebas con snapshot?
- Úsalas con moderación, solo para salida pequeña y estable.
- Los snapshots son frágiles y a menudo se actualizan automáticamente sin una revisión adecuada.
- Prefiere afirmaciones explícitas como
toHaveTextContentotoBeInTheDocument.
¿Cómo pruebo que un componente se actualiza cuando cambian las props?
Usa rerender del resultado de render:
const { rerender } = render(<Alert severity="info" message="Note" />);
rerender(<Alert severity="error" message="Note" />);
expect(screen.getByRole("alert")).toHaveClass("alert-error");Error común: ¿Por qué screen.getByText("Eliminar") coincide con el botón equivocado?
Si varios elementos contienen el mismo texto, getByText coincide con el primero. Usa within() para limitar el alcance a un padre específico, o usa atributos aria-label únicos.
¿Cómo simulo crypto.randomUUID() para IDs de prueba deterministas?
let counter = 0;
vi.stubGlobal("crypto", {
randomUUID: () => `test-id-${++counter}`,
});¿Por qué debería escribir pruebas enfocadas en lugar de probar varios comportamientos en un solo bloque it?
- Las pruebas enfocadas son más fáciles de depurar cuando fallan.
- El nombre de la prueba te indica exactamente qué se rompió.
- Las pruebas con varios comportamientos dan mensajes de fallo vagos.
Error común: ¿Qué pasa si olvido hacer await en los métodos de userEvent?
En userEvent v14+, todos los métodos son asíncronos. Sin await, las interacciones pueden no completarse antes de que se ejecuten las afirmaciones, provocando falsos positivos o pruebas inestables.
¿Cómo creo una factory de prueba con tipado seguro para props complejas en TypeScript?
function createTodo(overrides: Partial<Todo> = {}): Todo {
return {
id: crypto.randomUUID(),
text: "Default todo",
completed: false,
...overrides,
};
}¿Testing Library limpia automáticamente después de cada prueba?
Sí. La versión moderna de Testing Library limpia automáticamente después de cada prueba desmontando componentes y eliminando nodos del DOM. No necesitas llamar a cleanup() manualmente.
¿Cómo afirmo el número de elementos en una lista?
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(3);¿Cuál es la diferencia entre snapshots inline y snapshots normales?
- Los snapshots normales guardan la salida en un archivo
.snapseparado. - Los snapshots inline incrustan el valor esperado directamente en el archivo de prueba, lo que facilita su revisión para salida pequeña.
Relacionado
- Fundamentos de React Testing Library — conceptos básicos de consultas y afirmaciones
- Pruebas de hooks personalizados — probar hooks fuera de componentes
- Pruebas de formularios — patrones de prueba específicos para formularios
- Pruebas de comportamiento asíncrono — estados de carga y error