Testing Strategies for Modern Web Applications
Unit tests, integration tests, E2E tests — how much of each, what to test, and the testing pyramid in practice.
Testing is the engineering discipline most teams get wrong — not because they don't test, but because they test the wrong things in the wrong ways. A test suite with 90% coverage that tests implementation details is worse than 50% coverage testing behavior. After building testing strategies across 50+ projects, we've converged on patterns that balance confidence, speed, and maintainability.
The Testing Trophy (Not Pyramid)
The traditional testing pyramid (many unit tests, some integration, few E2E) was designed for backend services. For modern web applications, the testing trophy is more effective: a large middle band of integration tests, supported by static analysis at the base and a thin layer of E2E tests at the top.
- Static Analysis (base): TypeScript, ESLint, Prettier. Catches typos, type errors, and code style issues with zero runtime cost. Free confidence.
- Unit Tests: Pure functions, utilities, business logic. Fast, isolated, no DOM. Test the math, not the rendering.
- Integration Tests (widest): Components rendered with Testing Library, testing user-visible behavior. Click buttons, fill forms, verify outcomes. This is where most of your tests should live.
- E2E Tests (top): Critical user flows (signup, checkout, payment). Playwright against a real browser. Slow but catches real integration issues.
Write Tests That Test Behavior, Not Implementation
// ✗ Bad: tests implementation details (state, internal methods)
test('sets isLoading to true when submitted', () => {
const { result } = renderHook(() => useLoginForm());
act(() => result.current.handleSubmit());
expect(result.current.isLoading).toBe(true);
});
// ✓ Good: tests user-visible behavior
test('shows loading spinner and disables button during login', async () => {
render(<LoginForm />);
await userEvent.type(screen.getByLabelText('Email'), 'user@test.com');
await userEvent.type(screen.getByLabelText('Password'), 'password123');
await userEvent.click(screen.getByRole('button', { name: 'Sign In' }));
expect(screen.getByRole('button', { name: 'Signing in...' })).toBeDisabled();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});E2E Testing with Playwright
E2E tests are expensive (slow, flaky, require infrastructure), so be selective. Test the critical paths that, if broken, would directly impact revenue or user trust: authentication flow, payment/checkout, core feature workflows. We typically have 10-20 E2E tests per application, not hundreds.
import { test, expect } from '@playwright/test';
test('complete checkout flow', async ({ page }) => {
await page.goto('/products');
// Add item to cart
await page.click('[data-testid="product-card"]:first-child button');
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
// Navigate to checkout
await page.click('[data-testid="cart-icon"]');
await page.click('text=Proceed to Checkout');
// Fill shipping info
await page.fill('[name="address"]', '123 Test St');
await page.fill('[name="city"]', 'San Francisco');
await page.click('text=Continue to Payment');
// Complete payment
await page.fill('[name="cardNumber"]', '4242424242424242');
await page.click('text=Place Order');
// Verify success
await expect(page.locator('h1')).toHaveText('Order Confirmed');
});Use data-testid attributes for E2E test selectors instead of CSS classes or text content. Test IDs are explicitly for testing, won't break when styles change, and communicate intent to other developers.
The goal of testing isn't 100% coverage — it's confidence to ship. A thoughtful test suite that covers critical user paths and business logic gives you the confidence to deploy on Friday afternoon without anxiety.
Marcus Rodriguez
DevOps Engineering Lead