Testing Strategies for Modern Web Apps: Vitest, Playwright, and Beyond
Author
Date Published
TL;DR: A practical guide to testing modern web applications. Covers unit, integration, and E2E testing with Vitest, Playwright, and Testing Library. Includes the test pyramid, coverage strategies, and CI integration.
Shipping without tests is shipping with your eyes closed. You might get away with it on small projects, but as codebases grow, untested code becomes the single biggest source of regressions, deployment fear, and increased development costs.
The modern testing ecosystem has made it faster and easier than ever to write meaningful tests. Vitest runs in milliseconds, Playwright tests real browsers reliably, and Testing Library encourages tests that reflect how users actually interact with your app. This guide covers when to use each, how to structure your test suite, and how to get the most value from the least test code.
The Test Pyramid (and Why It Is a Trophy Now)
The classic test pyramid puts unit tests at the base, integration tests in the middle, and E2E tests at the top. The idea: write many small, fast tests and few slow, broad tests.
In practice, the modern web testing community has shifted toward a test trophy shape, where integration tests get the most investment.
| Layer | Tool | Speed | Confidence | Quantity | |-------|------|-------|------------|----------| | Static analysis | TypeScript, ESLint | Instant | Medium | Automatic | | Unit tests | Vitest | Milliseconds | Medium | Many | | Integration tests | Vitest + Testing Library | Seconds | High | Most investment | | E2E tests | Playwright | Seconds–Minutes | Highest | Few, critical paths |
Integration tests — rendering a component with its dependencies and testing user interactions — catch more real bugs per test than isolated unit tests. They are the sweet spot of confidence-to-cost ratio.
Unit Testing with Vitest
Vitest is the standard unit test runner for modern JavaScript and TypeScript projects. It is API-compatible with Jest but dramatically faster, thanks to native ESM support and Vite-powered transforms.
Setup
pnpm add -D vitest @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: [
"node_modules/",
"src/test/",
"**/*.d.ts",
"**/*.config.*",
],
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
Writing Unit Tests
Unit tests isolate a single function or module. They are fast, focused, and should not depend on external services.
// src/utils/format.ts
export function formatCurrency(
amount: number,
currency = "USD",
locale = "en-US"
): string {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: 2,
}).format(amount);
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.trim();
}
// src/utils/format.test.ts
import { describe, it, expect } from "vitest";
import { formatCurrency, slugify } from "./format";
describe("formatCurrency", () => {
it("formats USD by default", () => {
expect(formatCurrency(1234.56)).toBe("$1,234.56");
});
it("formats EUR", () => {
expect(formatCurrency(1234.56, "EUR", "de-DE")).toBe("1.234,56\u00A0€");
});
it("handles zero", () => {
expect(formatCurrency(0)).toBe("$0.00");
});
it("handles negative values", () => {
expect(formatCurrency(-50)).toBe("-$50.00");
});
});
describe("slugify", () => {
it("converts to lowercase kebab-case", () => {
expect(slugify("Hello World")).toBe("hello-world");
});
it("removes special characters", () => {
expect(slugify("React & Next.js: A Guide!")).toBe("react--nextjs-a-guide");
});
it("collapses multiple dashes", () => {
expect(slugify("one---two")).toBe("one-two");
});
});
Mocking Dependencies
// src/services/user-service.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getUserProfile } from "./user-service";
const mockDb = {
user: {
findUnique: vi.fn(),
},
};
vi.mock("@/lib/db", () => ({
db: mockDb,
}));
describe("getUserProfile", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns user profile when user exists", async () => {
mockDb.user.findUnique.mockResolvedValue({
id: "1",
name: "Alice",
email: "alice@example.com",
});
const profile = await getUserProfile("1");
expect(profile).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
expect(mockDb.user.findUnique).toHaveBeenCalledWith({
where: { id: "1" },
});
});
it("returns null when user does not exist", async () => {
mockDb.user.findUnique.mockResolvedValue(null);
const profile = await getUserProfile("999");
expect(profile).toBeNull();
});
});
Integration Testing with Testing Library
Testing Library focuses on testing components the way users interact with them — by finding elements through accessible roles, labels, and text, not by querying CSS classes or component internals.
Setup
pnpm add -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
Testing React Components
// src/components/login-form.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface LoginFormProps {
onSubmit: (email: string, password: string) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setIsLoading(true);
const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string;
const password = formData.get("password") as string;
try {
await onSubmit(email, password);
} catch (err) {
setError((err as Error).message);
} finally {
setIsLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" required />
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input id="password" name="password" type="password" required />
</div>
{error && <p role="alert" className="text-red-500">{error}</p>}
<Button type="submit" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in"}
</Button>
</form>
);
}
// src/components/login-form.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./login-form";
describe("LoginForm", () => {
it("submits email and password", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText("Email"), "alice@example.com");
await user.type(screen.getByLabelText("Password"), "password123");
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(handleSubmit).toHaveBeenCalledWith(
"alice@example.com",
"password123"
);
});
it("displays error message on failure", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn().mockRejectedValue(new Error("Invalid credentials"));
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText("Email"), "alice@example.com");
await user.type(screen.getByLabelText("Password"), "wrong");
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Invalid credentials"
);
});
it("disables button while loading", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn(
() => new Promise((resolve) => setTimeout(resolve, 1000))
);
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText("Email"), "alice@example.com");
await user.type(screen.getByLabelText("Password"), "password123");
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(
screen.getByRole("button", { name: "Signing in..." })
).toBeDisabled();
});
});
Testing Library Query Priority
Use queries in this order for accessible, resilient tests:
| Priority | Query | Use Case |
|----------|-------|----------|
| 1 | getByRole | Buttons, links, headings, checkboxes |
| 2 | getByLabelText | Form inputs |
| 3 | getByPlaceholderText | Inputs without labels |
| 4 | getByText | Non-interactive text content |
| 5 | getByTestId | Last resort when no semantic query works |
End-to-End Testing with Playwright
Playwright tests your application in a real browser — navigating pages, clicking buttons, filling forms, and asserting on the visible result. These tests catch bugs that unit and integration tests cannot: broken routing, API failures, third-party script conflicts, and CSS layout issues.
Setup
pnpm add -D @playwright/test
npx playwright install
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile", use: { ...devices["iPhone 14"] } },
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
Writing E2E Tests
// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Authentication", () => {
test("user can sign in and access dashboard", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("alice@example.com");
await page.getByLabel("Password").fill("password123");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/dashboard");
await expect(
page.getByRole("heading", { name: "Dashboard" })
).toBeVisible();
await expect(page.getByText("Welcome, Alice")).toBeVisible();
});
test("shows error for invalid credentials", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("alice@example.com");
await page.getByLabel("Password").fill("wrongpassword");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("alert")).toContainText(
"Invalid credentials"
);
await expect(page).toHaveURL("/login");
});
});
Page Object Pattern
For larger test suites, page objects reduce duplication and make tests more readable.
// e2e/pages/login-page.ts
import { type Page, type Locator, expect } from "@playwright/test";
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Password");
this.submitButton = page.getByRole("button", { name: "Sign in" });
this.errorMessage = page.getByRole("alert");
}
async goto() {
await this.page.goto("/login");
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
// e2e/auth-with-page-object.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "./pages/login-page";
test("user can sign in", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("alice@example.com", "password123");
await expect(page).toHaveURL("/dashboard");
});
Coverage Strategies
Code coverage is a useful signal, not a goal. Chasing 100% coverage leads to brittle tests that test implementation details. Instead, target meaningful coverage.
Recommended Thresholds
| Metric | Target | Rationale | |--------|--------|-----------| | Line coverage | 70–80% | Catches most regressions | | Branch coverage | 65–75% | Ensures error paths are tested | | Critical path coverage | 100% | Auth, payments, data mutations |
Generating Coverage Reports
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:all": "vitest run --coverage && playwright test"
}
}
# Run with coverage
pnpm test:coverage
# View HTML report
open coverage/index.html
CI Integration
Tests that do not run in CI are tests that will be ignored. Automate everything.
# .github/workflows/test.yml
name: Tests
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
unit-and-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:coverage
- uses: codecov/codecov-action@v4
with:
files: coverage/coverage-final.json
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: npx playwright install --with-deps
- run: pnpm test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
Flake Budgets and Enforcement
Every test suite drifts toward flakiness without active enforcement. A flake budget is the team's explicit tolerance, enforced in CI, not a wish.
Setting the Budget
| Suite size | Flake budget | Enforcement threshold | |------------|--------------|------------------------| | < 500 tests | 0 flakes tolerated | One flake triggers a ticket | | 500-5,000 tests | < 0.5% flake rate | CI fails the build above threshold | | 5,000-50,000 tests | < 1% flake rate | Weekly report; freeze new features above 2% | | > 50,000 tests | < 1% rate; quarantine lane for known flakes | Dedicated "test reliability" rotation |
Enforcement Mechanics
- Track flakes automatically. Buildkite Test Analytics, CircleCI Insights, and GitHub Actions with a tool like Trunk Flaky Tests detect tests that pass on retry.
- Quarantine, do not skip. Move a flaky test to a
@slowor@flakytag that runs on a separate cadence rather than deleting it. Set an explicit 2-week fix deadline. - Block merges on flake budget. If the trailing 7-day flake rate exceeds the budget, block new PRs from merging until the top offender is fixed.
- Measure developer time. A 5% flake rate on a 20-minute CI costs each engineer roughly 2-4 hours per week in re-runs. Put dollars on it; the business case for fixing flakes writes itself.
Failure Modes Specific to Each Layer
Each test layer has a signature failure mode that wastes the most time when ignored:
| Layer | Signature failure | Fix pattern |
|-------|-------------------|-------------|
| Unit | Over-mocked tests that pass even when the real code is broken | Integration-test the boundaries; unit-test pure functions |
| Integration (Testing Library) | act() warnings ignored; async state settles inconsistently | Use findBy* queries everywhere for async; wrap state updates in await |
| E2E (Playwright) | Flaky selectors (CSS classes that change) and arbitrary waitForTimeout | Use role/label queries; replace timeouts with expect(locator).toBeVisible() |
| Visual regression | Diffs triggered by font rendering or sub-pixel shifts | Use maskColor or ignore regions; Playwright's toHaveScreenshot with maxDiffPixels |
| Contract (Pact) | Producer and consumer contracts drift silently | Publish pacts on every producer deploy; fail consumer build on incompatibility |
What to Test (and What Not To)
| Test This | Skip This | |-----------|-----------| | User-facing behavior | Implementation details | | Error states and edge cases | Library internals | | Critical business logic | Trivial getters/setters | | API contract (request/response) | CSS styling (use visual tests) | | Access control and permissions | Third-party code | | Data transformations | Console.log output |
Getting Started
A well-structured test suite is the foundation of confident, fast-moving web development teams. Start with integration tests for your critical user flows, add unit tests for complex business logic, and use E2E tests to protect your most important paths end-to-end.
If you need help establishing a testing strategy for your application or improving an existing test suite, talk to our team. We build tested, maintainable applications using Vitest, Playwright, and Testing Library — with CI pipelines that catch bugs before they reach production.
Test what matters. Ship with confidence.
Frequently Asked Questions
What's the right test coverage target in 2026?
70-80% line coverage is the pragmatic target for most applications. Past 85%, marginal tests become low-value and flaky. Below 60%, regressions slip through frequently. Critical paths (auth, payments, data mutations) deserve 90%+ regardless of overall percentage — coverage should be weighted by risk.
How should I split between unit, integration, and E2E tests?
The classic pyramid — 70% unit, 20% integration, 10% E2E — still holds. E2E tests are 10-20x slower and flakier than unit tests; treat them as scarce resources reserved for business-critical user journeys. Teams with inverted pyramids (lots of E2E, few units) spend 30-50% of CI time fighting flakes.
What's the cost of a flaky test suite?
A test suite that fails 5% of the time on re-runs costs 2-4 hours of developer time per week per engineer and erodes trust — engineers start ignoring real failures assuming flake. Set a budget: if flakiness exceeds 1%, stop new work until you fix it. Flake is debt that compounds fast.
When is contract testing worth the overhead?
Contract testing (Pact, Spring Cloud Contract) pays off at 4+ services with multiple teams owning different ones. Below that, integration tests cover the same ground with less tooling. The win is catching cross-service regressions before merging, which saves 2-3 days of shared-environment debugging per incident.
What testing stack has the strongest Next.js App Router support in 2026?
Vitest for unit and component logic, Playwright for end-to-end, and React Testing Library for component rendering remains the Next.js-native default and is what the framework team tests against. Jest still works but needs extra configuration for server components and Edge runtime, and is losing upstream focus. Component tests for server components should use Playwright Component Testing or render under Node integration; do not try to mount them in a DOM simulator because server component boundaries rely on the actual React 19 render pipeline to resolve properly.
Should we replace Cypress with Playwright?
Yes for most new projects — Playwright runs 2-3x faster, supports parallel workers out of the box, and ships first-party TypeScript types, Safari/WebKit testing, and mobile viewports without plugins. Keep Cypress only if your team has a large existing suite with custom plugins that would be expensive to port; Playwright's migration codemods cover roughly 60-70% of Cypress patterns automatically, and the remaining conversions are usually straightforward in a focused one-to-two-week sprint.
Explore Related Solutions
Need Help Building Your Project?
From web apps and mobile apps to AI solutions and SaaS platforms — we ship production software for 300+ clients.
Related Articles
API Security Best Practices: A Developer Guide for 2026
A practical guide to securing APIs in production. Covers OAuth 2.0, JWT handling, rate limiting, input validation, CORS configuration, API key management, and security headers with real code examples.
13 min readDatabase Scaling Strategies: From Single Server to Global Scale
A practical guide to database scaling strategies for growing applications. Covers vertical and horizontal scaling, read replicas, sharding, connection pooling, caching layers, and partition strategies with real SQL examples.
10 min readGraphQL vs REST API: When to Use Each in 2026
A practical comparison of GraphQL and REST for modern applications. Covers over-fetching, the N+1 problem, schema stitching, performance tradeoffs, and clear guidance on when each approach wins.