React Testing Library: Writing Better Tests
You rename a CSS class, or move some state from useState into a reducer, and twenty tests go red. Nothing is broken for the user. The button still works, the form still submits. The tests were checking which class was applied and which internal value changed, so any refactor breaks them. Meanwhile a real bug, like a submit button that never re-enables after an error, slips through because nobody tested what the user actually sees.
React Testing Library (RTL) is built to stop that from happening. The idea is to test your components the way people use them: find things by their label or role, click them, and check what shows up on screen. This post covers setup, the query types and which one to pick, async patterns, mocking with MSW and jest.mock, and the mistakes that make RTL tests flaky or brittle.
Kent C. Dodds, who created the library, put it this way: "The more your tests resemble the way your software is used, the more confidence they can give you." In practice that means querying by visible text and ARIA roles, not CSS selectors or component internals.
Setup and Installation
If you're using Create React App or Vite with the React template, RTL is included by default alongside Jest. For manual setup, here's what you need:
Install dependencies
Add RTL, jest-environment-jsdom, and @testing-library/user-event for realistic interaction simulation.
# npm
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest jest-environment-jsdom
# yarn
yarn add --dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest jest-environment-jsdom
# pnpm
pnpm add -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jest jest-environment-jsdom
Configure Jest
Set up jest.config.js with jsdom environment and the RTL custom matchers setup file.
/** @type {import('jest').Config} */
const config = {
testEnvironment: 'jest-environment-jsdom',
setupFilesAfterFramework: ['@testing-library/jest-dom'],
// For Vite / ESM projects, add a transform:
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
moduleNameMapper: {
// Handle CSS/asset imports in tests
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '/__mocks__/fileMock.js',
},
};
module.exports = config;
Create a global setup file
Import @testing-library/jest-dom once so custom matchers like toBeInTheDocument() are available in every test file. Watch the config key name: Jest's option is setupFilesAfterEnv. If it's misspelled, Jest only prints an "Unknown option" validation warning, and then every test fails with TypeError: expect(...).toBeInTheDocument is not a function.
import '@testing-library/jest-dom';
// Optional: reset mocks between tests
beforeEach(() => {
jest.clearAllMocks();
});
Queries: Finding Elements the Right Way
Most of your time in RTL goes into queries: how the test finds an element in the DOM. The query you're able to use also tells you something about accessibility. If you can't find a button by its role and name, a screen reader user probably can't either. There are three families, and they differ in what happens when the element isn't there.
RTL query families: pick based on whether the element should be there now, later, or not at all
Query Priority: Which to Use When
The RTL docs suggest this order, starting with the queries that match how users and assistive tech find things:
| Priority | Query | What It Finds | Example |
|---|---|---|---|
| 1 (Best) | getByRole |
ARIA role + accessible name | getByRole('button', {name: /submit/i}) |
| 2 | getByLabelText |
Form inputs by label | getByLabelText(/email/i) |
| 3 | getByPlaceholderText |
Input placeholder text | getByPlaceholderText('Search...') |
| 4 | getByText |
Visible text content | getByText(/welcome back/i) |
| 5 | getByDisplayValue |
Current form field value | getByDisplayValue('John') |
| 6 | getByAltText |
Image alt text | getByAltText(/user avatar/i) |
| 7 | getByTitle |
title attribute | getByTitle('Close modal') |
| 8 (Last resort) | getByTestId |
data-testid attribute | getByTestId('submit-btn') |
If getByTestId is your first reach, the component is probably missing the roles, labels, or visible text that screen readers rely on. Fix the markup first. Keep test IDs for cases where there's genuinely nothing semantic to query, like a canvas or a purely decorative wrapper.
Your First RTL Test
Here's a small login form. Nothing fancy, but it has the three things you'll test constantly: labeled inputs, a submit handler, and an error that shows up after an async call.
import { useState } from 'react';
export default function LoginForm({ onLogin }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
async function handleSubmit(e) {
e.preventDefault();
setError('');
try {
await onLogin(email, password);
} catch (err) {
setError(err.message);
}
}
return (
<form onSubmit={handleSubmit}>
<h1>Sign In</h1>
{error && <p role="alert">{error}</p>}
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<button type="submit">Sign In</button>
</form>
);
}
And the tests, using @testing-library/user-event to type and click:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
// userEvent.setup() creates a user session that simulates real browser events
const user = userEvent.setup();
it('renders the form fields', () => {
render(<LoginForm onLogin={jest.fn()} />);
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
it('calls onLogin with email and password when form is submitted', async () => {
const mockLogin = jest.fn().mockResolvedValue(undefined);
render(<LoginForm onLogin={mockLogin} />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'supersecret');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'supersecret');
expect(mockLogin).toHaveBeenCalledTimes(1);
});
it('displays an error message when login fails', async () => {
const mockLogin = jest.fn().mockRejectedValue(new Error('Invalid credentials'));
render(<LoginForm onLogin={mockLogin} />);
await user.type(screen.getByLabelText(/email/i), 'wrong@example.com');
await user.type(screen.getByLabelText(/password/i), 'wrongpass');
await user.click(screen.getByRole('button', { name: /sign in/i }));
// The error appears asynchronously after the rejected promise
expect(await screen.findByRole('alert')).toHaveTextContent('Invalid credentials');
});
});
Default to userEvent. fireEvent dispatches one event. userEvent fires the sequence a browser would (focus, keydown, input, keyup, and so on), so it catches bugs like a handler wired to onKeyDown that fireEvent.change would skip. It also won't click a disabled button, which fireEvent happily does. Note that in v14 every userEvent call returns a promise, so forgetting await is a common source of "not wrapped in act(...)" warnings.
Async Testing Patterns
Most flaky RTL tests I've seen come from async code: an assertion runs before the fetch resolves, or before a state update lands. RTL gives you a few tools for waiting properly instead of sprinkling setTimeout around.
findBy* queries return a promise and poll the DOM until the element appears (up to 1000ms by default). Use them when an element renders after an async operation:
it('shows user profile after login', async () => {
render(<Dashboard />);
// This button triggers an API call that fetches user data
await userEvent.click(screen.getByRole('button', { name: /load profile/i }));
// findByText waits up to 1000ms for the element to appear
const heading = await screen.findByRole('heading', { name: /john doe/i });
expect(heading).toBeInTheDocument();
// You can also use findAllBy for multiple elements
const posts = await screen.findAllByRole('article');
expect(posts).toHaveLength(3);
});
waitFor retries a callback until it stops throwing or times out. Use it when the assertion itself is what becomes true asynchronously. Keep one assertion inside the callback, and don't put side effects like clicks in there, because they'll run on every retry:
import { render, screen, waitFor } from '@testing-library/react';
it('removes loading spinner once data loads', async () => {
render(<UserList />);
// Spinner is visible initially
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// Wait until the spinner disappears
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
// Now user list should be visible
expect(screen.getAllByRole('listitem')).toHaveLength(5);
});
// waitFor with custom timeout and interval
await waitFor(
() => expect(screen.getByText('Done!')).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
);
Mock Service Worker (MSW) intercepts HTTP requests at the network layer. Your component's real fetch code runs, so you test more of the actual path than you would by mocking the API module:
// src/mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
]);
}),
http.post('/api/login', async ({ request }) => {
const body = await request.json();
if (body.password === 'wrong') {
return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
return HttpResponse.json({ token: 'abc123' });
}),
];
// src/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// jest.setup.js: start, reset and stop the server around tests
import { server } from './src/mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Testing Common Patterns
Testing Forms with Validation
it('shows validation errors for empty fields', async () => {
const user = userEvent.setup();
render(<RegistrationForm onSubmit={jest.fn()} />);
// Submit without filling anything
await user.click(screen.getByRole('button', { name: /register/i }));
// Both error messages should appear
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
expect(screen.getByText(/valid email is required/i)).toBeInTheDocument();
});
it('clears errors when user starts typing', async () => {
const user = userEvent.setup();
render(<RegistrationForm onSubmit={jest.fn()} />);
// Trigger errors
await user.click(screen.getByRole('button', { name: /register/i }));
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
// Start typing, and the error should clear
await user.type(screen.getByLabelText(/name/i), 'John');
expect(screen.queryByText(/name is required/i)).not.toBeInTheDocument();
});
Testing Lists and Conditional Rendering
describe('TodoList', () => {
it('shows empty state when there are no todos', () => {
render(<TodoList todos={[]} />);
expect(screen.getByText(/no tasks yet/i)).toBeInTheDocument();
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
it('renders all todos', () => {
const todos = [
{ id: 1, text: 'Buy groceries', done: false },
{ id: 2, text: 'Walk the dog', done: true },
];
render(<TodoList todos={todos} />);
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(2);
expect(screen.getByText('Buy groceries')).toBeInTheDocument();
expect(screen.getByText('Walk the dog')).toBeInTheDocument();
});
it('toggles a todo when clicked', async () => {
const user = userEvent.setup();
const onToggle = jest.fn();
const todos = [{ id: 1, text: 'Read RTL docs', done: false }];
render(<TodoList todos={todos} onToggle={onToggle} />);
// Find the checkbox associated with the todo text
const checkbox = screen.getByRole('checkbox', { name: /read rtl docs/i });
await user.click(checkbox);
expect(onToggle).toHaveBeenCalledWith(1);
});
});
How RTL, jsdom, and MSW fit together in a test run
Mocking Modules and Context
Mocking React Context
Render a component that calls useContext without its provider and you'll either get the default context value or a crash. Rather than wrapping providers by hand in every test, I'd write a custom render function once:
import { render } from '@testing-library/react';
import { AuthContext } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
// Default auth state for most tests
const defaultAuthValue = {
user: { id: 1, name: 'Test User', email: 'test@example.com' },
isAuthenticated: true,
logout: jest.fn(),
};
// Custom render that wraps all required providers
function renderWithProviders(
ui,
{ authValue = defaultAuthValue, ...options } = {}
) {
function Wrapper({ children }) {
return (
<AuthContext.Provider value={authValue}>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthContext.Provider>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Re-export everything from RTL so tests only import from this file
export * from '@testing-library/react';
export { renderWithProviders as render };
// Import from our custom utils, not from RTL directly
import { render, screen } from '../test-utils';
import UserProfile from './UserProfile';
it('shows the logged-in user name', () => {
render(<UserProfile />);
expect(screen.getByText('Test User')).toBeInTheDocument();
});
it('shows guest message when not authenticated', () => {
render(<UserProfile />, {
authValue: { user: null, isAuthenticated: false, logout: jest.fn() },
});
expect(screen.getByText(/please log in/i)).toBeInTheDocument();
});
Mocking Modules with jest.mock
// Mock an entire module
jest.mock('../api/users', () => ({
fetchUsers: jest.fn(),
deleteUser: jest.fn(),
}));
import { fetchUsers } from '../api/users';
import UserList from './UserList';
it('displays users returned by the API', async () => {
fetchUsers.mockResolvedValue([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
render(<UserList />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
it('shows an error state when API fails', async () => {
fetchUsers.mockRejectedValue(new Error('Network error'));
render(<UserList />);
expect(await screen.findByRole('alert')).toHaveTextContent('Network error');
});
Custom Matchers and Assertions
@testing-library/jest-dom adds DOM-aware matchers to Jest. The main win is the failure output: toBeDisabled() tells you the element and why it failed, where expect(button.disabled).toBe(true) just says "expected true, received false".
jest-dom Custom Matchers Reference
| Matcher | What It Checks |
|---|---|
toBeInTheDocument() |
Element is present in the DOM |
toBeVisible() |
Element is visible to the user (not hidden/opacity:0) |
toBeEnabled() / toBeDisabled() |
Form element enabled/disabled state |
toBeChecked() |
Checkbox or radio is checked |
toHaveValue(value) |
Input/select current value |
toHaveTextContent(text) |
Element's text content matches |
toHaveAttribute(attr, value) |
Element has a specific attribute/value |
toHaveClass(className) |
Element has a CSS class (use sparingly) |
toHaveFocus() |
Element is the currently focused element |
toHaveStyle(css) |
Element has specific inline styles |
toHaveAccessibleName(name) |
Element's accessible name matches |
toHaveErrorMessage(msg) |
Element has an aria-errormessage |
Common Mistakes and How to Fix Them
These show up in code reviews all the time, including from people who've used RTL for years.
Asserting on internal state, instance methods, or internal class names. Refactor the internals without changing behavior and these tests still break. Test what the user sees and does instead.
// BAD: Tests internal state (breaks on refactor)
const { container } = render(<Counter />);
expect(container.firstChild).toHaveClass('counter--active');
expect(wrapper.state('count')).toBe(0);
// GOOD: Tests what the user sees
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
RTL already wraps renders and user events in act(). Manually wrapping everything in act() adds noise and is usually a sign you should use waitFor or findBy instead.
// BAD: Manual act() wrapping
import { act } from 'react-dom/test-utils';
act(() => {
fireEvent.click(button);
});
act(() => {
// wait for state update...
});
// GOOD: Let RTL handle act() through userEvent and findBy
await userEvent.click(button);
const result = await screen.findByText('Updated!');
RTL calls cleanup() after each test automatically when you run it under Jest or Vitest with globals enabled. It only unmounts what render mounted, though. Fake timers, global subscriptions, and anything you stuck on window are yours to reset in afterEach, or one test will leak into the next.
Structuring a Scalable Test Suite
Once you have a few hundred tests, finding and maintaining them starts to matter as much as writing them. This is the layout I'd start with:
Recommended Test Organization
- Co-locate tests: Keep
Component.test.jsxnext toComponent.jsx. Easy to find, and they get deleted together - One describe block per component: Group related tests, use nested
describefor sub-behaviors - Name tests as user behaviors: "shows error when email is invalid" not "email validation"
- Share test utilities: Put shared render helpers, mocks, and fixture data in
src/test-utils/ - Use factories for test data: Functions that build fixture objects reduce copy-paste and keep tests readable
- One assertion per test (loosely): Focused tests give clearer failure messages, but don't chop a single user flow into five tests that each re-render and re-type everything
// src/test-utils/factories.js
export function buildUser(overrides = {}) {
return {
id: Math.random(),
name: 'Test User',
email: 'test@example.com',
role: 'member',
createdAt: '2026-01-01',
...overrides,
};
}
export function buildPost(overrides = {}) {
return {
id: Math.random(),
title: 'Test Post Title',
body: 'Some test body content.',
author: buildUser(),
tags: [],
publishedAt: '2026-01-15',
...overrides,
};
}
// In tests:
it('shows admin badge for admin users', () => {
const admin = buildUser({ role: 'admin' });
render(<UserCard user={admin} />);
expect(screen.getByText(/admin/i)).toBeInTheDocument();
});
When RTL Is the Right Tool (and When It Isn't)
RTL is my default for component and small integration tests: a form, a modal flow, a list that loads data and lets you filter it. Instead of asking "is the counter state at 1?", you ask "does the user see 'Count: 1'?", and that question still has the same answer after a refactor.
Where I'd reach for something else
- Layout, scrolling, and anything visual: jsdom doesn't do layout.
getBoundingClientRect()returns zeros, andtoBeVisible()can't know an element is off screen or covered by another one. Use Playwright or Cypress for that. - Full flows across pages: Login, checkout, and redirects through a real backend belong in end-to-end tests. Faking the router, auth, and API all at once in RTL gets painful and proves less.
- Pure logic: A date formatter or a reducer doesn't need a DOM. Test the function directly; it's faster and the failures are easier to read.
- Browser APIs jsdom lacks:
IntersectionObserver,ResizeObserver, andmatchMediaaren't implemented. You'll have to stub them, and at that point you're testing your stub. - Huge snapshot tests: RTL works with snapshots, but a 400-line snapshot that everyone updates with
-uwithout reading catches nothing.
"Write tests. Not too many. Mostly integration."
Guillermo Rauch
RTL covers the "mostly integration" part well, since you're rendering real components into a DOM and firing real event sequences. If you're adding it to an existing project, don't try to convert the whole suite. Pick the flow that breaks most often, write two or three tests for it with getByRole and userEvent, and see how they hold up through the next refactor.