React Testing Library Fundamentals
Test your components the way users interact with them -- by querying accessible elements, not implementation details.
Recipe
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.
Working Example
// 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:
- Querying by accessible labels and roles
userEvent.setup()for realistic typing and clicking- Testing success, error, and loading states
waitForandfindByfor async assertions
Deep Dive
How It Works
render()mounts your component into ajsdomdocument body and returns utilities for cleanup (automatic with modern Testing Library)screenis a convenience object that holds all queries bound todocument.body-- no need to destructure fromrender()- Queries come in three variants:
getBy(throws if not found),queryBy(returns null if not found),findBy(waits and retries) userEventsimulates full user interaction sequences including focus, keydown, keypress, keyup, input, and change events --fireEventonly dispatches a single eventwaitForretries its callback until it passes or times out (default 1000ms) -- use it for assertions that depend on async state updates
Query Priority
| 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 |
Query Variants
| 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 |
TypeScript Notes
// 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>
),
});
}Gotchas
-
Using
fireEventinstead ofuserEvent--fireEvent.changedoes not simulate real typing (no focus, no keystrokes). Fix: Always useuserEvent.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 wrapsrender,userEvent, andwaitForinact()for you. Fix: Remove manualact()calls unless you are triggering state updates outside of Testing Library utilities. -
Not awaiting
userEventcalls --userEventmethods are async in v14+. Fix: Alwaysawaiteveryuser.type(),user.click(), etc. -
getByfor elements that may not exist --getBythrows if the element is missing, which crashes the test. Fix: UsequeryBywhen asserting something is NOT in the document:expect(screen.queryByText("Error")).not.toBeInTheDocument().
Alternatives
| 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 |
FAQs
Why should I use userEvent instead of fireEvent?
fireEvent.changedispatches a single event and skips focus, keydown, keypress, and keyup.userEventsimulates the full interaction sequence a real user would trigger.- Always use
userEvent.setup()andawaitits methods.
What is the query priority order in Testing Library?
getByRole-- accessible ARIA role (best)getByLabelText-- form field labelsgetByPlaceholderText-- input placeholdersgetByText-- visible text contentgetByTestId-- last resort
What is the difference between getBy, queryBy, and findBy?
getBythrows if the element is not found (synchronous).queryByreturnsnullif not found -- use for asserting absence.findBywaits and retries until found or timeout (async).
How do I assert that an element is NOT in the document?
expect(screen.queryByText("Error")).not.toBeInTheDocument();Use queryBy (not getBy) because getBy throws when the element is missing.
Gotcha: Why do I get warnings about act() when using Testing Library?
Modern Testing Library wraps render, userEvent, and waitFor in act() automatically. Remove manual act() calls unless you trigger state updates outside Testing Library utilities.
How do I wait for async state changes after an interaction?
Use waitFor or findBy:
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalled();
});
// or
const message = await screen.findByText(/success/i);Gotcha: What happens if I forget to await a userEvent call?
In userEvent v14+, all methods are async. Without await, the test may pass before the interaction completes, leading to false positives or flaky failures.
How do I create a custom render function with providers?
function renderWithProviders(ui: React.ReactElement) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider>{children}</ThemeProvider>
),
});
}What does screen give me that destructuring from render() does not?
screen is bound to document.body and provides all queries without needing to destructure. It keeps test code cleaner and more consistent.
How do I type the render result in TypeScript?
const { container, rerender, unmount } = render(<MyComponent />);The return type is automatically inferred. container is HTMLElement, rerender accepts ReactElement, and unmount returns void.
What is the default timeout for findBy and waitFor?
Both default to 1000ms. You can customize the timeout:
await screen.findByText(/data/i, {}, { timeout: 3000 });When should I use getByRole with a name option?
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 });Related
- Testing React Components -- patterns for different component types
- Testing Forms -- form-specific testing techniques
- Testing Async Behavior -- handling loading states and timers
- Mocking in Tests -- mocking dependencies