Frontend Testing Strategy: Unit, Integration, and End-to-End Testing | SoniNow Blog

Limited TimeLearn More

testingfrontendunit testingintegratione2e

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

Published

2026-06-23

Read Time

5 mins

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.