Skip to main content

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>;
};
});