Back to Insights
TestingVitestPlaywright

A Frontend Testing Strategy That Actually Works

10/202510 min read
Share

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