Difficulty: Intermediate
How do you test React components? Explain testing strategies and common patterns.
Testing React components is one of those skills that separates developers who ship reliable code from those who fight constant regressions. Let me walk you through how to think about testing in React, the tools you will use, and the patterns that matter most.
The foundational philosophy comes from React Testing Library, created by Kent C. Dodds. The guiding principle is: 'The more your tests resemble the way your software is used, the more confidence they can give you.' This means testing your components the way a real user interacts with them. A user does not know about your component's state variables, internal methods, or CSS class names. They see text on the screen, they click buttons, they type into inputs, and they read the results. Your tests should do the same.
This philosophy is a deliberate reaction to the older Enzyme library, which encouraged testing implementation details -- checking component state values, calling instance methods, and shallow rendering to test components in isolation. Those tests were brittle because they broke whenever you refactored internals, even if the user-facing behavior was unchanged.
The testing setup for most React projects is Jest (or Vitest for Vite-based projects) as the test runner and React Testing Library (RTL) as the rendering library. Jest provides the test framework (describe, it, expect), mocking capabilities (jest.fn, jest.mock), and assertion matchers. RTL provides render (to mount components), screen (to query the rendered output), and fireEvent/userEvent (to simulate interactions). The @testing-library/jest-dom package adds useful DOM-specific matchers like toBeInTheDocument(), toBeVisible(), and toHaveTextContent().
The testing flow is: render the component, find elements, interact with them, and assert on the results. Finding elements is where RTL's philosophy really shows. RTL provides queries in a specific priority order, and this order matters. First choice is getByRole -- it finds elements by their ARIA role (button, textbox, heading, checkbox), which mirrors how screen readers navigate the page. Using it naturally encourages accessible component design. Second is getByLabelText for form fields. Third is getByText for non-interactive content. Last resort is getByTestId with a data-testid attribute.
For user interactions, strongly prefer userEvent from @testing-library/user-event over fireEvent. The difference is significant. fireEvent dispatches a single DOM event directly. userEvent simulates the full interaction as a real browser would -- typing triggers focus, keyDown, keyPress, input, keyUp events for each character. This catches bugs that fireEvent misses.
The query variants serve different purposes. getBy throws if the element is not found -- use it for elements that should definitely be there. queryBy returns null if not found -- use it for asserting something is NOT rendered. findBy returns a Promise that resolves when the element appears -- use it for async content.
Testing async operations follows a clear pattern: mock the API module, set up mock return values, render the component, assert on the loading state, then use waitFor or findBy to assert on the loaded state. Always test both the success and error paths.
For testing custom hooks, RTL provides renderHook. You pass a callback that calls your hook and get back a result object. State updates need to be wrapped in act(). For simpler hooks, testing them through the components that use them is often better.
When components depend on providers (Router, query client, theme, auth), create a custom render function that wraps everything with the necessary providers. Export this from a test utilities file and use it everywhere instead of RTL's bare render.
The testing pyramid for React apps should be: many integration tests at the base (rendering parent components with real children, testing full interaction flows), some unit tests in the middle (for complex pure logic like reducers and utility functions), and a few end-to-end tests at the top (for critical flows like signup, login, checkout using Cypress or Playwright).
Common mistakes to avoid: do not test implementation details like state values or internal method calls. Do not test that specific CSS classes are applied. Do not mock child components unless they have complex side effects. And do not aim for 100% code coverage -- aim for high confidence that critical user paths work correctly.
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
it('renders email and password fields', () => {
render(<LoginForm onSubmit={jest.fn()} />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /log in/i })).toBeInTheDocument();
});
it('shows validation errors on empty submit', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
it('calls onSubmit with form data', async () => {
const handleSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
Tests use accessible queries (getByLabelText, getByRole) to find elements the way screen readers and users do. userEvent simulates realistic user interactions.
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UserProfile from './UserProfile';
// Mock the API module
jest.mock('./api', () => ({
fetchUser: jest.fn(),
updateUser: jest.fn(),
}));
import { fetchUser, updateUser } from './api';
describe('UserProfile', () => {
beforeEach(() => {
fetchUser.mockResolvedValue({
id: 1, name: 'John Doe', email: 'john@example.com'
});
});
it('shows loading state then user data', async () => {
render(<UserProfile userId={1} />);
// Initially shows loading
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// After fetch resolves, shows user data
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
it('handles API errors gracefully', async () => {
fetchUser.mockRejectedValue(new Error('Network error'));
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
// Retry button
const user = userEvent.setup();
fetchUser.mockResolvedValue({ id: 1, name: 'John Doe' });
await user.click(screen.getByRole('button', { name: /retry/i }));
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
});
Mock API calls with jest.mock to test loading, success, and error states. waitFor handles async updates. Testing error handling and retry flows is just as important as the happy path.
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with provided value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments and decrements', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0);
});
it('respects min and max bounds', () => {
const { result } = renderHook(() =>
useCounter(5, { min: 0, max: 10 })
);
// Try to go below min
act(() => {
for (let i = 0; i < 10; i++) result.current.decrement();
});
expect(result.current.count).toBe(0);
// Try to go above max
act(() => {
for (let i = 0; i < 20; i++) result.current.increment();
});
expect(result.current.count).toBe(10);
});
it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => result.current.increment());
act(() => result.current.reset());
expect(result.current.count).toBe(5);
});
});
renderHook from @testing-library/react tests hooks in isolation. Act wraps state updates. This is perfect for testing custom hooks without building a wrapper component.
Testing Library, Jest, Unit Tests, Integration Tests, Mocking