Chapter 16: Testing React Applications
Objectives
In this chapter, readers will:
- Write effective unit tests for React components with Jest
- Test user interactions with React Testing Library
- Implement end-to-end tests with Playwright
- Achieve high code coverage with meaningful tests
- Follow testing best practices for maintainable test suites
Chapter Outline
- Objectives
- Chapter Outline
- Introduction to React Testing
- Testing Setup
- Component Testing
- Advanced Testing Patterns
- Integration Testing
- End-to-End Testing
- Test Coverage
- Best Practices
- Summary
Introduction to React Testing
2025 Testing Landscape:
- Jest: Industry standard test runner
- React Testing Library: User-centric component testing
- Playwright: Modern E2E testing (replaces Cypress for many)
- Vitest: Fast alternative to Jest (Vite-native)
- Testing Library User Event: Realistic user interactions
Testing Philosophy:
“The more your tests resemble the way your software is used, the more confidence they can give you.” - Kent C. Dodds
Test Types:
- Unit Tests: Individual components and functions
- Integration Tests: Multiple components working together
- E2E Tests: Full user flows in real browser
Testing Setup
Jest Configuration
Vite Project Setup
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
],
},
},
});
// src/test/setup.ts
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);
afterEach(() => {
cleanup();
});
Next.js Project Setup
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
// jest.config.js
const nextJest = require('next/jest');
const createJestConfig = nextJest({
dir: './',
});
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{js,jsx,ts,tsx}',
],
};
module.exports = createJestConfig(customJestConfig);
// jest.setup.js
import '@testing-library/jest-dom';
React Testing Library
// src/test/utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
// Custom render function with providers
function customRender(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
return render(ui, { ...options });
}
export * from '@testing-library/react';
export { customRender as render };
TypeScript Support
// tsconfig.json
{
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"]
}
}
Component Testing
Basic Component Tests
// src/components/Button.tsx
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
variant?: 'primary' | 'secondary';
disabled?: boolean;
}
export function Button({
children,
onClick,
variant = 'primary',
disabled = false,
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{children}
</button>
);
}
// src/components/Button.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
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.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('applies correct variant class', () => {
render(<Button variant="secondary">Secondary</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('btn-secondary');
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('does not call onClick when disabled', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick} disabled>Disabled</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});
Testing Props and State
// src/components/Counter.tsx
import { useState } from 'react';
interface CounterProps {
initialCount?: number;
max?: number;
}
export function Counter({ initialCount = 0, max = 10 }: CounterProps) {
const [count, setCount] = useState(initialCount);
const increment = () => {
if (count < max) {
setCount(count + 1);
}
};
const decrement = () => {
if (count > 0) {
setCount(count - 1);
}
};
return (
<div>
<p>Count: <span data-testid="count">{count}</span></p>
<button onClick={decrement}>Decrement</button>
<button onClick={increment}>Increment</button>
</div>
);
}
// src/components/Counter.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
it('renders with default count', () => {
render(<Counter />);
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
it('renders with initial count', () => {
render(<Counter initialCount={5} />);
expect(screen.getByTestId('count')).toHaveTextContent('5');
});
it('increments count when increment button clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByText(/increment/i));
expect(screen.getByTestId('count')).toHaveTextContent('1');
});
it('decrements count when decrement button clicked', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
await user.click(screen.getByText(/decrement/i));
expect(screen.getByTestId('count')).toHaveTextContent('4');
});
it('does not increment beyond max', async () => {
const user = userEvent.setup();
render(<Counter initialCount={10} max={10} />);
await user.click(screen.getByText(/increment/i));
expect(screen.getByTestId('count')).toHaveTextContent('10');
});
it('does not decrement below zero', async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
await user.click(screen.getByText(/decrement/i));
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
});
Testing Events
// src/components/SearchInput.tsx
interface SearchInputProps {
onSearch: (query: string) => void;
placeholder?: string;
}
export function SearchInput({ onSearch, placeholder }: SearchInputProps) {
const [value, setValue] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(value);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
aria-label="Search"
/>
<button type="submit">Search</button>
</form>
);
}
// src/components/SearchInput.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchInput } from './SearchInput';
describe('SearchInput', () => {
it('calls onSearch with query when form submitted', async () => {
const handleSearch = vi.fn();
const user = userEvent.setup();
render(<SearchInput onSearch={handleSearch} />);
const input = screen.getByRole('textbox', { name: /search/i });
await user.type(input, 'React testing');
await user.click(screen.getByRole('button', { name: /search/i }));
expect(handleSearch).toHaveBeenCalledWith('React testing');
});
it('updates input value when typing', async () => {
const handleSearch = vi.fn();
const user = userEvent.setup();
render(<SearchInput onSearch={handleSearch} />);
const input = screen.getByRole('textbox');
await user.type(input, 'test query');
expect(input).toHaveValue('test query');
});
it('renders with placeholder', () => {
render(<SearchInput onSearch={vi.fn()} placeholder="Search items..." />);
expect(screen.getByPlaceholderText(/search items/i)).toBeInTheDocument();
});
});
Testing Hooks
// src/hooks/useCounter.ts
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0) {
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 };
}
// src/hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with custom 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();
});
expect(result.current.count).toBe(12);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Advanced Testing Patterns
Mocking
Mocking Modules
// src/services/api.ts
export async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// src/components/UserProfile.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
import * as api from '../services/api';
// Mock the entire module
vi.mock('../services/api');
describe('UserProfile', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('displays user data when loaded', async () => {
const mockUser = { id: '1', name: 'John Doe', email: 'john@example.com' };
vi.spyOn(api, 'fetchUser').mockResolvedValue(mockUser);
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(api.fetchUser).toHaveBeenCalledWith('1');
});
it('displays error when fetch fails', async () => {
vi.spyOn(api, 'fetchUser').mockRejectedValue(new Error('Failed to fetch'));
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
Mocking Fetch
// Global fetch mock
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('fetches and displays data', async () => {
const mockData = { name: 'Test Item' };
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});
render(<DataComponent />);
await waitFor(() => {
expect(screen.getByText('Test Item')).toBeInTheDocument();
});
expect(fetch).toHaveBeenCalledWith('/api/data');
});
Async Testing
// src/components/AsyncData.tsx
import { useState, useEffect } from 'react';
export function AsyncData() {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchData() {
try {
const response = await fetch('/api/data');
if (!response.ok) throw new Error('Failed to fetch');
const json = await response.json();
setData(json);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}
fetchData();
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!data) return <div>No data</div>;
return <div>Data: {data.value}</div>;
}
// src/components/AsyncData.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { AsyncData } from './AsyncData';
describe('AsyncData', () => {
it('shows loading state initially', () => {
global.fetch = vi.fn(() => new Promise(() => {}));
render(<AsyncData />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays data after successful fetch', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ value: 'Test Value' }),
});
render(<AsyncData />);
await waitFor(() => {
expect(screen.getByText(/data: test value/i)).toBeInTheDocument();
});
});
it('displays error when fetch fails', async () => {
global.fetch = vi.fn().mockRejectedValueOnce(new Error('Network error'));
render(<AsyncData />);
await waitFor(() => {
expect(screen.getByText(/error: network error/i)).toBeInTheDocument();
});
});
it('displays error when response is not ok', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
});
render(<AsyncData />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
Context Testing
// src/test/utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { AuthProvider } from '../contexts/AuthContext';
interface CustomRenderOptions extends RenderOptions {
user?: any;
}
function customRender(
ui: ReactElement,
{ user, ...options }: CustomRenderOptions = {}
) {
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider initialUser={user}>
{children}
</AuthProvider>
);
return render(ui, { wrapper: Wrapper, ...options });
}
export * from '@testing-library/react';
export { customRender as render };
// src/components/ProtectedContent.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '../test/utils';
import { ProtectedContent } from './ProtectedContent';
describe('ProtectedContent', () => {
it('shows content when user is authenticated', () => {
const mockUser = { id: '1', name: 'John' };
render(<ProtectedContent />, { user: mockUser });
expect(screen.getByText(/protected content/i)).toBeInTheDocument();
});
it('shows login prompt when user is not authenticated', () => {
render(<ProtectedContent />, { user: null });
expect(screen.getByText(/please log in/i)).toBeInTheDocument();
});
});
Router Testing
// src/test/utils.tsx (React Router)
import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { ReactElement } from 'react';
function customRender(
ui: ReactElement,
{ route = '/', ...options }: RenderOptions & { route?: string } = {}
) {
window.history.pushState({}, 'Test page', route);
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<BrowserRouter>{children}</BrowserRouter>
);
return render(ui, { wrapper: Wrapper, ...options });
}
export * from '@testing-library/react';
export { customRender as render };
// src/components/ProductPage.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '../test/utils';
import { Route, Routes } from 'react-router-dom';
import { ProductPage } from './ProductPage';
describe('ProductPage', () => {
it('displays product based on URL parameter', () => {
render(
<Routes>
<Route path="/products/:id" element={<ProductPage />} />
</Routes>,
{ route: '/products/123' }
);
// Test component behavior with product ID 123
});
});
Integration Testing
Form Testing
// src/components/LoginForm.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('submits form with valid credentials', async () => {
const handleLogin = vi.fn();
const user = userEvent.setup();
render(<LoginForm onLogin={handleLogin} />);
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /log in/i }));
await waitFor(() => {
expect(handleLogin).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
});
});
});
it('shows validation errors for empty fields', async () => {
const user = userEvent.setup();
render(<LoginForm onLogin={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
});
it('shows error message for invalid email', async () => {
const user = userEvent.setup();
render(<LoginForm onLogin={vi.fn()} />);
await user.type(screen.getByLabelText(/email/i), 'invalid-email');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
});
it('disables submit button while submitting', async () => {
const handleLogin = vi.fn(() => new Promise((resolve) => setTimeout(resolve, 100)));
const user = userEvent.setup();
render(<LoginForm onLogin={handleLogin} />);
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
const submitButton = screen.getByRole('button', { name: /log in/i });
await user.click(submitButton);
expect(submitButton).toBeDisabled();
await waitFor(() => {
expect(submitButton).not.toBeDisabled();
});
});
});
API Integration
// src/components/UserList.test.tsx
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { UserList } from './UserList';
const mockUsers = [
{ id: '1', name: 'John Doe', email: 'john@example.com' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com' },
];
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.json(mockUsers));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UserList', () => {
it('fetches and displays users', async () => {
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
it('handles fetch error', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
it('handles empty user list', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.json([]));
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/no users found/i)).toBeInTheDocument();
});
});
});
Authentication Flow
// src/flows/authentication.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from '../App';
describe('Authentication Flow', () => {
it('allows user to log in and access protected content', async () => {
const user = userEvent.setup();
render(<App />);
// Initially not logged in
expect(screen.getByText(/log in/i)).toBeInTheDocument();
// Navigate to login page
await user.click(screen.getByRole('link', { name: /log in/i }));
// Fill in login form
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /log in/i }));
// Should be redirected to dashboard
await waitFor(() => {
expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
});
// Can access protected content
await user.click(screen.getByRole('link', { name: /profile/i }));
expect(screen.getByText(/profile page/i)).toBeInTheDocument();
// Can log out
await user.click(screen.getByRole('button', { name: /log out/i }));
expect(screen.getByText(/log in/i)).toBeInTheDocument();
});
});
End-to-End Testing
Playwright Setup
npm install -D @playwright/test
npx playwright install
// 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',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
Writing E2E Tests
// e2e/authentication.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('user can log in', async ({ page }) => {
await page.goto('/');
// Click login link
await page.getByRole('link', { name: /log in/i }).click();
await expect(page).toHaveURL('/login');
// Fill in form
await page.getByLabel(/email/i).fill('user@example.com');
await page.getByLabel(/password/i).fill('password123');
await page.getByRole('button', { name: /log in/i }).click();
// Should redirect to dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText(/welcome/i)).toBeVisible();
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel(/email/i).fill('wrong@example.com');
await page.getByLabel(/password/i).fill('wrongpassword');
await page.getByRole('button', { name: /log in/i }).click();
await expect(page.getByText(/invalid credentials/i)).toBeVisible();
await expect(page).toHaveURL('/login');
});
test('protects routes when not authenticated', async ({ page }) => {
await page.goto('/dashboard');
// Should redirect to login
await expect(page).toHaveURL('/login');
});
});
// e2e/shopping-cart.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Shopping Cart', () => {
test('user can add items to cart and checkout', async ({ page }) => {
await page.goto('/products');
// Add first product to cart
await page.getByTestId('product-1').getByRole('button', { name: /add to cart/i }).click();
// Verify cart badge updated
await expect(page.getByTestId('cart-badge')).toHaveText('1');
// Add second product
await page.getByTestId('product-2').getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByTestId('cart-badge')).toHaveText('2');
// View cart
await page.getByRole('link', { name: /cart/i }).click();
await expect(page).toHaveURL('/cart');
// Verify items in cart
await expect(page.getByTestId('cart-item')).toHaveCount(2);
// Proceed to checkout
await page.getByRole('button', { name: /checkout/i }).click();
await expect(page).toHaveURL('/checkout');
// Fill checkout form
await page.getByLabel(/name/i).fill('John Doe');
await page.getByLabel(/email/i).fill('john@example.com');
await page.getByLabel(/address/i).fill('123 Main St');
// Submit order
await page.getByRole('button', { name: /place order/i }).click();
// Verify success
await expect(page.getByText(/order confirmed/i)).toBeVisible();
await expect(page.getByTestId('cart-badge')).toHaveText('0');
});
});
Visual Regression
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Visual Regression', () => {
test('homepage looks correct', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png');
});
test('product page looks correct', async ({ page }) => {
await page.goto('/products/123');
await expect(page).toHaveScreenshot('product-page.png');
});
test('mobile homepage looks correct', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-mobile.png');
});
});
Test Coverage
Measuring Coverage
# Vitest
npm run test -- --coverage
# Jest
npm run test -- --coverage
// package.json
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest --coverage",
"test:ui": "vitest --ui"
}
}
Coverage Goals
Recommended Coverage Targets:
- Statements: 80%+
- Branches: 75%+
- Functions: 80%+
- Lines: 80%+
Focus on:
- Business logic
- User interactions
- Error handling
- Edge cases
Less critical:
- UI/styling components
- Third-party integrations
- Configuration files
Coverage Reports
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/mockData',
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});
Best Practices
Testing Guidelines
✅ Do:
- Test behavior, not implementation
- Use accessible queries (
getByRole,getByLabelText) - Test user interactions with
@testing-library/user-event - Write descriptive test names
- Keep tests focused - one concept per test
- Use proper async/await for async operations
- Mock external dependencies
- Test error states
- Follow AAA pattern (Arrange, Act, Assert)
❌ Don’t:
- Don’t test implementation details
- Don’t use
getByTestIdunless necessary - Don’t write brittle selectors
- Don’t skip cleanup
- Don’t forget accessibility
- Don’t have flaky tests
- Don’t test third-party libraries
Query Priority
// ✅ Best: Accessible to everyone
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email/i);
screen.getByPlaceholderText(/search/i);
screen.getByText(/welcome/i);
// ⚠️ Use sparingly
screen.getByDisplayValue('value');
screen.getByAltText('image');
screen.getByTitle('title');
// ❌ Last resort only
screen.getByTestId('submit-button');
Test Organization
src/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx
│ │ └── index.ts
│ └── Form/
│ ├── Form.tsx
│ ├── Form.test.tsx
│ └── index.ts
├── hooks/
│ ├── useAuth.ts
│ └── useAuth.test.ts
└── test/
├── setup.ts
├── utils.tsx
└── mockData/
Summary
2025 Testing Standards
Testing Stack:
- Unit/Integration: Vitest + React Testing Library
- E2E: Playwright
- API Mocking: MSW (Mock Service Worker)
- User Interactions: @testing-library/user-event
Key Principles:
- Test user behavior, not implementation
- Make tests maintainable and readable
- Balance unit, integration, and E2E tests
- Automate testing in CI/CD
- Maintain good coverage of critical paths
Next Steps
Congratulations! You’ve learned React testing. Continue with:
- Chapter 17: Deployment and Production
Testing ensures your application works correctly - invest time in good tests!