React Testing Library Fundamentals
Test your components the way users interact with them -- by querying accessible elements, not implementation details.
Search across all documentation pages
Test your components the way users interact with them -- by querying accessible elements, not implementation details.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card -- copy-paste ready.
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());When to reach for this: Every time you write a component test. Testing Library is the standard for testing React components from a user's perspective.
// 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...");
});
});What this demonstrates:
userEvent.setup() for realistic typing and clickingwaitFor and findBy for async assertionsrender() mounts your component into a jsdom document body and returns utilities for cleanup (automatic with modern Testing Library)screen is a convenience object that holds all queries bound to document.body -- no need to destructure from render()getBy (throws if not found), queryBy (returns null if not found), findBy (waits and retries)userEvent simulates full user interaction sequences including focus, keydown, keypress, keyup, input, and change events -- fireEvent only dispatches a single eventwaitFor retries its callback until it passes or times out (default 1000ms) -- use it for assertions that depend on async state updates| Priority | Query | Use When |
|---|---|---|
| 1 | getByRole | Element has an implicit or explicit ARIA role |
| 2 | getByLabelText | Form fields with associated labels |
| 3 | getByPlaceholderText | No label available (prefer labels) |
| 4 | getByText | Non-interactive elements with visible text |
| 5 | getByDisplayValue | Filled-in form fields |
| 6 | getByAltText | Images with alt text |
| 7 | getByTitle | Elements with title attribute |
| 8 | getByTestId | Last resort when no semantic query works |
| Variant | No Match | 1 Match | Multiple | Async |
|---|---|---|---|---|
getBy | throws | returns | throws | no |
queryBy | null | returns | throws | no |
findBy | throws | returns | throws | yes |
getAllBy | throws | array | array | no |
queryAllBy | [] | array | array | no |
findAllBy | throws | array | array | yes |
// 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>
),
});
}Using fireEvent instead of userEvent -- fireEvent.change does not simulate real typing (no focus, no keystrokes). Fix: Always use userEvent.setup() and its methods (type, click, selectOptions).
Querying by test ID when a role query works -- Test IDs couple tests to implementation. Fix: Check the ARIA roles list -- most elements have implicit roles.
Wrapping in act() manually -- Modern Testing Library wraps render, userEvent, and waitFor in act() for you. Fix: Remove manual act() calls unless you are triggering state updates outside of Testing Library utilities.
Not awaiting userEvent calls -- userEvent methods are async in v14+. Fix: Always await every user.type(), user.click(), etc.
getBy for elements that may not exist -- getBy throws if the element is missing, which crashes the test. Fix: Use queryBy when asserting something is NOT in the document: expect(screen.queryByText("Error")).not.toBeInTheDocument().
| Alternative | Use When | Don't Use When |
|---|---|---|
| Enzyme | Legacy React class component tests (not recommended for new code) | You use React 18+ or function components |
| Playwright Component Testing | You need real browser rendering for visual or layout tests | You want fast unit tests in Node |
| Storybook interaction testing | You already have stories and want to test interactions within them | You need a full test suite with mocking and coverage |
fireEvent.change dispatches a single event and skips focus, keydown, keypress, and keyup.userEvent simulates the full interaction sequence a real user would trigger.userEvent.setup() and await its methods.getByRole -- accessible ARIA role (best)getByLabelText -- form field labelsgetByPlaceholderText -- input placeholdersgetByText -- visible text contentgetByTestId -- last resortgetBy throws if the element is not found (synchronous).queryBy returns null if not found -- use for asserting absence.findBy waits and retries until found or timeout (async).expect(screen.queryByText("Error")).not.toBeInTheDocument();Use queryBy (not getBy) because getBy throws when the element is missing.
Modern Testing Library wraps render, userEvent, and waitFor in act() automatically. Remove manual act() calls unless you trigger state updates outside Testing Library utilities.
Use waitFor or findBy:
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalled();
});
// or
const message = await screen.findByText(/success/i);In userEvent v14+, all methods are async. Without await, the test may pass before the interaction completes, leading to false positives or flaky failures.
function renderWithProviders(ui: React.ReactElement) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider>{children}</ThemeProvider>
),
});
}screen is bound to document.body and provides all queries without needing to destructure. It keeps test code cleaner and more consistent.
const { container, rerender, unmount } = render(<MyComponent />);The return type is automatically inferred. container is HTMLElement, rerender accepts ReactElement, and unmount returns void.
Both default to 1000ms. You can customize the timeout:
await screen.findByText(/data/i, {}, { timeout: 3000 });When multiple elements share the same role. The name option matches the accessible name (label text, aria-label, or button text):
screen.getByRole("button", { name: /submit/i });Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