Skip to content
All essays
WebMarch 1, 202412 min

Frontend Testing Strategies

Comprehensive guide to testing frontend applications from unit tests to end-to-end testing.

Ü
Ümit Uz
Mobile & Full Stack Developer

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.

Related essays

Next essay
PWA Development Complete Guide