Understand the philosophy and tools behind testing React components, including React Testing Library, user-centric queries, and best practices for writing maintainable tests.
Testing is your safety net. Without tests, every change you make to your codebase is a gamble — you might fix one thing and break three others without knowing. Tests give you confidence that your components behave correctly and continue to work as you refactor, add features, or upgrade dependencies.
There are three main levels of testing:
1. Unit tests — Test individual functions or components in isolation. Fast to run, easy to write, but can miss integration issues.
// Testing a utility function
expect(formatPrice(1999)).toBe('$19.99');2. Integration tests — Test how multiple components work together. These are the sweet spot for React — testing a form component that includes input fields, validation, and submission.
// Testing a login form
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.click(screen.getByRole('button', { name: 'Log in' }));
expect(screen.getByText('Welcome!')).toBeInTheDocument();3. End-to-end (E2E) tests — Test the entire application from the user's perspective using tools like Playwright or Cypress. Slow and expensive, but catch real-world issues.
The testing trophy (popularized by Kent C. Dodds) suggests writing mostly integration tests, some unit tests, fewer E2E tests, and minimal static analysis. This gives the best ratio of confidence to effort.
/\ E2E (few)
/ \ Integration (many)
/ \ Unit (some)
/------\ Static (TypeScript, ESLint)React Testing Library (RTL) is the standard tool for testing React components. It is built on a simple principle:
"The more your tests resemble the way your software is used, the more confidence they can give you."
This means testing behavior, not implementation. You should not test whether a state variable was set to true or whether a specific function was called. Instead, test what the user sees and does:
Bad test (implementation detail):
// Testing internal state — fragile, breaks during refactoring
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);Good test (behavior):
// Testing what the user sees — resilient to refactoring
render(<Counter />);
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();Both tests verify the counter works, but the behavior test will survive refactoring (renaming the hook, changing state management) while the implementation test will break.
RTL provides a render function that mounts your component into a test DOM, and a screen object to query what was rendered. It intentionally does not expose component internals like state or props.
React Testing Library provides several query methods, prioritized by how accessible they are:
Priority 1: Accessible to everyone
getByRole('button') — Queries by ARIA role. Best choice because it matches how screen readers and users perceive elements.getByLabelText('Email') — Queries form elements by their associated label. Great for form fields.getByPlaceholderText('Search...') — Queries by placeholder. Use only if there is no label.getByText('Submit') — Queries by visible text content.getByDisplayValue('john@example.com') — Queries by the current value of an input.Priority 2: Semantic queries
getByAltText('User avatar') — Queries images by alt text.getByTitle('Close') — Queries by the title attribute.Priority 3: Test IDs (last resort)
getByTestId('submit-button') — Queries by data-testid attribute. Use when no other query works.// Component
function UserCard({ name, role }) {
return (
<div data-testid="user-card">
<h3>{name}</h3>
<p>{role}</p>
</div>
);
}
// Test
render(<UserCard name="Jane" role="Developer" />);
expect(screen.getByText('Jane')).toBeInTheDocument();
expect(screen.getByText('Developer')).toBeInTheDocument();
expect(screen.getByTestId('user-card')).toBeInTheDocument();Each getBy query throws an error if the element is not found. Use queryBy when you expect the element not to exist (returns null instead of throwing). Use findBy for elements that appear asynchronously (returns a Promise).
// Assert something is NOT present
expect(screen.queryByText('Error')).not.toBeInTheDocument();
// Wait for async content
const message = await screen.findByText('Data loaded');React Testing Library encourages testing user interactions with the @testing-library/user-event library, which simulates real browser events more accurately than fireEvent:
import userEvent from '@testing-library/user-event';
test('form submission', async () => {
const user = userEvent.setup();
render(<LoginForm />);
// Type into inputs
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Password'), 'secret123');
// Click submit
await user.click(screen.getByRole('button', { name: 'Log in' }));
// Assert the result
expect(screen.getByText('Welcome, test@example.com!')).toBeInTheDocument();
});user.type() simulates each keystroke individually, triggering keydown, keypress, input, and keyup events — just like a real user typing. fireEvent.change() only triggers the change event, which can miss bugs.
Common assertions from @testing-library/jest-dom:
// Element presence
expect(element).toBeInTheDocument();
expect(element).not.toBeInTheDocument();
// Visibility
expect(element).toBeVisible();
expect(element).toHaveStyle('display: none');
// Content
expect(element).toHaveTextContent('Hello');
expect(input).toHaveValue('test@example.com');
// Attributes
expect(button).toBeDisabled();
expect(input).toBeRequired();
expect(element).toHaveAttribute('href', '/about');
expect(element).toHaveClass('active');The data-testid attribute is your escape hatch for querying elements that have no accessible role, label, or text. It is not visible to users and does not affect behavior:
<div data-testid="user-card">
<h3 data-testid="user-name">Jane</h3>
</div>Use data-testid sparingly — prefer accessible queries first.
Mocking replaces real dependencies with controlled substitutes during tests. This is essential for isolating the component under test:
Mocking API calls:
// Mock the fetch function
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ name: 'Jane', role: 'Developer' }),
})
);
test('displays user data', async () => {
render(<UserProfile userId="1" />);
expect(await screen.findByText('Jane')).toBeInTheDocument();
});Mocking child components:
// Replace a heavy child component with a simple mock
jest.mock('./HeavyChart', () => {
return function MockChart() {
return <div data-testid="chart">Chart placeholder</div>;
};
});Mocking context providers:
function renderWithTheme(ui, theme = 'light') {
return render(
<ThemeContext.Provider value={{ theme }}>
{ui}
</ThemeContext.Provider>
);
}
test('renders dark theme', () => {
renderWithTheme(<Header />, 'dark');
expect(screen.getByRole('banner')).toHaveClass('dark');
});Snapshot testing captures the rendered output of a component and compares it to a stored "snapshot" file. If the output changes, the test fails:
test('matches snapshot', () => {
const { container } = render(<UserCard name="Jane" role="Dev" />);
expect(container).toMatchSnapshot();
});Snapshots are controversial. They are easy to create but can lead to mindless "update snapshot" acceptance when they break. Use them for stable, presentational components and prefer explicit assertions for interactive components.
Best practice: Write tests that would fail if the behavior changed but pass if only the implementation changed. This is the key insight behind React Testing Library.
<div id="root"></div>
<script src="https://unpkg.com/react@19/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@19/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
// A well-structured, testable component
// Notice: semantic HTML, data-testid for complex queries, props for data
function UserCard({ name, role }) {
return (
<div data-testid="user-card">
<h3 data-testid="user-name">{name}</h3>
<p data-testid="user-role">{role}</p>
</div>
);
}
function UserList({ users }) {
if (users.length === 0) {
return <p data-testid="empty-message">No users found.</p>;
}
return (
<div data-testid="user-list">
{users.map((user, i) => (
<UserCard key={i} name={user.name} role={user.role} />
))}
</div>
);
}
function App() {
const users = [
{ name: "Jane", role: "Developer" },
{ name: "Alex", role: "Designer" },
];
return (
<div>
<h1>Team Members</h1>
<UserList users={users} />
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>Which query should you prefer first when testing a button with React Testing Library?