Testing Strategies for Modern Web Apps
Testing Strategies for Modern Web Apps
A good testing strategy balances confidence with speed. Too many E2E tests and your suite takes forever. Too few and bugs slip through. The key is putting the right tests at the right level.
The Testing Trophy
The modern approach (popularized by Kent C. Dodds) prioritizes integration tests over unit tests:
Unit Tests with Vitest
Test pure logic — utilities, helpers, business rules:
// utils/price.test.ts
import { describe, it, expect } from "vitest";
import { calculateDiscount } from "./price";
describe("calculateDiscount", () => {
it("applies percentage discount", () => {
expect(calculateDiscount(100, { type: "percent", value: 20 })).toBe(80);
});
it("never goes below zero", () => {
expect(calculateDiscount(10, { type: "fixed", value: 50 })).toBe(0);
});
it("rounds to two decimal places", () => {
expect(calculateDiscount(10, { type: "percent", value: 33 })).toBe(6.70);
});
});
Integration Tests with Testing Library
Test components with their dependencies — APIs, state, routing:
// components/UserProfile.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { UserProfile } from "./UserProfile";
const server = setupServer(
http.get("/api/users/1", () =>
HttpResponse.json({ id: "1", name: "Alice", email: "[email protected]" })
)
);
beforeAll(() => server.listen());
afterAll(() => server.close());
it("displays user information", async () => {
render( );
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("[email protected]")).toBeInTheDocument();
});
});
E2E Tests with Playwright
Test critical user journeys through the real application:
// e2e/checkout.spec.ts
import { test, expect } from "@playwright/test";
test("user can complete checkout", async ({ page }) => {
await page.goto("/products");
await page.click('[data-testid="product-1"] button');
await page.click('[data-testid="cart-icon"]');
await expect(page.getByText("1 item in cart")).toBeVisible();
await page.click("text=Checkout");
await page.fill('[name="email"]', "[email protected]");
await page.click("text=Place Order");
await expect(page.getByText("Order confirmed")).toBeVisible();
});
Visual Regression Testing
Catch unintended UI changes with screenshot comparisons:
// e2e/visual.spec.ts
import { test, expect } from "@playwright/test";
test("homepage matches snapshot", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("homepage.png", {
maxDiffPixels: 100,
});
});
What to Test at Each Level
| Level | What to Test | Tools |
|-------|-------------|-------|
| Static | Types, lint rules | TypeScript, ESLint |
| Unit | Pure functions, utils | Vitest |
| Integration | Components, hooks, API handlers | Vitest + Testing Library + MSW |
| E2E | Critical user flows | Playwright |
| Visual | UI appearance | Playwright screenshots |
Test Organization
src/
utils/
price.ts
price.test.ts # Unit tests next to source
components/
UserProfile.tsx
UserProfile.test.tsx # Integration tests next to source
e2e/
checkout.spec.ts # E2E tests in dedicated folder
visual.spec.ts
Conclusion
Write mostly integration tests, supplement with unit tests for complex logic, and add E2E tests for critical paths. Use static analysis as your first line of defense. A fast, reliable test suite that developers trust is worth more than a comprehensive but flaky one.