Testing React Components
Test component rendering, user interactions, conditional UI, and dynamic content with confidence.
Recipe
Quick-reference recipe card -- copy-paste ready.
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();When to reach for this: Whenever you build a component that renders props, handles interactions, or shows conditional UI.
Working Example
// 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();
});
});
});What this demonstrates:
- Testing empty state, initial rendering, and dynamic updates
- User interactions: typing, clicking, form submission
- Conditional rendering assertions with
queryBy - Scoped queries with
within() - Accessible
aria-labelfor targeted delete button queries
Deep Dive
How It Works
- Each
render()call creates a fresh DOM tree -- tests are isolated by default within()scopes queries to a specific container element, useful for testing individual list itemsqueryByreturnsnullinstead of throwing, making it ideal for asserting absencegetAllByreturns an array of matching elements -- use.toHaveLength()to assert list sizes- Testing Library auto-cleans up after each test (unmounts and removes DOM nodes)
Variations
Snapshot testing (use sparingly):
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 reviewInline snapshots for small output:
it("renders correct class names", () => {
render(<Badge variant="success">OK</Badge>);
expect(screen.getByText("OK").className).toMatchInlineSnapshot(
`"badge badge-success"`
);
});Testing with rerender for prop changes:
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");
});TypeScript Notes
// 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" })]} />);Gotchas
-
Testing implementation details -- Checking internal state, instance methods, or CSS class names ties tests to code structure. Fix: Assert what the user sees -- text content, visibility, enabled/disabled state.
-
Over-relying on snapshots -- Large snapshots are hard to review and get rubber-stamped on update. Fix: Use snapshots only for small, stable output. Prefer explicit assertions.
-
Forgetting to await userEvent -- All
userEventmethods return promises in v14+. Fix: Alwaysawaitthem or the test may pass before the interaction completes. -
Not using
within()for list items --screen.getByText("Delete")matches the first delete button on the page. Fix: Scope to the specific list item withwithin()or use uniquearia-labelattributes. -
Testing too many things in one test -- Tests that add, toggle, and delete in a single
itblock are hard to debug when they fail. Fix: Write focused tests for each behavior.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Storybook + Chromatic | You need visual regression testing alongside component stories | You only need behavioral tests |
| Playwright Component Testing | You need real browser rendering (canvas, complex CSS) | Fast unit-level component tests are sufficient |
| Snapshot testing only | Guarding against unintended changes in stable, small components | Components change frequently or snapshots are large |
FAQs
How do I test that an element is NOT rendered (conditional rendering)?
Use queryBy which returns null instead of throwing:
render(<Banner show={false} />);
expect(screen.queryByRole("banner")).not.toBeInTheDocument();How do I test a specific item in a list?
Use within() to scope queries to a container:
const items = screen.getAllByRole("listitem");
expect(within(items[0]).getByText("First")).toBeInTheDocument();When should I use snapshot testing?
- Use sparingly, only for small and stable output.
- Snapshots are brittle and often auto-updated without proper review.
- Prefer explicit assertions like
toHaveTextContentortoBeInTheDocument.
How do I test that a component updates when props change?
Use rerender from the render result:
const { rerender } = render(<Alert severity="info" message="Note" />);
rerender(<Alert severity="error" message="Note" />);
expect(screen.getByRole("alert")).toHaveClass("alert-error");Gotcha: Why does screen.getByText("Delete") match the wrong button?
If multiple elements contain the same text, getByText matches the first one. Use within() to scope to a specific parent, or use unique aria-label attributes.
How do I mock crypto.randomUUID() for deterministic test IDs?
let counter = 0;
vi.stubGlobal("crypto", {
randomUUID: () => `test-id-${++counter}`,
});Why should I write focused tests instead of testing multiple behaviors in one it block?
- Focused tests are easier to debug when they fail.
- The test name tells you exactly what broke.
- Multi-behavior tests give vague failure messages.
Gotcha: What happens if I forget to await userEvent methods?
In userEvent v14+, all methods are async. Without await, interactions may not complete before assertions run, causing false positives or flaky tests.
How do I create a type-safe test factory for complex props in TypeScript?
function createTodo(overrides: Partial<Todo> = {}): Todo {
return {
id: crypto.randomUUID(),
text: "Default todo",
completed: false,
...overrides,
};
}Does Testing Library automatically clean up after each test?
Yes. Modern Testing Library auto-cleans up after each test by unmounting components and removing DOM nodes. You do not need to call cleanup() manually.
How do I assert the number of items in a list?
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(3);What is the difference between inline snapshots and regular snapshots?
- Regular snapshots save output to a separate
.snapfile. - Inline snapshots embed the expected value directly in the test file, making them easier to review for small output.
Related
- React Testing Library Fundamentals -- query and assertion basics
- Testing Custom Hooks -- testing hooks outside components
- Testing Forms -- form-specific testing patterns
- Testing Async Behavior -- loading and error states