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
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.
- 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
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
- 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
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');
});
});- 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:
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