Testing React components doesn't have to be complicated. React Testing Library (RTL) provides a simple and complete set of utilities for testing React components in a way that encourages good testing practices. Let's master this essential tool.
Philosophy: Test User Behavior, Not Implementation Details
The core philosophy of RTL is testing from the user's perspective. Your tests should interact with your components the way users do—finding elements by their label text, role, or other attributes that users perceive.
What to Test
Test what users see and interact with: rendered output, user interactions like clicks and form submissions, and state changes reflected in the UI. Don't test implementation details like internal state, component methods, or lifecycle methods.
Setup and Installation
Install RTL and its peer dependencies along with jest-dom for custom matchers.
npm install --save-dev @testing-library/react @testing-library/jest-domConfigure your test setup to include jest-dom matchers.
Basic Queries
RTL provides several queries for finding elements in the DOM. The most important ones mirror how users find elements.
Priority Order for Queries
- 1getByRole: For elements with ARIA roles (buttons, links, form elements)
- 2getByLabelText: For form inputs with associated labels
- 3getByPlaceholderText: For form inputs with placeholder text
- 4getByText: For non-interactive elements like headings and paragraphs
- 5getByDisplayValue: For form inputs with current values
- 6getByAltText: For images and graphics with alt text
- 7getByTitle: For elements with title attributes
getByRole Examples
getByRole is often the best query because it's accessible and mirrors how screen reader users find elements.
import { render, screen } from '@testing-library/react';
test('finds button by role', () => {
render(<button>Submit</button>);
const button = screen.getByRole('button', { name: 'Submit' });
expect(button).toBeInTheDocument();
});Testing User Interactions
Use fireEvent or userEvent to simulate user interactions. userEvent is preferred as it more closely simulates real browser behavior.
Click Events
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('calls onClick when button is clicked', async () => {
const handleClick = jest.fn();
render(<button onClick={handleClick}>Click me</button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});Form Interactions
test('submits form with input values', async () => {
const handleSubmit = jest.fn();
render(<form onSubmit={handleSubmit}>
<input name="username" />
<button type="submit">Submit</button>
</form>);
await userEvent.type(
screen.getByRole('textbox', { name: /username/i }),
'john_doe'
);
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(handleSubmit).toHaveBeenCalledWith(
expect.objectContaining({
username: 'john_doe'
})
);
});Async Testing
Modern React applications are full of async operations—fetching data, updating state, and more. RTL provides utilities for testing async behavior.
findBy Queries for Async Elements
findBy queries wait for elements to appear, returning a promise that resolves when the element is found.
test('displays loading message then data', async () => {
render(<UserProfile userId="1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// waitForElement to be removed or findBy for element to appear
expect(await screen.findByText('John Doe')).toBeInTheDocument();
});waitFor for Custom Conditions
waitFor lets you wait for any condition to be true, retrying until it passes or times out.
import { waitFor } from '@testing-library/react';
test('waits for callback to pass', async () => {
render(<MyComponent />);
await waitFor(() => {
expect(screen.getByText('Success')).toBeInTheDocument();
});
});waitForElementToBeRemoved
Use this utility when waiting for loading states or modal overlays to disappear.
test('removes loading spinner after data loads', async () => {
render(<DataLoader />);
const spinner = screen.getByRole('status', { name: /loading/i });
await waitForElementToBeRemoved(spinner);
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});Testing Forms
Forms are central to most applications. Here's how to test them comprehensively.
Controlled Inputs
test('controlled input updates value', async () => {
const handleChange = jest.fn();
render(<input value="" onChange={e => handleChange(e.target.value)} />);
const input = screen.getByRole('textbox');
await userEvent.type(input, 'hello');
expect(handleChange).toHaveBeenLastCalledWith('hello');
});Form Validation
test('shows error message for invalid email', async () => {
render(<ContactForm />);
const emailInput = screen.getByLabelText(/email/i);
const submitButton = screen.getByRole('button', { name: /submit/i });
await userEvent.type(emailInput, 'invalid-email');
await userEvent.click(submitButton);
expect(screen.getByText(/please enter a valid email/i)).toBeInTheDocument();
});Mocking and Stubbing
Sometimes you need to mock dependencies like API calls or external libraries.
Mocking API Calls
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.json({ name: 'John Doe' }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('displays user data', async () => {
render(<UserProfile />);
expect(await screen.findByText('John Doe')).toBeInTheDocument();
});Mocking Child Components
jest.mock('./ChildComponent', () => ({
ChildComponent: () => <div>Mocked Child</div>
}));
test('renders parent with mocked child', () => {
render(<Parent />);
expect(screen.getByText('Mocked Child')).toBeInTheDocument();
});Testing Hooks
Use @testing-library/react-hooks to test custom hooks in isolation.
import { renderHook, act } from '@testing-library/react-hooks';
test('useCounter increments and decrements', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});Testing Context
Test components that consume context by wrapping them in the appropriate provider.
test('displays user name from context', () => {
const mockUser = { name: 'John Doe' };
render(
<UserContext.Provider value={{ user: mockUser }}>
<UserProfile />
</UserContext.Provider>
);
expect(screen.getByText('John Doe')).toBeInTheDocument();
});Testing Routing
For routing, you can use MemoryRouter from react-router-dom to test routes without needing browser navigation.
import { MemoryRouter } from 'react-router-dom';
test('navigates to about page', async () => {
render(
<MemoryRouter>
<App />
</MemoryRouter>
);
await userEvent.click(screen.getByText(/about/i));
expect(screen.getByText(/about us/i)).toBeInTheDocument();
});Accessibility Testing
RTL encourages accessible testing practices by default. Use jest-axe to check for accessibility issues.
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('has no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Best Practices
- 1Test user behavior, not implementation details
- 2Use getByRole as your primary query
- 3Avoid querying by test IDs unless necessary
- 4Test async behavior with findBy queries or waitFor
- 5Keep tests simple and focused
- 6Mock external dependencies appropriately
- 7Use descriptive test names that explain what is being tested
- 8One assertion per test when possible
- 9Arrange-Act-Assert pattern for clarity
Common Pitfalls
Avoid these testing mistakes: testing internal state instead of DOM output, using test IDs for everything instead of accessible queries, testing library implementation details, not cleaning up after tests, over-mocking instead of testing integration points, and testing too many things in a single test.
Conclusion
React Testing Library provides simple utilities that encourage testing from the user's perspective. By focusing on what users see and interact with, you write tests that are more resilient to implementation changes and better catch real bugs.
Master the queries, practice testing user interactions, handle async operations properly, and follow the philosophy of testing behavior over implementation. Your tests will be more valuable, maintainable, and will give you confidence that your application works as intended.
Remember: the more your tests resemble the way your software is used, the more confidence they can give you.