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
What's your approach to testing React applications? How do you balance unit, integration, and e2e tests?
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.