React Testing Library: Writing Better Tests
Testing is the safety net that keeps production bugs from reaching your users. Yet most developers write tests that verify implementation details — which class names are applied, which internal state variable changed, which method was called. These tests break constantly during refactoring and give you false confidence. React Testing Library (RTL) was created specifically to fix this problem. Its guiding philosophy is deceptively simple: test your components the way users actually use them.
In this guide, we'll go deep on RTL — from installation and fundamental queries to advanced async patterns, mocking strategies, and the mental model that separates brittle tests from robust ones. By the end, you'll write tests that survive refactors, catch real bugs, and document how your UI is supposed to work.
RTL is built on a core principle coined by its creator Kent C. Dodds: "The more your tests resemble the way your software is used, the more confidence they can give you." This 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.
import '@testing-library/jest-dom';
// Optional: reset mocks between tests
beforeEach(() => {
jest.clearAllMocks();
});
Queries: Finding Elements the Right Way
Queries are the heart of RTL. They determine how your test locates elements in the DOM — and choosing the right query type directly reflects how accessible your component is. RTL ships with three families of query functions, each with different behavior when the element isn't found.
RTL Query Families — Pick based on whether the element exists immediately or asynchronously
Query Priority: Which to Use When
RTL recommends queries in this priority order — highest accessibility signal first:
| 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') |
Reaching for getByTestId first is a code smell. It means your component may not have the proper ARIA roles, labels, or visible text that real users and screen readers rely on. Only use it when no semantic query is possible.
Your First RTL Test
Let's write a real test for a Login component, step by step. First, the component itself:
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>
);
}
Now the test file, using @testing-library/user-event for realistic user interactions:
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');
});
});
Always prefer userEvent over fireEvent. fireEvent dispatches a single synthetic event. userEvent simulates the full sequence of events a real browser would fire — focus, keydown, input, keyup, change — making your tests far more realistic and reliable.
Async Testing Patterns
Real applications make API calls, use timers, and render data asynchronously. RTL provides several utilities to handle this correctly without flaky tests.
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:
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 actual HTTP requests at the network layer — the most realistic way to test API-driven components:
// 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/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 — 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 work together to create a realistic test environment
Mocking Modules and Context
Mocking React Context
Components that consume Context need their providers wrapped during testing. The cleanest pattern is a custom render function:
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 extends Jest with DOM-aware matchers that produce far clearer failure messages than generic assertions:
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
Even experienced developers fall into these RTL anti-patterns. Here's what to watch out for:
Testing internal state, method calls on instances, or internal class names. When you refactor the internals — even without changing behavior — these tests break. Test what users see and interact with, not how it works under the hood.
// 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 automatically calls cleanup() after each test when used with a test framework like Jest — but if you have custom side effects (timers, subscriptions), clean those up in afterEach to prevent test pollution.
Structuring a Scalable Test Suite
As your test suite grows, organization becomes critical. Here's a battle-tested structure:
Recommended Test Organization
- Co-locate tests: Keep
Component.test.jsxnext toComponent.jsx— easy to find, easy to delete 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): More focused tests give clearer failure messages — but don't split single user flows into multiple tests
// 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();
});
Conclusion: Write Tests That Matter
React Testing Library fundamentally changes how you think about tests. Instead of asking "is the useState counter at 1?", you ask "can the user see 'Count: 1'?". This shift makes tests durable through refactors and genuinely useful for catching regressions.
Key Takeaways
- Query priority matters: Use
getByRolefirst — it also validates accessibility - Use
userEventoverfireEvent: Realistic event simulation catches more bugs - Prefer
findByfor async: It waits automatically without arbitrary delays - Mock at the right layer: MSW for API calls,
jest.mockfor modules, custom renders for context - Don't test implementation details: Tests should survive refactoring
- Custom render utilities pay off: Invest once in a good
test-utils.jsxand all your tests benefit - Co-locate tests: Tests near their component are tests that actually get maintained
"Write tests. Not too many. Mostly integration."
— Guillermo Rauch
React Testing Library makes the "mostly integration" part easy — you're testing real components rendering real DOM, responding to real events. Start with the user flow that matters most, then build from there. A small suite of high-confidence RTL tests beats a thousand shallow snapshot tests every time.
