Testing Strategies for Modern Web Apps: Vitest, Playwright, and Beyond
Author
ZTABS Team
Date Published
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/
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.
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.
12 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.
8 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.