Testing React Applications: A Practical Guide

December 14, 2024

Testing React Applications: A Practical Guide

Tests are your safety net — they let you refactor with confidence and ship changes without fear. The goal is not “as many tests as possible”; it’s confidence in the critical paths. A well-tested React application gives you the freedom to refactor, upgrade dependencies, and add features without breaking existing functionality.

Testing dashboard with green checkmarks

The Testing Pyramid for React

The testing pyramid helps you distribute your testing efforts effectively:

/\\ / \\ / E2E \\ <-- Few tests, high confidence (5-10%) /--------\\ / Integration \\ <-- Medium tests, component interactions (20-30%) /--------------\\ / Unit \\ <-- Many tests, fast feedback (60-75%) /--------------------\\

Test Type Comparison

TypeSpeedCostConfidenceWhen to Use
UnitFast (<100ms)LowComponent logicBusiness logic, utilities, hooks
IntegrationMedium (<1s)MediumComponent interactionsPages, features, data flow
E2ESlow (>5s)HighFull user journeyCritical paths, checkout, auth

Setting Up Your Testing Environment

Essential Dependencies

# Core testing libraries npm install --save-dev vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event # E2E testing npm install --save-dev playwright # Utilities npm install --save-dev msw # Mock Service Worker for API mocking

Configuration

// vitest.config.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'jsdom', globals: true, setupFiles: './src/test/setup.ts', coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/', 'src/test/', '**/*.d.ts', ], }, }, });
// src/test/setup.ts import '@testing-library/jest-dom'; import { cleanup } from '@testing-library/react'; import { afterEach } from 'vitest'; // Clean up after each test afterEach(() => { cleanup(); });

Unit Testing Components

Testing Presentational Components

// Button.tsx interface ButtonProps { children: React.ReactNode; variant?: 'primary' | 'secondary'; onClick?: () => void; disabled?: boolean; } export function Button({ children, variant = 'primary', onClick, disabled }: ButtonProps) { return ( <button className={`btn btn-${variant}`} onClick={onClick} disabled={disabled} data-testid="button" > {children} </button> ); } // Button.test.tsx import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Button } from './Button'; import { describe, it, expect, vi } from 'vitest'; describe('Button', () => { it('renders with default primary variant', () => { render(<Button>Click me</Button>); const button = screen.getByTestId('button'); expect(button).toHaveClass('btn-primary'); expect(button).toHaveTextContent('Click me'); }); it('renders with secondary variant', () => { render(<Button variant="secondary">Click me</Button>); expect(screen.getByTestId('button')).toHaveClass('btn-secondary'); }); it('calls onClick when clicked', async () => { const handleClick = vi.fn(); const user = userEvent.setup(); render(<Button onClick={handleClick}>Click me</Button>); await user.click(screen.getByTestId('button')); expect(handleClick).toHaveBeenCalledTimes(1); }); it('does not call onClick when disabled', async () => { const handleClick = vi.fn(); const user = userEvent.setup(); render(<Button onClick={handleClick} disabled>Click me</Button>); await user.click(screen.getByTestId('button')); expect(handleClick).not.toHaveBeenCalled(); expect(screen.getByTestId('button')).toBeDisabled(); }); });

Testing Components with Props

