React Testing Best Practices: Writing Tests That Matter
Learn how to write effective React tests that catch bugs, improve code quality, and give you confidence to ship
React Testing Best Practices: Writing Tests That Matter
Testing React components can feel overwhelming. What should you test? How much is enough? Let's cut through the noise and focus on tests that actually matterβtests that catch real bugs and give you confidence to ship.
The Testing Philosophy
"The more your tests resemble the way your software is used, the more confidence they can give you." - Kent C. Dodds
Focus on testing behavior, not implementation details.
// β Bad: Testing implementation details
test('updates state when button clicked', () => {
const { result } = renderHook(() => useState(0));
const [count, setCount] = result.current;
setCount(1);
expect(result.current[0]).toBe(1);
});
// β
Good: Testing user behavior
test('increments counter when button clicked', () => {
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
Essential Testing Tools
1. React Testing Library (Recommended)
Tests components the way users interact with them.
npm install --save-dev @testing-library/react @testing-library/jest-dom
2. Vitest (Modern Alternative to Jest)
Fast, ESM-first test runner.
npm install --save-dev vitest
3. Testing 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',
globals: true,
},
});
// src/test/setup.js
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
Testing Patterns
Pattern 1: Testing User Interactions
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
test('submits form with email and password', async () => {
const handleSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
// Find elements by role (accessible!)
const emailInput = screen.getByRole('textbox', { name: /email/i });
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /log in/i });
// Simulate user typing (more realistic than fireEvent)
await user.type(emailInput, 'user@example.com');
await user.type(passwordInput, 'password123');
await user.click(submitButton);
// Assert form was submitted
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
});
});
test('shows error for invalid email', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
const emailInput = screen.getByRole('textbox', { name: /email/i });
const submitButton = screen.getByRole('button', { name: /log in/i });
await user.type(emailInput, 'invalid-email');
await user.click(submitButton);
expect(screen.getByText(/please enter a valid email/i)).toBeInTheDocument();
});
});
Pattern 2: Testing Async Operations
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UserProfile from './UserProfile';
describe('UserProfile', () => {
test('loads and displays user data', async () => {
// Mock API call
const mockUser = { name: 'John Doe', email: 'john@example.com' };
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve(mockUser),
})
);
render(<UserProfile userId="123" />);
// Initially shows loading
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
test('handles error state', async () => {
// Mock failed API call
global.fetch = vi.fn(() => Promise.reject(new Error('Network error')));
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText(/error loading profile/i)).toBeInTheDocument();
});
});
});
Pattern 3: Testing Components with Context
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import ThemedButton from './ThemedButton';
// Helper to render with context
function renderWithTheme(ui, { theme = 'light', ...options } = {}) {
return render(
<ThemeProvider initialTheme={theme}>
{ui}
</ThemeProvider>,
options
);
}
describe('ThemedButton', () => {
test('applies light theme styles', () => {
renderWithTheme(<ThemedButton>Click me</ThemedButton>, { theme: 'light' });
const button = screen.getByRole('button');
expect(button).toHaveClass('light-theme');
});
test('applies dark theme styles', () => {
renderWithTheme(<ThemedButton>Click me</ThemedButton>, { theme: 'dark' });
const button = screen.getByRole('button');
expect(button).toHaveClass('dark-theme');
});
});
Pattern 4: Testing Custom Hooks
import { renderHook, waitFor } from '@testing-library/react';
import { act } from 'react';
import useCounter from './useCounter';
describe('useCounter', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('resets counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
// Testing async hooks
describe('useUser', () => {
test('fetches user data', async () => {
const mockUser = { name: 'John' };
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve(mockUser),
})
);
const { result } = renderHook(() => useUser('123'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.user).toEqual(mockUser);
});
});
Best Practices
1. Query by Accessibility Roles
Use queries that encourage accessible markup.
// β
Best: Query by role (encourages semantic HTML)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { name: /welcome/i });
// β
Good: Query by label (accessible forms)
screen.getByLabelText(/password/i);
// β οΈ Okay: Query by text (if unique)
screen.getByText(/welcome back/i);
// β Avoid: Query by test ID (last resort)
screen.getByTestId('submit-button');
// β Avoid: Query by class/id (implementation detail)
container.querySelector('.submit-btn');
2. Use userEvent Over fireEvent
userEvent simulates actual user interactions more accurately.
import userEvent from '@testing-library/user-event';
// β Less realistic
fireEvent.change(input, { target: { value: 'hello' } });
// β
More realistic - fires all events a user would trigger
const user = userEvent.setup();
await user.type(input, 'hello');
3. Test User Flows, Not Implementation
// β Bad: Testing implementation
test('calls useState hook', () => {
const spy = jest.spyOn(React, 'useState');
render(<Counter />);
expect(spy).toHaveBeenCalled();
});
// β
Good: Testing user experience
test('user can increment and decrement counter', async () => {
const user = userEvent.setup();
render(<Counter />);
const increment = screen.getByRole('button', { name: /increment/i });
const decrement = screen.getByRole('button', { name: /decrement/i });
await user.click(increment);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
await user.click(decrement);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
4. Mock External Dependencies
// Mock fetch
global.fetch = vi.fn();
beforeEach(() => {
fetch.mockClear();
});
test('fetches data on mount', async () => {
fetch.mockResolvedValueOnce({
json: async () => ({ data: 'test' }),
});
render(<DataComponent />);
await waitFor(() => {
expect(screen.getByText('test')).toBeInTheDocument();
});
expect(fetch).toHaveBeenCalledWith('/api/data');
});
// Mock modules
vi.mock('./api', () => ({
fetchUser: vi.fn(() => Promise.resolve({ name: 'John' })),
}));
5. Create Custom Render Functions
// test-utils.js
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './ThemeContext';
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
export function renderWithProviders(
ui,
{
route = '/',
theme = 'light',
...options
} = {}
) {
window.history.pushState({}, 'Test page', route);
return render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ThemeProvider initialTheme={theme}>
{ui}
</ThemeProvider>
</BrowserRouter>
</QueryClientProvider>,
options
);
}
// Usage
import { renderWithProviders } from './test-utils';
test('renders dashboard', () => {
renderWithProviders(<Dashboard />, { route: '/dashboard' });
expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
});
Testing Patterns for Common Scenarios
Testing Forms
test('validates and submits form', async () => {
const handleSubmit = vi.fn();
const user = userEvent.setup();
render(<RegistrationForm onSubmit={handleSubmit} />);
// Fill out form
await user.type(screen.getByLabelText(/username/i), 'johndoe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
// Submit
await user.click(screen.getByRole('button', { name: /register/i }));
// Check validation
expect(handleSubmit).toHaveBeenCalledWith({
username: 'johndoe',
email: 'john@example.com',
password: 'password123',
});
});
Testing Modals
test('opens and closes modal', async () => {
const user = userEvent.setup();
render(<ModalExample />);
// Modal not visible initially
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Open modal
await user.click(screen.getByRole('button', { name: /open modal/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Close modal
await user.click(screen.getByRole('button', { name: /close/i }));
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
Testing Lists and Filtering
test('filters list based on search', async () => {
const user = userEvent.setup();
const items = [
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Cherry' },
];
render(<FilterableList items={items} />);
// All items visible initially
expect(screen.getByText('Apple')).toBeInTheDocument();
expect(screen.getByText('Banana')).toBeInTheDocument();
expect(screen.getByText('Cherry')).toBeInTheDocument();
// Filter list
const searchInput = screen.getByRole('textbox', { name: /search/i });
await user.type(searchInput, 'an');
// Only matching items visible
expect(screen.queryByText('Apple')).not.toBeInTheDocument();
expect(screen.getByText('Banana')).toBeInTheDocument();
expect(screen.queryByText('Cherry')).not.toBeInTheDocument();
});
Coverage Targets
Don't chase 100% coverage. Focus on critical paths.
// package.json
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest --coverage"
}
}
Good coverage targets:
- 80-90% for business logic
- 60-70% for UI components
- 100% for utility functions and critical paths
Common Pitfalls
1. Testing Implementation Details
// β Bad
test('uses useState', () => {
const { result } = renderHook(() => useCounter());
expect(result.current[0]).toBe(0);
});
// β
Good
test('displays initial count', () => {
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
2. Not Waiting for Async Updates
// β Bad: Doesn't wait
test('loads data', () => {
render(<AsyncComponent />);
expect(screen.getByText('Data loaded')).toBeInTheDocument(); // Fails!
});
// β
Good: Waits for async operation
test('loads data', async () => {
render(<AsyncComponent />);
expect(await screen.findByText('Data loaded')).toBeInTheDocument();
});
3. Testing Too Much in One Test
// β Bad: Too much in one test
test('entire user flow', async () => {
// ... 100 lines of test code
});
// β
Good: Split into multiple tests
describe('User Flow', () => {
test('user can login', async () => { /* ... */ });
test('user can view dashboard', async () => { /* ... */ });
test('user can update profile', async () => { /* ... */ });
test('user can logout', async () => { /* ... */ });
});
Testing Checklist
- Test user interactions, not implementation
- Use accessible queries (getByRole, getByLabelText)
- Wait for async operations with waitFor/findBy
- Mock external dependencies (APIs, localStorage)
- Test error states and edge cases
- Use userEvent for realistic interactions
- Create custom render utilities for providers
- Aim for meaningful coverage, not 100%
- Test critical user flows thoroughly
- Keep tests maintainable and readable
Conclusion
Great React tests:
- Test behavior, not implementation
- Use accessible queries that encourage semantic HTML
- Simulate real user interactions with userEvent
- Wait for async operations properly
- Mock external dependencies to isolate components
- Focus on critical paths over coverage numbers
Remember: The best test is one that catches real bugs and gives you confidence to ship. Start with the most important user flows and work your way out. Happy testing!
Jordan Patel
Web Developer & Technology Enthusiast