Testing frontend applications is different from testing backend code. The UI is inherently visual, user interactions are complex, and the browser environment adds layers of unpredictability. Here's how to build a testing strategy that provides real confidence.
The Frontend Testing Pyramid
The traditional testing pyramid needs adjustment for frontend applications. Integration tests provide the best balance of confidence and speed for UI components.
text
/\
/ \
/ E2E \ Few, critical user journeys
/ Tests \ (Playwright, Cypress)
/──────────\
/ \
/ Integration \ Most tests here!
/ Tests \ (React Testing Library)
/──────────────────\
/ \
/ Unit Tests \ Utilities, hooks, pure functions
/──────────────────────\ (Jest, Vitest)Component Testing with React Testing Library
Test components the way users interact with them. Query by role, label, and text - not by test IDs or class names. This ensures your tests remain valid even when implementation details change.
typescript
import { render, screen, userEvent } from '@testing-library/react';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('submits credentials when form is filled', async () => {
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// Query by role and label - accessible and resilient
await userEvent.type(
screen.getByLabelText(/email/i),
'user@example.com'
);
await userEvent.type(
screen.getByLabelText(/password/i),
'password123'
);
await userEvent.click(
screen.getByRole('button', { name: /sign in/i })
);
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123'
});
});
it('shows validation errors for invalid input', async () => {
render(<LoginForm onSubmit={vi.fn()} />);
await userEvent.click(
screen.getByRole('button', { name: /sign in/i })
);
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
});Testing Custom Hooks
typescript
import { renderHook, act, waitFor } from '@testing-library/react';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('debounces value changes', async () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'initial' } }
);
expect(result.current).toBe('initial');
// Update value
rerender({ value: 'updated' });
// Value shouldn't change immediately
expect(result.current).toBe('initial');
// Fast forward time
act(() => {
vi.advanceTimersByTime(500);
});
// Now it should be updated
expect(result.current).toBe('updated');
});
});E2E Testing with Playwright
End-to-end tests verify critical user journeys work correctly across the full stack. Keep them focused on happy paths and critical business flows.
typescript
import { test, expect } from '@playwright/test';
test.describe('Checkout Flow', () => {
test('completes purchase successfully', async ({ page }) => {
// Start from product page
await page.goto('/products/awesome-product');
// Add to cart
await page.getByRole('button', { name: 'Add to Cart' }).click();
await expect(page.getByText('Added to cart')).toBeVisible();
// Go to checkout
await page.getByRole('link', { name: 'Checkout' }).click();
// Fill shipping info
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Address').fill('123 Test St');
// Complete payment
await page.getByRole('button', { name: 'Pay Now' }).click();
// Verify success
await expect(page).toHaveURL(/\/order-confirmation/);
await expect(page.getByText('Thank you!')).toBeVisible();
});
});What Not to Test
- Third-party library internals - trust they work
- Implementation details - test behavior, not how
- Trivial code - simple getters, pass-through functions
- Styles and CSS - visual regression tools are better
- Framework code - React, Vue, etc. are already tested
80%Coverage target
70%Integration tests
< 5sUnit test suite
5-10E2E critical paths