Testing is not optional—it's essential for maintaining confidence in your codebase. Here's a complete testing strategy for frontend applications.
Testing Pyramid
Follow the testing pyramid for optimal coverage.
/ /E2E Few, slow, expensive
/------ /Integration /-------------- / Unit Tests Many, fast, cheap
/------------------```
## Unit Testing
### What to Test
Test business logic and pure functions.
// utils/format.test.ts import { formatCurrency, formatDate } from './format';
describe('formatCurrency', () => { it('formats positive numbers correctly', () => { expect(formatCurrency(1234.56)).toBe('$1,234.56'); });
it('formats zero correctly', () => { expect(formatCurrency(0)).toBe('$0.00'); });
it('handles negative numbers', () => { expect(formatCurrency(-100)).toBe('-$100.00'); });
it('respects locale', () => { expect(formatCurrency(1234.56, 'de-DE')).toBe('1.234,56 €'); }); });
describe('formatDate', () => { it('formats dates correctly', () => { const date = new Date('2024-01-15'); expect(formatDate(date)).toBe('Jan 15, 2024'); });
it('handles invalid dates', () => { expect(formatDate(new Date('invalid'))).toBe('Invalid Date'); }); });
### Hooks Testing
Test custom hooks thoroughly.
// hooks/useForm.test.ts import { renderHook, act } from '@testing-library/react'; import { useForm } from './useForm';
describe('useForm', () => { it('initializes with default values', () => { const { result } = renderHook(() => useForm({ username: '', email: '' }) );
expect(result.current.values).toEqual({ username: '', email: '' }); });
it('updates values on change', () => { const { result } = renderHook(() => useForm({ username: '', email: '' }) );
act(() => { result.current.handleChange('username')('john'); });
expect(result.current.values.username).toBe('john'); });
it('validates on submit', async () => { const { result } = renderHook(() => useForm({ username: '', email: '' }) );
await act(async () => { try { await result.current.submit(); } catch (error) { // Expected } });
expect(result.current.errors.username).toBeDefined(); }); });
### Component Testing
Test component behavior, not implementation.
// components/Button.test.tsx import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Button } from './Button';
describe('Button', () => { it('renders children correctly', () => { render(<Button>Click me</Button>); expect(screen.getByText('Click me')).toBeInTheDocument(); });
it('calls onClick when clicked', async () => { const handleClick = jest.fn(); const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByText('Click me')); expect(handleClick).toHaveBeenCalledTimes(1); });
it('is disabled when disabled prop is true', () => { render(<Button disabled>Click me</Button>); expect(screen.getByRole('button')).toBeDisabled(); });
it('shows loading spinner when loading', () => { render(<Button loading>Loading</Button>); expect(screen.getByRole('button')).toHaveAttribute('data-loading', 'true'); }); });
## Integration Testing
### API Integration
Test component integration with APIs.
// components/UserProfile.test.tsx import { render, screen, waitFor } from '@testing-library/react'; import { UserProfile } from './UserProfile'; import { server } from '../mocks/server';
describe('UserProfile', () => { beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());
it('loads and displays user data', async () => { server.use( rest.get('/api/users/1', (req, res, ctx) => { return res( ctx.json({ id: 1, name: 'John Doe', email: 'john@example.com', }) ); }) );
render(<UserProfile userId="1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => { expect(screen.getByText('John Doe')).toBeInTheDocument(); expect(screen.getByText('john@example.com')).toBeInTheDocument(); }); });
it('handles API errors', async () => { server.use( rest.get('/api/users/1', (req, res, ctx) => { return res(ctx.status(500)); }) );
render(<UserProfile userId="1" />);
await waitFor(() => { expect(screen.getByText(/error/i)).toBeInTheDocument(); }); }); });
### Redux Integration
Test Redux-connected components.
// components/Counter.test.tsx import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import counterReducer from './counterSlice'; import { Counter } from './Counter';
const renderWithRedux = (component) => { const store = configureStore({ reducer: { counter: counterReducer }, });
return render(<Provider store={store}>{component}</Provider>); };
describe('Counter', () => { it('displays initial count', () => { renderWithRedux(<Counter />); expect(screen.getByText('0')).toBeInTheDocument(); });
it('increments count when increment button clicked', async () => { const user = userEvent.setup(); renderWithRedux(<Counter />);
await user.click(screen.getByText('Increment')); expect(screen.getByText('1')).toBeInTheDocument(); }); });
## End-to-End Testing
### Playwright Setup
Test complete user flows.
// e2e/login.spec.ts import { test, expect } from '@playwright/test';
test.describe('Authentication', () => { test('user can login with valid credentials', async ({ page }) => { await page.goto('/login');
await page.fill('[name="email"]', 'user@example.com'); await page.fill('[name="password"]', 'password123'); await page.click('[type="submit"]');
await expect(page).toHaveURL('/dashboard'); await expect(page.locator('h1')).toContainText('Welcome'); });
test('shows error with invalid credentials', async ({ page }) => { await page.goto('/login');
await page.fill('[name="email"]', 'wrong@example.com'); await page.fill('[name="password"]', 'wrongpassword'); await page.click('[type="submit"]');
await expect(page.locator('.error')).toContainText('Invalid credentials'); });
test('redirects to dashboard if already logged in', async ({ page }) => { // Login first await page.goto('/login'); await page.fill('[name="email"]', 'user@example.com'); await page.fill('[name="password"]', 'password123'); await page.click('[type="submit"]');
// Try to access login again await page.goto('/login'); await expect(page).toHaveURL('/dashboard'); }); });
### Mobile Testing
Test responsive behavior.
test('navigation is hamburger on mobile', async ({ page, viewport }) => { await page.setViewportSize({ width: 375, height: 667 }); await page.goto('/');
// Hamburger menu visible await expect(page.locator('[aria-label="Menu"]')).toBeVisible();
// Desktop nav hidden await expect(page.locator('.desktop-nav')).not.toBeVisible(); });
test('navigation is full on desktop', async ({ page }) => { await page.setViewportSize({ width: 1024, height: 768 }); await page.goto('/');
// Desktop nav visible await expect(page.locator('.desktop-nav')).toBeVisible();
// Hamburger hidden await expect(page.locator('[aria-label="Menu"]')).not.toBeVisible(); });
## Visual Regression Testing
### Chromatic Setup
Catch unintended visual changes.
// stories/Button.stories.tsx import { Button } from './Button';
export default { title: 'Components/Button', component: Button, };
export const Primary = { args: { variant: 'primary', children: 'Primary Button', }, parameters: { chromatic: { viewports: [320, 768, 1024] }, }, };
## Performance Testing
### Lighthouse CI
Enforce performance budgets.
{ "ci": { "assert": { "assertions": { "categories:performance": ["error", { "minScore": 90 }], "categories:accessibility": ["error", { "minScore": 100 }], "categories:best-practices": ["error", { "minScore": 90 }] } } } }
## Accessibility Testing
### Automated A11y Tests
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Accessibility', () => { it('homepage should be accessible', async () => { const { container } = render(<Home />); const results = await axe(container); expect(results).toHaveNoViolations(); });
it('modal should be accessible', async () => { const { container } = render( <Modal isOpen onClose={() => {}}> Modal content </Modal> ); const results = await axe(container); expect(results).toHaveNoViolations(); }); });
## Test Coverage
### Coverage Goals
- Unit tests: 80%+ coverage
- Integration tests: Critical paths
- E2E tests: Key user flows
### Coverage Configuration
// jest.config.js module.exports = { collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts', '!src/**/*.stories.tsx', '!src//__tests__/', ], coverageThresholds: { global: { branches: 80, functions: 80, lines: 80, statements: 80, }, }, };
## Best Practices
### DO
- Test behavior, not implementation
- Write tests before or with code
- Keep tests simple and readable
- Test edge cases
- Mock external dependencies
- Keep tests fast
### DON'T
- Test everything
- Test implementation details
- Write fragile tests
- Skip integration and E2E tests
- Over-mock
- Ignore test failures
## Continuous Integration
### CI Pipeline
.github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest
steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: node-version: '18'
- run: npm ci - run: npm run lint - run: npm run test:unit - run: npm run test:integration - run: npm run test:e2e - run: npm run test:a11y - run: npm run test:visual
## Conclusion
A comprehensive testing strategy includes unit tests for logic, integration tests for components, and E2E tests for user flows. Focus on testing behavior over implementation, maintain good coverage, and run tests in CI. Good tests give you confidence to ship code and prevent regressions.