Testing React Components
Test component rendering, user interactions, conditional UI, and dynamic content with confidence.
Search across all documentation pages
Test component rendering, user interactions, conditional UI, and dynamic content with confidence.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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:
queryBywithin()aria-label for targeted delete button queriesrender() call creates a fresh DOM tree -- tests are isolated by defaultwithin() scopes queries to a specific container element, useful for testing individual list itemsqueryBy returns null instead of throwing, making it ideal for asserting absencegetAllBy returns an array of matching elements -- use .toHaveLength() to assert list sizesSnapshot 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");
});// 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" })]} />);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 userEvent methods return promises in v14+. Fix: Always await them 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 with within() or use unique aria-label attributes.
Testing too many things in one test -- Tests that add, toggle, and delete in a single it block are hard to debug when they fail. Fix: Write focused tests for each behavior.
| 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 |
Use queryBy which returns null instead of throwing:
render(<Banner show={false} />);
expect(screen.queryByRole("banner")).not.toBeInTheDocument();Use within() to scope queries to a container:
const items = screen.getAllByRole("listitem");
expect(within(items[0]).getByText("First")).toBeInTheDocument();toHaveTextContent or toBeInTheDocument.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");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.
let counter = 0;
vi.stubGlobal("crypto", {
randomUUID: () => `test-id-${++counter}`,
});In userEvent v14+, all methods are async. Without await, interactions may not complete before assertions run, causing false positives or flaky tests.
function createTodo(overrides: Partial<Todo> = {}): Todo {
return {
id: crypto.randomUUID(),
text: "Default todo",
completed: false,
...overrides,
};
}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.
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(3);.snap file.Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