Testing Strategies for Modern Web Apps: What Actually Works
A practical guide to a testing strategy that catches real bugs: unit, integration and end-to-end with Vitest, Testing Library and Playwright.

Tests Are a Design Tool, Not a Checkbox
Most teams write tests primarily to avoid regressions. That's valid — but it misses the deeper value. A well-designed test suite forces you to write modular, decoupled code. It makes refactoring safe. It documents intended behavior more honestly than comments ever do.
The trouble is that poor testing strategies produce test suites that are slow, brittle, and provide false confidence. This guide is about building the right coverage — the kind that catches real bugs without fighting you at every merge.
The Testing Trophy, Not the Pyramid
You've likely seen the testing pyramid: lots of unit tests at the base, fewer integration tests in the middle, minimal e2e tests at the top. The pyramid was designed for a different era.
For modern web apps — especially React + API-driven architectures — the testing trophy describes reality better:
- Static analysis (TypeScript, ESLint): catches entire classes of bugs for free
- Unit tests: pure logic, utilities, isolated functions
- Integration tests: the bulk of your suite — components with real behavior, API handlers with a real database
- End-to-end tests: the critical paths a real user actually walks
Most teams under-invest in integration tests and over-invest in narrow unit tests that survive refactors but miss systemic failures.
Setting Up Vitest in a Next.js Project
Vitest is Jest-compatible, ESM-native, and dramatically faster. It's the right default for modern TypeScript projects.
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
exclude: ["node_modules/", "src/test/", "**/*.d.ts"],
},
},
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
});// src/test/setup.ts
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
afterEach(() => {
cleanup();
});
// Mock next/navigation globally — avoids router errors in component tests
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));Unit Tests: Narrow, Fast, and Deterministic
Unit tests shine on pure logic: data transformations, validation rules, utility functions. The goal is complete isolation — no network, no database, no DOM.
// utils/format.ts
export function formatCurrency(amount: number, locale = "en-US", currency = "USD"): string {
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount);
}
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + "...";
}// utils/format.test.ts
import { describe, it, expect } from "vitest";
import { formatCurrency, truncate } from "./format";
describe("formatCurrency", () => {
it("formats USD by default", () => {
expect(formatCurrency(1234.5)).toBe("$1,234.50");
});
it("handles zero", () => {
expect(formatCurrency(0)).toBe("$0.00");
});
it("supports other currencies", () => {
expect(formatCurrency(100, "de-DE", "EUR")).toBe("100,00 €");
});
});
describe("truncate", () => {
it("returns string unchanged when within limit", () => {
expect(truncate("hello", 10)).toBe("hello");
});
it("truncates and appends ellipsis", () => {
expect(truncate("hello world", 8)).toBe("hello...");
});
it("handles exact boundary", () => {
expect(truncate("hello", 5)).toBe("hello");
});
});Keep unit tests DAMP (Descriptive And Meaningful Phrases), not overly DRY — a failing unit test should tell you exactly what broke without requiring you to trace through abstractions.
Integration Tests: Where Confidence Lives
Integration tests render components with their real dependencies, fire real events, and assert on real output. This is where Testing Library is indispensable.
// components/LoginForm.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { LoginForm } from "./LoginForm";
// Mock only the network boundary — everything else is real
vi.mock("@/services/auth", () => ({
signIn: vi.fn(),
}));
import { signIn } from "@/services/auth";
describe("LoginForm", () => {
const user = userEvent.setup();
beforeEach(() => {
vi.clearAllMocks();
});
it("renders email and password fields", () => {
render(<LoginForm />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
it("disables submit while request is in flight", async () => {
vi.mocked(signIn).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 500))
);
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), "user@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(screen.getByRole("button", { name: /signing in/i })).toBeDisabled();
});
it("shows a field-level error for invalid email", async () => {
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), "not-an-email");
await user.tab(); // trigger blur validation
expect(await screen.findByText(/enter a valid email/i)).toBeInTheDocument();
});
it("redirects to dashboard on success", async () => {
const mockPush = vi.fn();
vi.mocked(signIn).mockResolvedValueOnce({ success: true });
// Provide router mock at the test level for more control
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), "user@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/dashboard");
});
});
});Key principle: query by role and label, not by CSS class or test ID. Tests that interact the way users do survive UI refactors; tests that query .btn-primary do not.
Testing API Route Handlers
Next.js App Router handlers are plain async functions — they're easy to test without spinning up a server.
// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const post = await db.post.findUnique({ where: { id: params.id } });
if (!post) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(post);
}// app/api/posts/[id]/route.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "./route";
vi.mock("@/lib/db", () => ({
db: {
post: {
findUnique: vi.fn(),
},
},
}));
import { db } from "@/lib/db";
describe("GET /api/posts/:id", () => {
beforeEach(() => vi.clearAllMocks());
it("returns the post when found", async () => {
const mockPost = { id: "1", title: "Hello", content: "World" };
vi.mocked(db.post.findUnique).mockResolvedValueOnce(mockPost);
const request = new Request("http://localhost/api/posts/1");
const response = await GET(request, { params: { id: "1" } });
const body = await response.json();
expect(response.status).toBe(200);
expect(body).toEqual(mockPost);
});
it("returns 404 when post does not exist", async () => {
vi.mocked(db.post.findUnique).mockResolvedValueOnce(null);
const request = new Request("http://localhost/api/posts/99");
const response = await GET(request, { params: { id: "99" } });
expect(response.status).toBe(404);
});
});End-to-End Tests with Playwright
E2E tests run against a real browser with a real (or seeded) database. They're slow and should be used sparingly — reserved for the user flows that, if broken, would cause the most damage.
// tests/e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Authentication", () => {
test.beforeEach(async ({ page }) => {
// Seed a test user via API before each test
await fetch("http://localhost:3000/api/test/seed", { method: "POST" });
await page.goto("/login");
});
test("user can log in with valid credentials", async ({ page }) => {
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Password").fill("TestPassword123!");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/dashboard");
await expect(page.getByRole("heading", { name: /welcome/i })).toBeVisible();
});
test("shows error for invalid credentials", async ({ page }) => {
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Password").fill("wrong-password");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByText(/invalid credentials/i)).toBeVisible();
await expect(page).toHaveURL("/login");
});
test("redirects unauthenticated users to login", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login/);
});
});// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "Mobile Safari", use: { ...devices["iPhone 14"] } },
],
webServer: {
command: "bun run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});Structuring Tests for Maintainability
A test file that's impossible to read is almost as bad as no tests. A few conventions that help:
// ✅ Structure: Arrange → Act → Assert, every time
it("calculates total with discount applied", () => {
// Arrange
const cart = [
{ id: "1", price: 100, quantity: 2 },
{ id: "2", price: 50, quantity: 1 },
];
const discount = 0.1; // 10%
// Act
const total = calculateCartTotal(cart, discount);
// Assert
expect(total).toBe(225); // (100*2 + 50) * 0.9
});
// ✅ Test behavior, not implementation
// ❌ Bad: tests internal state
it("sets isLoading to true when fetching", () => {
const { result } = renderHook(() => useData());
expect(result.current.isLoading).toBe(true); // brittle — tied to internal naming
});
// ✅ Good: tests observable behavior
it("shows a loading indicator while data is fetching", async () => {
render(<DataTable />);
expect(screen.getByRole("status", { name: /loading/i })).toBeInTheDocument();
await waitForElementToBeRemoved(() => screen.queryByRole("status"));
});CI Integration
Tests only add value if they run automatically. A minimal CI pipeline for a Next.js project:
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run typecheck
- run: bun run lint
- run: bun run test --coverage
e2e:
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bunx playwright install --with-deps chromium
- run: bun run build
- run: bunx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/E2E tests run after unit tests pass — no point testing the full browser flow if the build is already broken.
What Good Coverage Actually Means
Coverage percentages are a vanity metric. 90% coverage with tests that only assert expect(component).toBeTruthy() is worthless.
A healthy test strategy asks better questions:
- Can I delete this feature's code and have a test fail? If not, the test isn't testing the feature.
- Does a test fail when I introduce a real bug? Run mutation testing with Stryker periodically to check.
- How long does the full suite take? Under 30 seconds for unit/integration means developers will actually run it.
- Are falures actionable? A failing test should name the broken behavior, not just say "snapshot mismatch".
A test suite that developers trust — and run locally before every commit — catches bugs earlier, speeds up code review, and makes refactoring far less terrifying. That's the real return on the investment.


