Testing
React testing strategies, tools, and best practices using Jest, React Testing Library, and modern testing approaches.
Testing Setup
Jest Configuration
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
moduleNameMapping: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'jest-transform-stub',
},
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/index.js',
'!src/reportWebVitals.js',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
Setup Files
// src/setupTests.js
import '@testing-library/jest-dom';
import { configure } from '@testing-library/react';
// Configure testing library
configure({ testIdAttribute: 'data-testid' });
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
disconnect: jest.fn(),
unobserve: jest.fn(),
}));
Component Testing
Basic Component Tests
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';
describe('Button', () => {
test('renders button with text', () => {
render(<Button>Click me</Button>);
expect(
screen.getByRole('button', { name: 'Click me' })
).toBeInTheDocument();
});
test('calls onClick handler when clicked', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('applies correct CSS classes', () => {
render(
<Button variant="primary" size="large">
Button
</Button>
);
const button = screen.getByRole('button');
expect(button).toHaveClass('btn', 'btn-primary', 'btn-large');
});
test('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
Testing Props and State
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
describe('Counter', () => {
test('displays initial count', () => {
render(<Counter initialCount={5} />);
expect(screen.getByText('Count: 5')).toBeInTheDocument();
});
test('increments count when increment button is clicked', () => {
render(<Counter initialCount={0} />);
const incrementButton = screen.getByRole('button', { name: 'Increment' });
fireEvent.click(incrementButton);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
test('decrements count when decrement button is clicked', () => {
render(<Counter initialCount={1} />);
const decrementButton = screen.getByRole('button', { name: 'Decrement' });
fireEvent.click(decrementButton);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
test('calls onChange callback when count changes', () => {
const handleChange = jest.fn();
render(<Counter initialCount={0} onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Increment' }));
expect(handleChange).toHaveBeenCalledWith(1);
});
});
Testing Forms
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ContactForm from './ContactForm';
describe('ContactForm', () => {
test('submits form with valid data', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<ContactForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('Name'), 'John Doe');
await user.type(screen.getByLabelText('Email'), 'john@example.com');
await user.type(screen.getByLabelText('Message'), 'Hello world');
await user.click(screen.getByRole('button', { name: 'Submit' }));
expect(handleSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
message: 'Hello world',
});
});
test('shows validation errors for invalid data', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={jest.fn()} />);
await user.click(screen.getByRole('button', { name: 'Submit' }));
expect(screen.getByText('Name is required')).toBeInTheDocument();
expect(screen.getByText('Email is required')).toBeInTheDocument();
});
test('clears form after successful submission', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn().mockResolvedValue();
render(<ContactForm onSubmit={handleSubmit} />);
const nameInput = screen.getByLabelText('Name');
const emailInput = screen.getByLabelText('Email');
await user.type(nameInput, 'John Doe');
await user.type(emailInput, 'john@example.com');
await user.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => {
expect(nameInput).toHaveValue('');
expect(emailInput).toHaveValue('');
});
});
});
Testing Hooks
Testing Custom Hooks
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
describe('useCounter', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
Testing Hooks with Dependencies
import { renderHook, waitFor } from '@testing-library/react';
import useFetch from './useFetch';
// Mock fetch
global.fetch = jest.fn();
describe('useFetch', () => {
beforeEach(() => {
fetch.mockClear();
});
test('fetches data successfully', async () => {
const mockData = { id: 1, name: 'John' };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});
const { result } = renderHook(() => useFetch('/api/user/1'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});
});
test('handles fetch error', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useFetch('/api/user/1'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.data).toBe(null);
expect(result.current.error).toEqual(new Error('Network error'));
});
});
test('refetches when URL changes', async () => {
fetch.mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: 'John' }),
});
const { result, rerender } = renderHook(({ url }) => useFetch(url), {
initialProps: { url: '/api/user/1' },
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(fetch).toHaveBeenCalledWith('/api/user/1');
rerender({ url: '/api/user/2' });
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith('/api/user/2');
});
});
});
Testing Context
Testing Context Providers
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider, useTheme } from './ThemeContext';
// Test component that uses context
function TestComponent() {
const { theme, toggleTheme } = useTheme();
return (
<div>
<span>Current theme: {theme}</span>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
);
}
// Helper function to render with context
function renderWithTheme(ui, { theme = 'light' } = {}) {
return render(<ThemeProvider initialTheme={theme}>{ui}</ThemeProvider>);
}
describe('ThemeProvider', () => {
test('provides default theme', () => {
renderWithTheme(<TestComponent />);
expect(screen.getByText('Current theme: light')).toBeInTheDocument();
});
test('provides custom initial theme', () => {
renderWithTheme(<TestComponent />, { theme: 'dark' });
expect(screen.getByText('Current theme: dark')).toBeInTheDocument();
});
test('toggles theme when button is clicked', () => {
renderWithTheme(<TestComponent />);
fireEvent.click(screen.getByRole('button', { name: 'Toggle Theme' }));
expect(screen.getByText('Current theme: dark')).toBeInTheDocument();
});
});
Testing with Multiple Providers
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ThemeProvider } from './ThemeContext';
import { AuthProvider } from './AuthContext';
// Test utilities
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
}
function AllProviders({ children }) {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<ThemeProvider>{children}</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);
}
function renderWithAllProviders(ui, options) {
return render(ui, { wrapper: AllProviders, ...options });
}
// Usage in tests
test('renders authenticated user dashboard', () => {
renderWithAllProviders(<Dashboard />);
// Test implementation
});
Async Testing
Testing API Calls
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UserList from './UserList';
// Mock API
const mockUsers = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
];
global.fetch = jest.fn();
describe('UserList', () => {
beforeEach(() => {
fetch.mockClear();
});
test('displays loading state initially', () => {
fetch.mockImplementation(() => new Promise(() => {})); // Never resolves
render(<UserList />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('displays users after successful fetch', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUsers,
});
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
test('displays error message when fetch fails', async () => {
fetch.mockRejectedValueOnce(new Error('Failed to fetch'));
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Error loading users')).toBeInTheDocument();
});
});
test('refetches data when refresh button is clicked', async () => {
fetch.mockResolvedValue({
ok: true,
json: async () => mockUsers,
});
const user = userEvent.setup();
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: 'Refresh' }));
expect(fetch).toHaveBeenCalledTimes(2);
});
});
Testing Timers
import { render, screen, act } from '@testing-library/react';
import Timer from './Timer';
describe('Timer', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('starts timer when start button is clicked', () => {
render(<Timer />);
expect(screen.getByText('Time: 0')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Start' }));
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.getByText('Time: 1')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(2000);
});
expect(screen.getByText('Time: 3')).toBeInTheDocument();
});
test('stops timer when stop button is clicked', () => {
render(<Timer />);
fireEvent.click(screen.getByRole('button', { name: 'Start' }));
act(() => {
jest.advanceTimersByTime(2000);
});
fireEvent.click(screen.getByRole('button', { name: 'Stop' }));
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.getByText('Time: 2')).toBeInTheDocument();
});
});
Testing with React Router
Testing Navigation
import { render, screen } from '@testing-library/react';
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import Navigation from './Navigation';
function renderWithRouter(ui, { route = '/' } = {}) {
return render(<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>);
}
describe('Navigation', () => {
test('renders navigation links', () => {
renderWithRouter(<Navigation />);
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Contact' })).toBeInTheDocument();
});
test('highlights current page', () => {
renderWithRouter(<Navigation />, { route: '/about' });
expect(screen.getByRole('link', { name: 'About' })).toHaveClass('active');
});
});
Testing Route Components
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import UserProfile from './UserProfile';
function renderWithRouter(ui, { route = '/' } = {}) {
return render(<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>);
}
describe('UserProfile', () => {
test('displays user profile with ID from URL', () => {
renderWithRouter(<UserProfile />, { route: '/users/123' });
expect(screen.getByText('User ID: 123')).toBeInTheDocument();
});
test('redirects to login when user is not authenticated', () => {
// Mock authentication context
const mockAuth = { user: null, isAuthenticated: false };
renderWithRouter(
<AuthContext.Provider value={mockAuth}>
<UserProfile />
</AuthContext.Provider>,
{ route: '/users/123' }
);
// Should redirect to login
expect(screen.getByText('Please log in')).toBeInTheDocument();
});
});
Testing Best Practices
Test Organization
// Good test structure
describe('UserCard', () => {
// Setup
const defaultProps = {
user: {
id: 1,
name: 'John Doe',
email: 'john@example.com',
},
};
// Helper function
const renderUserCard = (props = {}) => {
return render(<UserCard {...defaultProps} {...props} />);
};
// Grouped tests
describe('rendering', () => {
test('displays user name', () => {
renderUserCard();
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
test('displays user email', () => {
renderUserCard();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
});
describe('interactions', () => {
test('calls onEdit when edit button is clicked', async () => {
const user = userEvent.setup();
const handleEdit = jest.fn();
renderUserCard({ onEdit: handleEdit });
await user.click(screen.getByRole('button', { name: 'Edit' }));
expect(handleEdit).toHaveBeenCalledWith(1);
});
});
describe('edge cases', () => {
test('handles missing user data gracefully', () => {
renderUserCard({ user: null });
expect(screen.getByText('No user data')).toBeInTheDocument();
});
});
});
Custom Testing Utilities
// test-utils.js
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ThemeProvider } from '../contexts/ThemeContext';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
}
function AllProviders({ children }) {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ThemeProvider>{children}</ThemeProvider>
</BrowserRouter>
</QueryClientProvider>
);
}
function customRender(ui, options) {
return render(ui, { wrapper: AllProviders, ...options });
}
// Mock factories
export const createMockUser = (overrides = {}) => ({
id: 1,
name: 'John Doe',
email: 'john@example.com',
...overrides,
});
export const createMockPost = (overrides = {}) => ({
id: 1,
title: 'Test Post',
content: 'This is a test post',
author: createMockUser(),
...overrides,
});
// Re-export everything
export * from '@testing-library/react';
export { customRender as render };
E2E Testing
Cypress Example
// cypress/integration/user-flow.spec.js
describe('User Flow', () => {
beforeEach(() => {
cy.visit('/');
});
it('allows user to sign up and login', () => {
// Navigate to signup
cy.get('[data-testid=signup-link]').click();
// Fill signup form
cy.get('[data-testid=name-input]').type('John Doe');
cy.get('[data-testid=email-input]').type('john@example.com');
cy.get('[data-testid=password-input]').type('password123');
// Submit form
cy.get('[data-testid=signup-button]').click();
// Should redirect to dashboard
cy.url().should('include', '/dashboard');
cy.get('[data-testid=welcome-message]').should(
'contain',
'Welcome, John Doe'
);
});
it('displays error for invalid login', () => {
cy.get('[data-testid=login-link]').click();
cy.get('[data-testid=email-input]').type('invalid@example.com');
cy.get('[data-testid=password-input]').type('wrongpassword');
cy.get('[data-testid=login-button]').click();
cy.get('[data-testid=error-message]').should(
'contain',
'Invalid credentials'
);
});
});
Playwright Example
// tests/user-flow.spec.js
import { test, expect } from '@playwright/test';
test.describe('User Flow', () => {
test('user can create and edit a post', async ({ page }) => {
await page.goto('/');
// Login
await page.click('[data-testid=login-button]');
await page.fill('[data-testid=email-input]', 'user@example.com');
await page.fill('[data-testid=password-input]', 'password');
await page.click('[data-testid=submit-button]');
// Create post
await page.click('[data-testid=create-post-button]');
await page.fill('[data-testid=title-input]', 'My Test Post');
await page.fill('[data-testid=content-input]', 'This is test content');
await page.click('[data-testid=publish-button]');
// Verify post was created
await expect(page.locator('[data-testid=post-title]')).toHaveText(
'My Test Post'
);
// Edit post
await page.click('[data-testid=edit-post-button]');
await page.fill('[data-testid=title-input]', 'Updated Test Post');
await page.click('[data-testid=save-button]');
// Verify post was updated
await expect(page.locator('[data-testid=post-title]')).toHaveText(
'Updated Test Post'
);
});
});
Quick Reference
Basic Test Structure
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('component behavior', async () => {
const user = userEvent.setup();
render(<Component />);
// Query elements
const button = screen.getByRole('button', { name: 'Click me' });
const input = screen.getByLabelText('Email');
// Interact with elements
await user.type(input, 'test@example.com');
await user.click(button);
// Assert expectations
expect(screen.getByText('Success')).toBeInTheDocument();
});
Common Queries
// By role (preferred)
screen.getByRole('button', { name: 'Submit' });
screen.getByRole('textbox', { name: 'Email' });
// By label
screen.getByLabelText('Email');
// By placeholder
screen.getByPlaceholderText('Enter email');
// By text
screen.getByText('Welcome');
// By test ID
screen.getByTestId('submit-button');
Async Testing
// Wait for element to appear
await screen.findByText('Loaded');
// Wait for condition
await waitFor(() => {
expect(screen.getByText('Success')).toBeInTheDocument();
});
// Wait for element to disappear
await waitForElementToBeRemoved(screen.getByText('Loading'));
Mocking
// Mock function
const mockFn = jest.fn();
// Mock module
jest.mock('./api', () => ({
fetchUser: jest.fn().mockResolvedValue({ name: 'John' }),
}));
// Mock component
jest.mock('./ExpensiveComponent', () => {
return function MockedExpensiveComponent() {
return <div>Mocked Component</div>;
};
});