// UserCard.tsx interface User { id: string; name: string; email: string; avatar?: string; } interface UserCardProps { user: User; onEdit?: (user: User) => void; onDelete?: (userId: string) => void; } export function UserCard({ user, onEdit, onDelete }: UserCardProps) { return ( <div className="user-card" data-testid="user-card"> {user.avatar ? ( <img src={user.avatar} alt={user.name} data-testid="user-avatar" /> ) : ( <div className="avatar-placeholder" data-testid="avatar-placeholder"> {user.name.charAt(0)} </div> )} <div className="user-info"> <h3 data-testid="user-name">{user.name}</h3> <p data-testid="user-email">{user.email}</p> </div> <div className="user-actions"> {onEdit && ( <button onClick={() => onEdit(user)} data-testid="edit-button" > Edit </button> )} {onDelete && ( <button onClick={() => onDelete(user.id)} data-testid="delete-button" > Delete </button> )} </div> </div> ); } // UserCard.test.tsx describe('UserCard', () => { const mockUser: User = { id: '1', name: 'John Doe', email: 'john@example.com', avatar: 'https://example.com/avatar.jpg', }; it('renders user information correctly', () => { render(<UserCard user={mockUser} />); expect(screen.getByTestId('user-name')).toHaveTextContent('John Doe'); expect(screen.getByTestId('user-email')).toHaveTextContent('john@example.com'); }); it('renders avatar when provided', () => { render(<UserCard user={mockUser} />); const avatar = screen.getByTestId('user-avatar'); expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.jpg'); expect(avatar).toHaveAttribute('alt', 'John Doe'); }); it('renders placeholder when avatar is not provided', () => { const userWithoutAvatar = { ...mockUser, avatar: undefined }; render(<UserCard user={userWithoutAvatar} />); expect(screen.getByTestId('avatar-placeholder')).toHaveTextContent('J'); expect(screen.queryByTestId('user-avatar')).not.toBeInTheDocument(); }); it('calls onEdit with user when edit button is clicked', async () => { const onEdit = vi.fn(); const user = userEvent.setup(); render(<UserCard user={mockUser} onEdit={onEdit} />); await user.click(screen.getByTestId('edit-button')); expect(onEdit).toHaveBeenCalledWith(mockUser); }); it('does not show edit button when onEdit is not provided', () => { render(<UserCard user={mockUser} />); expect(screen.queryByTestId('edit-button')).not.toBeInTheDocument(); }); });

Testing Hooks

Custom Hook Testing

// useCounter.ts import { useState, useCallback } from 'react'; interface UseCounterReturn { count: number; increment: () => void; decrement: () => void; reset: () => void; } export function useCounter(initialValue = 0): UseCounterReturn { const [count, setCount] = useState(initialValue); const increment = useCallback(() => setCount(c => c + 1), []); const decrement = useCallback(() => setCount(c => c - 1), []); const reset = useCallback(() => setCount(initialValue), [initialValue]); return { count, increment, decrement, reset }; } // useCounter.test.ts import { renderHook, act } from '@testing-library/react'; import { useCounter } from './useCounter'; import { describe, it, expect } from 'vitest'; 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 count', () => { const { result } = renderHook(() => useCounter()); act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); }); it('decrements count', () => { const { result } = renderHook(() => useCounter(5)); act(() => { result.current.decrement(); }); expect(result.current.count).toBe(4); }); it('resets to initial value', () => { const { result } = renderHook(() => useCounter(10)); act(() => { result.current.increment(); result.current.increment(); result.current.reset(); }); expect(result.current.count).toBe(10); }); });

Integration Testing

Testing Component Interactions

// TodoApp.tsx with multiple components interface Todo { id: string; text: string; completed: boolean; } function TodoList({ todos, onToggle }: { todos: Todo[]; onToggle: (id: string) => void }) { return ( <ul data-testid="todo-list"> {todos.map(todo => ( <li key={todo.id} data-testid={`todo-${todo.id}`}> <input type="checkbox" checked={todo.completed} onChange={() => onToggle(todo.id)} data-testid={`todo-checkbox-${todo.id}`} /> <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}> {todo.text} </span> </li> ))} </ul> ); } function TodoInput({ onAdd }: { onAdd: (text: string) => void }) { const [text, setText] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (text.trim()) { onAdd(text.trim()); setText(''); } }; return ( <form onSubmit={handleSubmit} data-testid="todo-form"> <input type="text" value={text} onChange={e => setText(e.target.value)} placeholder="Add a todo..." data-testid="todo-input" /> <button type="submit" data-testid="add-button">Add</button> </form> ); } export function TodoApp() { const [todos, setTodos] = useState<Todo[]>([]); const addTodo = useCallback((text: string) => { setTodos(prev => [...prev, { id: Date.now().toString(), text, completed: false }]); }, []); const toggleTodo = useCallback((id: string) => { setTodos(prev => prev.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo ) ); }, []); return ( <div> <h1>Todo App</h1> <TodoInput onAdd={addTodo} /> <TodoList todos={todos} onToggle={toggleTodo} /> </div> ); } // TodoApp.test.tsx describe('TodoApp Integration', () => { it('adds a new todo', async () => { const user = userEvent.setup(); render(<TodoApp />); const input = screen.getByTestId('todo-input'); const addButton = screen.getByTestId('add-button'); await user.type(input, 'Learn React Testing'); await user.click(addButton); expect(screen.getByTestId('todo-list')).toHaveTextContent('Learn React Testing'); }); it('toggles todo completion', async () => { const user = userEvent.setup(); render(<TodoApp />); // Add a todo await user.type(screen.getByTestId('todo-input'), 'Test toggle'); await user.click(screen.getByTestId('add-button')); // Find and toggle the checkbox const checkbox = screen.getByTestId('todo-checkbox-'); await user.click(checkbox); expect(checkbox).toBeChecked(); }); it('persists multiple todos', async () => { const user = userEvent.setup(); render(<TodoApp />); // Add multiple todos await user.type(screen.getByTestId('todo-input'), 'First todo'); await user.click(screen.getByTestId('add-button')); await user.type(screen.getByTestId('todo-input'), 'Second todo'); await user.click(screen.getByTestId('add-button')); expect(screen.getByTestId('todo-list').children).toHaveLength(2); expect(screen.getByText('First todo')).toBeInTheDocument(); expect(screen.getByText('Second todo')).toBeInTheDocument(); }); });

