Back to Insights
TestingVitestPlaywright

A Frontend Testing Strategy That Actually Works

10/202510 min read
Share
A Frontend Testing Strategy That Actually Works

Why Most Frontend Testing Strategies Fail

The typical frontend testing strategy is inverted. Teams write hundreds of unit tests for utility functions and component rendering, a handful of integration tests, and zero end-to-end tests. The result: 90% code coverage, but bugs still ship because the tests don't verify what users actually do.

The problem isn't a lack of tests — it's testing the wrong things. A unit test that verifies a button has the class btn-primary tells you nothing about whether clicking it submits the form. The testing trophy model — pioneered by Kent C. Doddy — prioritizes integration tests that verify behavior from the user's perspective.

  • Static analysis (TypeScript, ESLint): catch typos, type errors, and bad patterns — free and fast
  • Integration tests: verify user flows and component interactions — highest ROI
  • Unit tests: only for complex logic (calculations, state machines, transformations)
  • E2E tests: critical paths only (signup, checkout, auth) — expensive but essential

#1 Integration Tests: The Highest ROI

Integration tests render components as users see them, trigger real interactions, and verify the outcome. They catch the bugs that actually reach production — broken forms, failed API calls, missing error states.

Testing User Behavior with Vitest + Testing Library

components/__tests__/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import { describe, expect, it } from 'vitest'; import { LoginForm } from '../LoginForm'; const server = setupServer( http.post('/api/auth/login', async ({ request }) => { const body = await request.json() as Record<string, string>; if (body.email === '[email protected]') { return HttpResponse.json({ token: 'abc123' }); } return HttpResponse.json( { code: 'INVALID_CREDENTIALS', message: 'Invalid email or password' }, { status: 401 } ); }) ); beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); describe('LoginForm', () => { it('submits valid credentials and redirects', async () => { const user = userEvent.setup(); render(<LoginForm />); await user.type(screen.getByLabelText('Email'), '[email protected]'); await user.type(screen.getByLabelText('Password'), 'password123'); await user.click(screen.getByRole('button', { name: /sign in/i })); await waitFor(() => { expect(screen.getByText(/welcome/i)).toBeInTheDocument(); }); }); it('shows error message for invalid credentials', async () => { const user = userEvent.setup(); render(<LoginForm />); await user.type(screen.getByLabelText('Email'), '[email protected]'); await user.type(screen.getByLabelText('Password'), 'wrong'); await user.click(screen.getByRole('button', { name: /sign in/i })); await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent( 'Invalid email or password' ); }); }); it('validates required fields before submitting', async () => { const user = userEvent.setup(); render(<LoginForm />); await user.click(screen.getByRole('button', { name: /sign in/i })); expect(screen.getByText(/email is required/i)).toBeInTheDocument(); expect(screen.getByText(/password is required/i)).toBeInTheDocument(); }); });

Key principles: query by role and label (what users see), not by test IDs or CSS selectors. Use userEvent over fireEvent — it simulates real browser behavior (focus, type, click). Mock APIs with MSW at the network level, not by mocking fetch.

Result
  • Tests verify what users see and do, not implementation details
  • API mocking at the network level catches request/response issues
  • Refactoring internals doesn't break tests — only behavior changes do

#2 E2E Tests: Critical Paths Only

End-to-end tests run in a real browser, hit real (or staged) APIs, and verify complete user journeys. They're slow and flaky, so use them sparingly — only for flows where a bug means lost revenue or broken trust.

Playwright for Reliable E2E

e2e/checkout.spec.ts
import { test, expect } from '@playwright/test'; test.describe('Checkout Flow', () => { test('completes purchase with valid payment', async ({ page }) => { // Navigate and add product await page.goto('/products/premium-plan'); await page.getByRole('button', { name: 'Add to Cart' }).click(); // Verify cart await page.getByRole('link', { name: 'Cart (1)' }).click(); await expect(page.getByText('Premium Plan')).toBeVisible(); // Proceed to checkout await page.getByRole('button', { name: 'Checkout' }).click(); // Fill payment form await page.getByLabel('Card number').fill('4242424242424242'); await page.getByLabel('Expiry').fill('12/28'); await page.getByLabel('CVC').fill('123'); // Submit and verify success await page.getByRole('button', { name: 'Pay Now' }).click(); await expect( page.getByRole('heading', { name: 'Order Confirmed' }) ).toBeVisible({ timeout: 10000 }); await expect(page.getByText('Order #')).toBeVisible(); }); test('shows error for declined payment', async ({ page }) => { // ... setup await page.getByLabel('Card number').fill('4000000000000002'); await page.getByRole('button', { name: 'Pay Now' }).click(); await expect( page.getByRole('alert') ).toContainText('payment was declined'); }); });
  • Test only critical paths: signup, login, checkout, core CRUD operations
  • Use role-based selectors for resilience across design changes
  • Set generous timeouts for network-dependent assertions
  • Run E2E tests in CI against a staging environment, not production
Result
  • Critical revenue-impacting paths are verified end-to-end
  • Tests run in real browsers catching rendering and interaction issues
  • Flakiness is minimized by testing only stable, critical flows

#3 Unit Tests: Only for Complex Logic

Unit tests have the highest speed but the lowest confidence per test. Reserve them for pure functions with complex logic — calculations, data transformations, state machines, and validation rules.

Test Complex Business Logic in Isolation

lib/__tests__/pricing.test.ts
import { describe, expect, it } from 'vitest'; import { calculateDiscount, formatPrice } from '../pricing'; describe('calculateDiscount', () => { it('applies percentage discount', () => { expect(calculateDiscount(100, { type: 'percentage', value: 20 })) .toBe(80); }); it('applies fixed discount', () => { expect(calculateDiscount(100, { type: 'fixed', value: 15 })) .toBe(85); }); it('never returns negative price', () => { expect(calculateDiscount(10, { type: 'fixed', value: 50 })) .toBe(0); }); it('caps percentage at 100%', () => { expect(calculateDiscount(100, { type: 'percentage', value: 150 })) .toBe(0); }); }); describe('formatPrice', () => { it('formats USD with 2 decimals', () => { expect(formatPrice(1999, 'USD')).toBe('$19.99'); }); it('formats EUR with comma decimal', () => { expect(formatPrice(1999, 'EUR', 'de-DE')).toBe('19,99\u00a0€'); }); it('handles zero', () => { expect(formatPrice(0, 'USD')).toBe('$0.00'); }); });
Result
  • Complex calculations have exhaustive edge case coverage
  • Pure functions are fast to test — no DOM, no mocks, no setup
  • Regression protection for business-critical logic

The Testing Configuration That Works

A practical configuration for a Next.js project:

vitest.config.ts
import react from '@vitejs/plugin-react'; import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true, setupFiles: ['./tests/setup.ts'], include: ['**/*.test.{ts,tsx}'], coverage: { provider: 'v8', // Don't chase 100% — focus on critical paths thresholds: { statements: 70, branches: 70 }, exclude: ['**/*.test.*', 'e2e/**', '**/*.d.ts'], }, }, });

Key Takeaways

  • 1Integration tests (Testing Library + MSW) provide the highest ROI — test user behavior, not implementation
  • 2E2E tests (Playwright) are for critical paths only — signup, checkout, core flows
  • 3Unit tests are for pure, complex logic — calculations, transformations, state machines
  • 4Query by role and label, not by test IDs or CSS selectors
  • 5Mock APIs at the network level with MSW — not by mocking fetch or axios
  • 6Don't chase 100% coverage — chase confidence in your critical paths