React Testing Library: Complete Testing Guide
Learn to test React components with React Testing Library. Covers rendering, user events, async testing, mocking, and accessibility-driven queries.
What you'll learn
- ✓Setting up React Testing Library with Vitest
- ✓Writing tests using accessibility-first queries
- ✓Testing user interactions, forms, and async behavior
- ✓Mocking API calls and modules
Prerequisites
- •React components and hooks
- •Basic JavaScript testing concepts
React Testing Library encourages you to test components the way users interact with them. Instead of testing implementation details like state values or method calls, you test what the user sees and does. This produces tests that are more resilient to refactoring and more meaningful as documentation.
Setup
Install the necessary packages with your preferred test runner. This guide uses Vitest, but Jest works the same way:
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest jsdom
Configure Vitest in vite.config.ts:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
});
Create the setup file at src/test/setup.ts:
import '@testing-library/jest-dom/vitest';
This adds custom matchers like toBeInTheDocument() and toHaveTextContent().
Your First Test
Start with a simple component:
function Greeting({ name }: { name: string }) {
return <h1>Hello, {name}!</h1>;
}
Test it by rendering and querying:
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';
test('displays greeting with name', () => {
render(<Greeting name="Alice" />);
expect(screen.getByRole('heading')).toHaveTextContent('Hello, Alice!');
});
screen.getByRole('heading') finds the element by its accessibility role, not by a CSS class or test ID. This is the core philosophy: query like a user or assistive technology would.
Query Priority
React Testing Library provides several query types. Use them in this priority order:
getByRole- Queries by ARIA role. Works for buttons, headings, textboxes, etc.getByLabelText- Finds form elements by their label. Best for inputs.getByPlaceholderText- Fallback for inputs without labels.getByText- Finds elements by their text content.getByDisplayValue- Finds inputs by their current value.getByAltText- Finds images by alt text.getByTestId- Last resort. Only use when no other query works.
// Preferred: query by role
screen.getByRole('button', { name: 'Submit' });
// Good: query by label
screen.getByLabelText('Email address');
// Acceptable: query by text
screen.getByText('Welcome back');
// Last resort: query by test ID
screen.getByTestId('custom-dropdown');
Testing User Interactions
Use @testing-library/user-event for realistic user interactions. It simulates actual browser events more accurately than fireEvent:
import userEvent from '@testing-library/user-event';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<span>Count: {count}</span>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={() => setCount(c => c - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
test('increments and decrements count', async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 2')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Decrement' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Reset' }));
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
Testing Forms
Forms are where React Testing Library shines. Test the complete user flow:
function LoginForm({ onSubmit }: { onSubmit: (data: LoginData) => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email || !password) {
setError('All fields are required');
return;
}
onSubmit({ email, password });
}
return (
<form onSubmit={handleSubmit}>
<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)}
/>
{error && <p role="alert">{error}</p>}
<button type="submit">Log in</button>
</form>
);
}
describe('LoginForm', () => {
test('submits with valid data', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'securepass');
await user.click(screen.getByRole('button', { name: 'Log in' }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'securepass',
});
});
test('shows error when fields are empty', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: 'Log in' }));
expect(screen.getByRole('alert')).toHaveTextContent('All fields are required');
});
});
Testing Async Behavior
Use findBy queries for elements that appear asynchronously. They automatically wait and retry:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <p>Loading...</p>;
return <h2>{user.name}</h2>;
}
test('loads and displays user data', async () => {
vi.mocked(fetchUser).mockResolvedValue({ name: 'Alice' });
render(<UserProfile userId="1" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
const heading = await screen.findByRole('heading');
expect(heading).toHaveTextContent('Alice');
});
For waiting until elements disappear, use waitForElementToBeRemoved:
import { waitForElementToBeRemoved } from '@testing-library/react';
test('loading spinner disappears after fetch', async () => {
render(<UserProfile userId="1" />);
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
expect(screen.getByRole('heading')).toHaveTextContent('Alice');
});
Mocking API Calls
Use vi.mock to mock modules, or use MSW (Mock Service Worker) for more realistic API mocking:
Simple mocking with vi.mock:
import { fetchUsers } from '../api';
vi.mock('../api', () => ({
fetchUsers: vi.fn(),
}));
beforeEach(() => {
vi.mocked(fetchUsers).mockResolvedValue([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
});
test('displays list of users', async () => {
render(<UserList />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
MSW for API mocking:
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('handles server error', async () => {
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserList />);
expect(await screen.findByText('Failed to load users')).toBeInTheDocument();
});
Testing Components with Context
Wrap components with their required providers using a custom render function:
import { render } from '@testing-library/react';
import { ThemeProvider } from '../context/ThemeContext';
import { AuthProvider } from '../context/AuthContext';
function renderWithProviders(ui: React.ReactElement, options = {}) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}
test('shows user name from auth context', () => {
renderWithProviders(<UserBadge />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
Testing Custom Hooks
Use renderHook from React Testing Library to test hooks in isolation:
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('increments counter', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('resets counter to initial value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(7);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(5);
});
Common Mistakes
Using getBy for elements that might not exist:
// This throws if the element doesn't exist
screen.getByText('Error message'); // throws
// Use queryBy instead - returns null if not found
expect(screen.queryByText('Error message')).not.toBeInTheDocument();
Not waiting for async updates:
// Bad: might fail intermittently
test('loads data', () => {
render(<AsyncComponent />);
expect(screen.getByText('Loaded')).toBeInTheDocument(); // might not be there yet
});
// Good: wait for it
test('loads data', async () => {
render(<AsyncComponent />);
expect(await screen.findByText('Loaded')).toBeInTheDocument();
});
Testing implementation details:
// Bad: testing state directly
expect(component.state.isOpen).toBe(true);
// Good: testing what the user sees
expect(screen.getByRole('dialog')).toBeInTheDocument();
Wrapping Up
React Testing Library pushes you toward testing behavior rather than implementation. Use role-based queries to find elements the way users and screen readers do. Use userEvent for realistic interactions. Use findBy queries for async content. Mock at the API boundary with MSW for realistic integration tests. When your tests focus on what the user experiences, they stay valid even when you refactor the internal implementation of your components.
Related articles
- React React Testing Library: Advanced Patterns for Complex UIs
Advanced testing patterns with React Testing Library including custom render wrappers, async flows, portals, context-heavy components, and integration testing.
- React React Testing Library Best Practices
Practical guidance for writing maintainable, user-focused tests with React Testing Library, including queries, async patterns, and smart mocking.
- Testing Jest vs Vitest: JavaScript Test Runners Compared
Compare Jest and Vitest for JavaScript testing. Understand speed, configuration, compatibility, and migration tradeoffs to pick the right test runner.
- Testing Snapshot Testing: Patterns and Anti-Patterns
Learn when snapshot tests help, when they hurt, and how to use them effectively in React, API, and configuration testing.