Mocking and API Testing

Mocking with MSW

// src/mocks/handlers.ts import { http, HttpResponse } from 'msw'; export const handlers = [ http.get('/api/users', () => { return HttpResponse.json([ { id: '1', name: 'John Doe', email: 'john@example.com' }, { id: '2', name: 'Jane Smith', email: 'jane@example.com' }, ]); }), http.post('/api/users', async ({ request }) => { const newUser = await request.json(); return HttpResponse.json({ id: '3', ...newUser }, { status: 201 }); }), http.get('/api/users/:id', ({ params }) => { const { id } = params; if (id === '404') { return HttpResponse.json({ message: 'User not found' }, { status: 404 }); } return HttpResponse.json({ id, name: 'John Doe', email: 'john@example.com', }); }), ]; // src/mocks/server.ts import { setupServer } from 'msw/node'; import { handlers } from './handlers'; export const server = setupServer(...handlers); // src/test/setup.ts import '@testing-library/jest-dom'; import { cleanup } from '@testing-library/react'; import { afterAll, afterEach, beforeAll } from 'vitest'; import { server } from '../mocks/server'; // Start MSW before tests beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); // Reset handlers after each test afterEach(() => { cleanup(); server.resetHandlers(); }); // Clean up after all tests afterAll(() => server.close());

Testing Components with API Calls

// UserList.tsx import { useState, useEffect } from 'react'; interface User { id: string; name: string; email: string; } export function UserList() { const [users, setUsers] = useState<User[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { fetch('/api/users') .then(res => { if (!res.ok) throw new Error('Failed to fetch'); return res.json(); }) .then(data => { setUsers(data); setLoading(false); }) .catch(err => { setError(err.message); setLoading(false); }); }, []); if (loading) return <div data-testid="loading">Loading...</div>; if (error) return <div data-testid="error">{error}</div>; return ( <ul data-testid="user-list"> {users.map(user => ( <li key={user.id} data-testid={`user-${user.id}`}> {user.name} ({user.email}) </li> ))} </ul> ); } // UserList.test.tsx import { server } from '../mocks/server'; import { http, HttpResponse } from 'msw'; describe('UserList', () => { it('renders loading state initially', () => { render(<UserList />); expect(screen.getByTestId('loading')).toBeInTheDocument(); }); it('renders users after loading', async () => { render(<UserList />); await waitFor(() => { expect(screen.queryByTestId('loading')).not.toBeInTheDocument(); }); expect(screen.getByTestId('user-list')).toBeInTheDocument(); expect(screen.getByTestId('user-1')).toHaveTextContent('John Doe'); expect(screen.getByTestId('user-2')).toHaveTextContent('Jane Smith'); }); it('handles error state', async () => { // Override handler for this test server.use( http.get('/api/users', () => { return HttpResponse.json({ message: 'Server error' }, { status: 500 }); }) ); render(<UserList />); await waitFor(() => { expect(screen.getByTestId('error')).toBeInTheDocument(); }); }); });

E2E Testing with Playwright

Setting Up Playwright

