Testing React Components with Testing Library
Learn to test React components the right way with React Testing Library. Covers rendering, queries, user events, async testing, and best practices.
What you'll learn
- ✓The philosophy behind React Testing Library
- ✓Rendering components and querying the DOM
- ✓Simulating user interactions with userEvent
- ✓Testing async behavior (API calls, loading states)
- ✓Common testing patterns and anti-patterns
Prerequisites
- •React basics (components, props, state, hooks)
- •JavaScript testing fundamentals
- •Basic familiarity with Jest or Vitest
React Testing Library encourages you to test components the way users interact with them — by finding elements by their text, labels, and roles, not by CSS selectors or component internals. This approach produces tests that are resilient to refactoring and actually verify that your UI works.
Setup
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
If using Vitest, add the setup:
// vitest.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.js'],
},
});
// src/test/setup.js
import '@testing-library/jest-dom';
Your first test
// Greeting.jsx
export function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';
test('renders greeting with name', () => {
render(<Greeting name="Alice" />);
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
});
render mounts the component into a virtual DOM. screen provides query methods to find elements. getByText finds an element containing the specified text.
Query priority
React Testing Library provides multiple query methods. Use them in this priority order (most accessible first):
- getByRole — accessible roles (button, heading, textbox)
- getByLabelText — form elements by their label
- getByPlaceholderText — inputs by placeholder
- getByText — elements by their text content
- getByDisplayValue — inputs by their current value
- getByAltText — images by alt text
- getByTitle — elements by title attribute
- getByTestId — last resort, by data-testid attribute
// Good: tests what the user sees
screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email address');
screen.getByText('Welcome back');
// Avoid: tests implementation details
screen.getByTestId('submit-btn'); // Only as last resort
Query variants
Each query has three variants:
| Variant | No match | 1 match | Multiple matches |
|---|---|---|---|
getBy | Throws | Returns element | Throws |
queryBy | Returns null | Returns element | Throws |
findBy | Rejects | Resolves | Rejects |
getAllBy | Throws | Returns array | Returns array |
queryAllBy | Returns [] | Returns array | Returns array |
findAllBy | Rejects | Resolves | Resolves |
Use getBy for elements that should exist, queryBy to assert elements do NOT exist, and findBy for elements that appear asynchronously:
// Element should exist
const button = screen.getByRole('button', { name: 'Submit' });
// Element should NOT exist
expect(screen.queryByText('Error')).not.toBeInTheDocument();
// Element appears after async operation
const message = await screen.findByText('Success!');
User interactions with userEvent
userEvent simulates real user behavior more accurately than fireEvent:
import userEvent from '@testing-library/user-event';
test('form submission', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
// Type into inputs
await user.type(screen.getByLabelText('Email'), 'alice@test.com');
await user.type(screen.getByLabelText('Password'), 'secret123');
// Click submit
await user.click(screen.getByRole('button', { name: 'Log in' }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@test.com',
password: 'secret123',
});
});
Common user interactions
const user = userEvent.setup();
// Click
await user.click(element);
await user.dblClick(element);
// Type
await user.type(input, 'hello');
// Clear and type
await user.clear(input);
await user.type(input, 'new value');
// Select from dropdown
await user.selectOptions(select, 'option-value');
// Check/uncheck
await user.click(checkbox);
// Tab navigation
await user.tab();
// Keyboard shortcuts
await user.keyboard('{Enter}');
await user.keyboard('{Shift>}{A}{/Shift}'); // Shift+A
// Hover
await user.hover(element);
await user.unhover(element);
Testing a complete form
// ContactForm.jsx
import { useState } from 'react';
export function ContactForm({ onSubmit }) {
const [errors, setErrors] = useState({});
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
const newErrors = {};
if (!data.name) newErrors.name = 'Name is required';
if (!data.email) newErrors.email = 'Email is required';
if (data.email && !data.email.includes('@')) newErrors.email = 'Invalid email';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit(data);
};
return (
<form onSubmit={handleSubmit}>
<label>
Name
<input name="name" />
</label>
{errors.name && <span role="alert">{errors.name}</span>}
<label>
Email
<input name="email" type="email" />
</label>
{errors.email && <span role="alert">{errors.email}</span>}
<button type="submit">Send</button>
</form>
);
}
// ContactForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ContactForm } from './ContactForm';
describe('ContactForm', () => {
const user = userEvent.setup();
test('submits with valid data', async () => {
const onSubmit = vi.fn();
render(<ContactForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Name'), 'Alice');
await user.type(screen.getByLabelText('Email'), 'alice@test.com');
await user.click(screen.getByRole('button', { name: 'Send' }));
expect(onSubmit).toHaveBeenCalledWith({
name: 'Alice',
email: 'alice@test.com',
});
});
test('shows error for empty name', async () => {
render(<ContactForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText('Email'), 'alice@test.com');
await user.click(screen.getByRole('button', { name: 'Send' }));
expect(screen.getByRole('alert')).toHaveTextContent('Name is required');
});
test('shows error for invalid email', async () => {
render(<ContactForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText('Name'), 'Alice');
await user.type(screen.getByLabelText('Email'), 'not-an-email');
await user.click(screen.getByRole('button', { name: 'Send' }));
expect(screen.getByRole('alert')).toHaveTextContent('Invalid email');
});
test('does not submit when validation fails', async () => {
const onSubmit = vi.fn();
render(<ContactForm onSubmit={onSubmit} />);
await user.click(screen.getByRole('button', { name: 'Send' }));
expect(onSubmit).not.toHaveBeenCalled();
});
});
Testing async behavior
Mocking API calls
// UserProfile.jsx
import { useState, useEffect } from 'react';
export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => {
if (!res.ok) throw new Error('User not found');
return res.json();
})
.then(setUser)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p role="alert">{error}</p>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// UserProfile.test.jsx
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';
// Mock fetch globally
beforeEach(() => {
global.fetch = vi.fn();
});
test('shows loading state initially', () => {
fetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) });
render(<UserProfile userId="1" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('displays user data after loading', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ name: 'Alice', email: 'alice@test.com' }),
});
render(<UserProfile userId="1" />);
// findByText waits for the element to appear
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('alice@test.com')).toBeInTheDocument();
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
test('shows error message on failure', async () => {
fetch.mockResolvedValueOnce({ ok: false });
render(<UserProfile userId="999" />);
expect(await screen.findByRole('alert')).toHaveTextContent('User not found');
});
Testing with context providers
// Wrap components that need providers
function renderWithProviders(ui, { theme = 'light', ...options } = {}) {
function Wrapper({ children }) {
return (
<ThemeProvider theme={theme}>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Usage in tests
test('renders with theme', () => {
renderWithProviders(<ThemedButton />, { theme: 'dark' });
expect(screen.getByRole('button')).toHaveClass('dark');
});
Common anti-patterns
Do not test implementation details
// Bad: testing state values
expect(component.state.count).toBe(5);
// Good: testing what the user sees
expect(screen.getByText('Count: 5')).toBeInTheDocument();
Do not use container queries
// Bad: querying by CSS selector
const { container } = render(<App />);
const button = container.querySelector('.submit-btn');
// Good: querying by role
const button = screen.getByRole('button', { name: 'Submit' });
Do not use snapshot tests for UI logic
// Bad: snapshot tests are brittle and don't test behavior
expect(container).toMatchSnapshot();
// Good: test specific behavior
expect(screen.getByRole('heading')).toHaveTextContent('Welcome');
Debugging tips
// Print the current DOM
screen.debug();
// Print a specific element
screen.debug(screen.getByRole('form'));
// Log accessible roles in the DOM
import { logRoles } from '@testing-library/react';
const { container } = render(<App />);
logRoles(container);
Summary
React Testing Library pushes you to test from the user’s perspective: find elements by roles, labels, and text, not by class names or test IDs. Use userEvent for realistic interaction simulation. Use findBy queries for async elements. Mock API calls at the fetch level. Create reusable render wrappers for context providers. The guiding principle is: if you cannot find an element using accessibility queries, your component likely has an accessibility problem to fix.
Related articles
- 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.
- Testing End-to-End Testing with Cypress
Write reliable E2E tests with Cypress — selectors, assertions, network stubbing, custom commands, and CI integration.
- Testing Integration Tests vs Unit Tests: Finding the Right Balance
Understand the tradeoffs between unit tests and integration tests. Learn the testing pyramid, trophy, and practical strategies for effective test suites.
- Testing Mocking Strategies for Unit Tests
Learn when and how to mock dependencies in unit tests. Covers stubs, spies, fakes, mock patterns, and how to avoid over-mocking.