React Testing Strategies
Comprehensive guide to testing React applications - from unit tests to end-to-end testing, covering tools, best practices, and real-world examples:
Testing Philosophy
The testing pyramid for React applications consists of three layers: Unit Tests (70%), Integration Tests (20%), and End-to-End Tests (10%). Focus on testing behavior, not implementation details.
Jest - The Testing Framework
Jest is a delightful JavaScript testing framework with a focus on simplicity. It works out of the box with React and provides mocking, assertions, and code coverage.
Basic Jest Test
// sum.js
export function sum(a, b) {
return a + b;
}
// sum.test.js
import { sum } from './sum';
describe('sum function', () => {
it('adds two numbers correctly', () => {
expect(sum(1, 2)).toBe(3);
});
it('handles negative numbers', () => {
expect(sum(-1, 1)).toBe(0);
});
});Mocking with Jest
// Mock API calls
jest.mock('./api');
import { fetchUser } from './api';
test('fetches user data', async () => {
fetchUser.mockResolvedValue({
id: 1,
name: 'John Doe',
email: 'john@example.com'
});
const user = await fetchUser(1);
expect(user.name).toBe('John Doe');
expect(fetchUser).toHaveBeenCalledWith(1);
});React Testing Library
React Testing Library builds on top of DOM Testing Library by adding APIs for working with React components. It encourages testing from a user's perspective.
Basic Component Test
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
describe('Counter Component', () => {
it('renders with initial count', () => {
render(<Counter initialCount={0} />);
expect(screen.getByText(/count: 0/i)).toBeInTheDocument();
});
it('increments count when button is clicked', () => {
render(<Counter initialCount={0} />);
const button = screen.getByRole('button', { name: /increment/i });
fireEvent.click(button);
expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
fireEvent.click(button);
expect(screen.getByText(/count: 2/i)).toBeInTheDocument();
});
});Testing Async Components
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
test('loads and displays user data', async () => {
// Mock API
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ name: 'John', email: 'john@example.com' }),
})
);
render(<UserProfile userId={1} />);
// Initially shows loading
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
});
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});Testing User Interactions
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('submits form with user credentials', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// Type in email and password
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
// Click submit button
await user.click(screen.getByRole('button', { name: /submit/i }));
// Verify form submission
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});Testing Hooks
Use @testing-library/react-hooks to test custom hooks in isolation without rendering components.
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
test('useCounter hook', () => {
const { result } = renderHook(() => useCounter(0));
// Initial state
expect(result.current.count).toBe(0);
// Increment
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
// Decrement
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0);
// Reset
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(0);
});Testing Context and Providers
When testing components that use Context, wrap them in the appropriate provider or create a custom render function.
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import ThemedButton from './ThemedButton';
// Custom render with providers
function renderWithProviders(ui, options = {}) {
const { theme = 'light', ...renderOptions } = options;
return render(
<ThemeProvider initialTheme={theme}>
{ui}
</ThemeProvider>,
renderOptions
);
}
test('button uses theme context', () => {
renderWithProviders(<ThemedButton>Click Me</ThemedButton>, {
theme: 'dark'
});
const button = screen.getByRole('button');
expect(button).toHaveClass('dark-theme');
});Integration Testing
Integration tests verify that multiple components work together correctly. They test component interactions, data flow, and complex user scenarios.
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoApp from './TodoApp';
test('complete todo workflow', async () => {
const user = userEvent.setup();
render(<TodoApp />);
// Add a todo
const input = screen.getByPlaceholderText(/add todo/i);
await user.type(input, 'Buy groceries');
await user.click(screen.getByRole('button', { name: /add/i }));
// Verify todo appears
expect(screen.getByText('Buy groceries')).toBeInTheDocument();
// Toggle todo completion
const checkbox = screen.getByRole('checkbox', { name: /buy groceries/i });
await user.click(checkbox);
// Verify todo is marked complete
expect(checkbox).toBeChecked();
// Filter to show only completed
await user.click(screen.getByRole('button', { name: /completed/i }));
expect(screen.getByText('Buy groceries')).toBeInTheDocument();
// Filter to show only active
await user.click(screen.getByRole('button', { name: /active/i }));
expect(screen.queryByText('Buy groceries')).not.toBeInTheDocument();
});End-to-End Testing with Cypress
Cypress provides a complete end-to-end testing solution that runs in the browser and provides excellent debugging capabilities.
// cypress/e2e/todo-app.cy.js
describe('Todo App', () => {
beforeEach(() => {
cy.visit('http://localhost:3000');
});
it('allows users to add and complete todos', () => {
// Add a todo
cy.get('[data-testid="todo-input"]').type('Buy groceries');
cy.get('[data-testid="add-button"]').click();
// Verify todo appears
cy.contains('Buy groceries').should('be.visible');
// Complete the todo
cy.get('[data-testid="todo-checkbox"]').first().click();
cy.get('[data-testid="todo-item"]').first().should('have.class', 'completed');
});
it('persists todos after page reload', () => {
// Add todo
cy.get('[data-testid="todo-input"]').type('Test persistence');
cy.get('[data-testid="add-button"]').click();
// Reload page
cy.reload();
// Verify todo still exists
cy.contains('Test persistence').should('be.visible');
});
it('filters todos correctly', () => {
// Add multiple todos
['Todo 1', 'Todo 2', 'Todo 3'].forEach(todo => {
cy.get('[data-testid="todo-input"]').type(todo);
cy.get('[data-testid="add-button"]').click();
});
// Complete first todo
cy.get('[data-testid="todo-checkbox"]').first().click();
// Filter to show only completed
cy.get('[data-testid="filter-completed"]').click();
cy.get('[data-testid="todo-item"]').should('have.length', 1);
cy.contains('Todo 1').should('be.visible');
// Filter to show only active
cy.get('[data-testid="filter-active"]').click();
cy.get('[data-testid="todo-item"]').should('have.length', 2);
});
});End-to-End Testing with Playwright
Playwright enables reliable end-to-end testing for modern web apps with support for all browsers and provides powerful auto-waiting and debugging features.
import { test, expect } from '@playwright/test';
test.describe('Todo Application', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3000');
});
test('should add and complete a todo', async ({ page }) => {
// Add a new todo
await page.fill('[data-testid="todo-input"]', 'Buy groceries');
await page.click('[data-testid="add-button"]');
// Verify todo is added
await expect(page.locator('text=Buy groceries')).toBeVisible();
// Complete the todo
await page.click('[data-testid="todo-checkbox"]');
// Verify completed state
await expect(page.locator('[data-testid="todo-item"]')).toHaveClass(/completed/);
});
test('should filter todos', async ({ page }) => {
// Add multiple todos
const todos = ['Todo 1', 'Todo 2', 'Todo 3'];
for (const todo of todos) {
await page.fill('[data-testid="todo-input"]', todo);
await page.click('[data-testid="add-button"]');
}
// Complete first todo
await page.locator('[data-testid="todo-checkbox"]').first().click();
// Filter completed
await page.click('text=Completed');
await expect(page.locator('[data-testid="todo-item"]')).toHaveCount(1);
// Filter active
await page.click('text=Active');
await expect(page.locator('[data-testid="todo-item"]')).toHaveCount(2);
});
test('should handle network errors gracefully', async ({ page }) => {
// Intercept and fail API request
await page.route('**/api/todos', route => route.abort());
await page.fill('[data-testid="todo-input"]', 'Test error');
await page.click('[data-testid="add-button"]');
// Verify error message appears
await expect(page.locator('text=Failed to add todo')).toBeVisible();
});
});Testing Best Practices
Test behavior, not implementation: Focus on what users see and do
Use semantic queries: getByRole, getByLabelText over getByTestId
Avoid testing implementation details: Don't test state or internal methods
Mock external dependencies: APIs, timers, external libraries
Write descriptive test names: Describe what the test does and expects
Keep tests isolated: Each test should be independent
Snapshot Testing
Snapshot tests are useful for catching unexpected UI changes, but should be used sparingly and reviewed carefully.
import { render } from '@testing-library/react';
import Button from './Button';
test('Button snapshot', () => {
const { container } = render(
<Button variant="primary" size="large">
Click Me
</Button>
);
expect(container.firstChild).toMatchSnapshot();
});
// Update snapshots with: jest -uCode Coverage
Measure and track code coverage to ensure your tests cover critical paths. Aim for 80%+ coverage on business logic.
# Run tests with coverage
npm test -- --coverage
# Generate HTML coverage report
npm test -- --coverage --coverageReporters=html
# Coverage thresholds in package.json
{
"jest": {
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}Visual Regression Testing
Catch visual bugs by comparing screenshots before and after changes using tools like Percy or Chromatic.
// Percy with Cypress
import '@percy/cypress';
it('matches button snapshot', () => {
cy.visit('/components/button');
cy.percySnapshot('Button Component');
});
// Chromatic with Storybook
// chromatic.yml
name: Chromatic
on: push
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: chromaui/action@v1
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}Keep your own version of these notes — editable, searchable, and organised by your stack.
Start free