npm init playwright@latest
// playwright.config.ts import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { baseURL: 'http://localhost:5173', trace: 'on-first-retry', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, ], });

Writing E2E Tests

// e2e/todo.spec.ts import { test, expect } from '@playwright/test'; test.describe('Todo App', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); }); test('user can add a todo', async ({ page }) => { await page.fill('[data-testid="todo-input"]', 'Learn Playwright'); await page.click('[data-testid="add-button"]'); await expect(page.locator('[data-testid="todo-list"]')).toContainText('Learn Playwright'); }); test('user can complete a todo', async ({ page }) => { // Add a todo await page.fill('[data-testid="todo-input"]', 'Test completion'); await page.click('[data-testid="add-button"]'); // Complete it await page.click('[data-testid="todo-checkbox"]'); // Verify it's checked and has strikethrough await expect(page.locator('[data-testid="todo-checkbox"]')).toBeChecked(); }); test('user can delete a todo', async ({ page }) => { // Add and then delete await page.fill('[data-testid="todo-input"]', 'To be deleted'); await page.click('[data-testid="add-button"]'); await page.click('[data-testid="delete-button"]'); await expect(page.locator('[data-testid="todo-list"]')).not.toContainText('To be deleted'); }); test('persists todos after page reload', async ({ page }) => { await page.fill('[data-testid="todo-input"]', 'Persistent todo'); await page.click('[data-testid="add-button"]'); await page.reload(); await expect(page.locator('[data-testid="todo-list"]')).toContainText('Persistent todo'); }); });

Testing Best Practices

What to Test (Testing Trophy)

Testing Trophy by Kent C. Dodds

Prioritize tests by value:

  1. Static Analysis (ESLint, TypeScript) - Prevent bugs before running code
  2. Unit Tests - Test pure functions, utilities, hooks
  3. Integration Tests - Test component combinations, data flow
  4. E2E Tests - Test critical user journeys

The Right Way to Query Elements

// ✅ DO: Use user-centric queries in this order screen.getByRole('button', { name: /submit/i }); // 1st choice screen.getByLabelText(/email address/i); // 2nd choice screen.getByPlaceholderText(/enter your email/i); // 3rd choice screen.getByText(/welcome back/i); // 4th choice screen.getByDisplayValue(/test@example.com/i); // 5th choice // ✅ DO: Use test IDs when semantic queries don't work screen.getByTestId('user-profile-card'); // ❌ DON'T: Query by implementation details screen.getByClassName('btn-primary'); // Brittle screen.getById('submit-button'); // Not user-centric document.querySelector('.user-list'); // Not Testing Library

Async Testing Patterns

// ✅ DO: Use findBy for async elements const submitButton = await screen.findByRole('button', { name: /submit/i }); // ✅ DO: Use waitFor for multiple assertions await waitFor(() => { expect(screen.getByTestId('user-list')).toHaveLength(3); expect(screen.getByText('Loaded')).toBeInTheDocument(); }); // ✅ DO: Test loading states render(<UserProfile userId="123" />); expect(screen.getByText(/loading/i)).toBeInTheDocument(); await waitForElementToBeRemoved(() => screen.queryByText(/loading/i)); expect(screen.getByText('John Doe')).toBeInTheDocument();

Testing Checklist

Before shipping a feature, ensure:

  • Component renders correctly with various props
  • User interactions work (clicks, inputs, navigation)
  • Loading states are handled
  • Error states are handled gracefully
  • Edge cases are covered (empty states, max values)
  • Accessibility is verified (keyboard navigation, screen readers)
  • Integration with other components works
  • Critical user paths have E2E coverage

Conclusion

Testing React applications doesn't have to be overwhelming. Start with these fundamentals:

  1. Test behavior, not implementation - Users don't care about your state management
  2. Use semantic queries - Test from the user's perspective
  3. Mock at boundaries - Network, browser APIs, not internals
  4. Write integration tests - Most confidence per test
  5. Add E2E for critical paths - But don't overdo it

Remember: The goal is confidence, not coverage. A few well-written integration tests often provide more value than dozens of shallow unit tests.

Start with one integration test for your most important user flow. Build from there. Over time, your tests become living documentation that helps you move faster with less fear.

GitHub
LinkedIn
X
youtube