Frontend Testing Strategy: Unit, Integration, and End-to-End Testing

A frontend without tests is a ticking time bomb. Every refactor risks breaking functionality that was working yesterday. A well-structured testing strategy catches regressions early, documents expected behavior, and gives developers confidence to ship. The key is using the right tool for each level of the testing pyramid.
Unit Tests with Vitest: Isolate and Validate Logic
Unit tests verify individual functions and hooks in isolation. Vitest has become the go-to runner in the Vite ecosystem, offering Jest-compatible API with native ESM and TypeScript support:
// utils/format-currency.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency } from './format-currency';
describe('formatCurrency', () => {
it('formats USD correctly', () => {
expect(formatCurrency(1000, 'USD')).toBe('$1,000.00');
});
it('handles zero', () => {
expect(formatCurrency(0, 'USD')).toBe('$0.00');
});
it('handles large numbers with commas', () => {
expect(formatCurrency(1234567, 'USD')).toBe('$1,234,567.00');
});
it('formats EUR with correct symbol', () => {
expect(formatCurrency(500, 'EUR')).toBe('€500.00');
});
});
Test pure functions exhaustively—edge cases, error states, and boundary conditions. For custom React hooks, use renderHook from @testing-library/react-hooks:
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('starts at initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments the counter', () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
});
Aim for 80% coverage on utility functions and hooks. These rarely change interface but catch subtle regressions instantly.
Integration Tests with Testing Library
Integration tests verify that components render correctly and respond to user interactions. Testing Library enforces testing from the user's perspective—find elements by role, label, or text, never by implementation details:
// components/CheckoutForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CheckoutForm } from './CheckoutForm';
describe('CheckoutForm', () => {
it('shows validation errors for empty fields', async () => {
const user = userEvent.setup();
render(<CheckoutForm />);
await user.click(screen.getByRole('button', { name: /submit order/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/shipping address is required/i)).toBeInTheDocument();
});
it('submits with valid data', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<CheckoutForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/address/i), '123 Main St');
await user.click(screen.getByRole('button', { name: /submit order/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: '[email protected]',
address: '123 Main St',
});
});
});
Test user flows, not component internals. When a button says "Submit Order," find it by that text. When you need to verify an error message, look for the rendered text—not a CSS class or state variable.
End-to-End Tests with Playwright
E2E tests validate complete user journeys across pages, API calls, and browser behavior. Playwright is the industry standard for cross-browser testing:
// tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test('complete checkout flow', async ({ page }) => {
await page.goto('/products');
// Add item to cart
await page.getByRole('button', { name: /add to cart/i }).first().click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
// Go to checkout
await page.getByRole('link', { name: /checkout/i }).click();
await expect(page).toHaveURL('/checkout');
// Fill shipping info
await page.getByLabel(/email/i).fill('[email protected]');
await page.getByLabel(/address/i).fill('123 Main St');
// Submit
await page.getByRole('button', { name: /place order/i }).click();
// Verify confirmation
await expect(page.getByText(/order confirmed/i)).toBeVisible();
});
Use Playwright's codegen to generate initial selectors, then refine them. Run E2E tests against a staging environment or a preview deployment on every PR.
Mocking APIs and Network Requests
Frontend tests should run without a real backend. Mock HTTP requests with MSW (Mock Service Worker):
// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/products', () => {
return HttpResponse.json([
{ id: 1, name: 'Widget', price: 29.99 },
{ id: 2, name: 'Gadget', price: 49.99 },
]);
}),
http.post('/api/checkout', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ orderId: 'ord_123', status: 'confirmed' });
}),
];
MSW works in both Vitest unit tests and Playwright E2E tests. In Playwright, configure it as a route handler for consistent mocks across tests.
Test Organization by Pyramid
Structure your test files to mirror the pyramid:
src/
utils/
format-currency.ts
format-currency.test.ts # Unit — fast, many
components/
CheckoutForm/
CheckoutForm.tsx
CheckoutForm.test.tsx # Integration — moderate count
app/
checkout/
__tests__/
checkout-flow.spec.ts # E2E — few, critical paths
Run unit and integration tests on every commit. Run E2E tests before merging to main. Keep the ratio roughly 70% unit, 20% integration, 10% E2E.
{
"scripts": {
"test": "vitest",
"test:e2e": "playwright test",
"test:ci": "vitest run --coverage && playwright test"
}
}
Continuous Testing in CI
Fail the build on test failure. Use --shard to parallelize E2E tests across multiple runners:
- run: npx playwright test --shard=${{ matrix.shard }}/${{ strategy.job-count }}
Store test reports and screenshots for failed E2E tests as CI artifacts. A screenshot of the failing state is often faster to debug than a stack trace.
A proper frontend testing strategy gives you the confidence to refactor, the documentation to onboard new developers, and the safety net to ship frequently. Invest in the testing pyramid, and every deploy becomes lower risk.
Our web development team sets up comprehensive testing pipelines with Vitest, Testing Library, and Playwright for every project we deliver.
Related Insights

Accessibility Testing Automation: axe-core, Lighthouse, and CI Integration
Learn automated accessibility testing with axe-core, Lighthouse CI, and integration into CI/CD pipelines for catching issues before they reach production.

CI/CD Pipeline Design: Automating Build, Test, and Deployment Workflows
A guide to designing CI/CD pipelines that automate build, test, and deployment including GitHub Actions, GitLab CI, environment strategies, and rollback patterns.

CSS Container Queries: Building Truly Responsive Components
Learn how to use CSS Container Queries for building component-level responsive designs that adapt to their container rather than the viewport.