What's your approach to testing React applications? How do you balance unit, integration, and e2e tests?
Quick Answer
A comprehensive testing strategy for React applications follows the testing pyramid principle, balancing different types of tests for optimal coverage and maintainability.
Detailed Answer
A comprehensive testing strategy for React applications follows the testing pyramid principle, balancing different types of tests for optimal coverage and maintainability.
5.3.1. Testing Pyramid Structure
- Unit Tests (70%) - Fast, isolated tests for individual functions/components
- Integration Tests (20%) - Test component interactions and data flow
- End-to-End Tests (10%) - Full user journey testing
5.3.2. Unit Testing
Component Testing with React Testing Library
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button Component', () => {
it('renders with correct text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('handles click events', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('shows loading state', () => {
render(<Button loading>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
});
Custom Hook Testing
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter Hook', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('resets counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(0);
});
});
Utility Function Testing
import { formatCurrency, validateEmail } from './utils';
describe('Utility Functions', () => {
describe('formatCurrency', () => {
it('formats positive numbers correctly', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56');
});
it('handles zero', () => {
expect(formatCurrency(0)).toBe('$0.00');
});
it('handles negative numbers', () => {
expect(formatCurrency(-100)).toBe('-$100.00');
});
});
describe('validateEmail', () => {
it('validates correct email formats', () => {
expect(validateEmail('test@example.com')).toBe(true);
expect(validateEmail('user.name@domain.co.uk')).toBe(true);
});
it('rejects invalid email formats', () => {
expect(validateEmail('invalid-email')).toBe(false);
expect(validateEmail('@domain.com')).toBe(false);
expect(validateEmail('user@')).toBe(false);
});
});
});
5.3.3. Integration Testing
Component Integration with Context
import { render, screen, waitFor } from '@testing-library/react';
import { ThemeProvider } from './ThemeProvider';
import { ThemedButton } from './ThemedButton';
const renderWithTheme = (component: React.ReactElement) => {
return render(
<ThemeProvider theme="dark">
{component}
</ThemeProvider>
);
};
describe('ThemedButton Integration', () => {
it('applies theme styles correctly', () => {
renderWithTheme(<ThemedButton>Click me</ThemedButton>);
const button = screen.getByRole('button');
expect(button).toHaveClass('dark-theme');
});
});
API Integration Testing
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { UserList } from './UserList';
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }
])
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UserList Integration', () => {
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 API errors gracefully', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error loading users/i)).toBeInTheDocument();
});
});
});
Form Integration Testing
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ContactForm } from './ContactForm';
describe('ContactForm Integration', () => {
it('submits form with valid data', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<ContactForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/name/i), 'John Doe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.type(screen.getByLabelText(/message/i), 'Hello world');
await user.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
message: 'Hello world'
});
});
});
it('shows validation errors', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={jest.fn()} />);
await user.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
});
});
5.3.4. End-to-End Testing
Playwright E2E Tests
import { test, expect } from '@playwright/test';
test.describe('User Authentication Flow', () => {
test('user can login and access dashboard', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-button"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
});
test('user can logout', async ({ page }) => {
// Login first
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-button"]');
// Logout
await page.click('[data-testid="user-menu"]');
await page.click('[data-testid="logout-button"]');
await expect(page).toHaveURL('/login');
});
});
test.describe('Shopping Cart Flow', () => {
test('user can add items to cart and checkout', async ({ page }) => {
await page.goto('/products');
// Add first product
await page.click('[data-testid="product-1"] [data-testid="add-to-cart"]');
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
// Add second product
await page.click('[data-testid="product-2"] [data-testid="add-to-cart"]');
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('2');
// Go to cart
await page.click('[data-testid="cart-icon"]');
await expect(page).toHaveURL('/cart');
// Proceed to checkout
await page.click('[data-testid="checkout-button"]');
await expect(page).toHaveURL('/checkout');
});
});
5.3.5. Testing Configuration
Jest Configuration
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/index.tsx',
'!src/setupTests.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
Test Setup
// src/setupTests.ts
import '@testing-library/jest-dom';
import { server } from './mocks/server';
// Establish API mocking before all tests
beforeAll(() => server.listen());
// Reset any request handlers that we may add during the tests
afterEach(() => server.resetHandlers());
// Clean up after the tests are finished
afterAll(() => server.close());
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
};
5.3.6. Testing Best Practices
-
Test Behavior, Not Implementation
// Good: Tests user behavior it('shows error message when form is invalid', () => { render(<ContactForm />); fireEvent.click(screen.getByRole('button')); expect(screen.getByText(/please fill all fields/i)).toBeInTheDocument(); }); // Avoid: Tests implementation details it('calls validateForm when submit button is clicked', () => { const validateForm = jest.fn(); render(<ContactForm validateForm={validateForm} />); fireEvent.click(screen.getByRole('button')); expect(validateForm).toHaveBeenCalled(); }); -
Use Data Test IDs Sparingly
// Use semantic queries first screen.getByRole('button', { name: /submit/i }); screen.getByLabelText(/email address/i); // Use data-testid only when necessary screen.getByTestId('complex-component'); -
Mock External Dependencies
// Mock API calls jest.mock('./api', () => ({ fetchUsers: jest.fn(() => Promise.resolve(mockUsers)),
}));
// Mock browser APIs Object.defineProperty(window, 'localStorage', { value: { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), }, });
4. **Test Accessibility**
```tsx
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('should not have accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
5.3.7. Continuous Integration
GitHub Actions Workflow
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
- name: Run E2E tests
run: npm run test:e2e
- name: Upload coverage
uses: codecov/codecov-action@v3
This comprehensive testing strategy ensures code quality, catches regressions early, and provides confidence when deploying changes to production.