Comprehensive Testing Strategies for Modern Web Applications
Testing is not optional in modern software development—it's a fundamental practice that ensures code reliability, prevents regressions, and enables confident refactoring. Let's explore a comprehensive testing strategy for full-stack applications.
The Testing Pyramid
The testing pyramid, introduced by Mike Cohn, provides a strategic framework for test distribution:
/\
/ \ E2E Tests (10%)
/____\
/ \ Integration Tests (20%)
/____\
/ \ Unit Tests (70%)
/____\
Principles:
- 70% Unit Tests - Fast, isolated, focused
- 20% Integration Tests - Component interactions
- 10% E2E Tests - Complete user flows
- Cost increases upward - Execution time and maintenance
- Confidence increases upward - Real-world scenarios
Unit Testing with Vitest
Vitest is a modern, blazing-fast test runner built on Vite.
Setup
bash
npm install -D vitest @vitest/ui @testing-library/react @testing-library/jest-dom jsdom
typescript
// 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: "./test/setup.ts",
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["node_modules/", "test/", "/*.config.ts", "/*.d.ts"],
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
typescript
// test/setup.ts
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
Testing Pure Functions
typescript
// lib/utils.ts
export function calculateDiscount(price: number, percentage: number): number {
if (price < 0 || percentage < 0 || percentage > 100) {
throw new Error("Invalid input");
}
return price * (1 - percentage / 100);
}
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}
export function debounce any>(
func: T,
delay: number
): (...args: Parameters) => void {
let timeoutId: NodeJS.Timeout;
return function (...args: Parameters) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
}
// lib/utils.test.ts
import { describe, it, expect, vi } from "vitest";
import { calculateDiscount, formatCurrency, debounce } from "./utils";
describe("calculateDiscount", () => {
it("should calculate correct discount", () => {
expect(calculateDiscount(100, 10)).toBe(90);
expect(calculateDiscount(200, 25)).toBe(150);
expect(calculateDiscount(50, 50)).toBe(25);
});
it("should handle zero discount", () => {
expect(calculateDiscount(100, 0)).toBe(100);
});
it("should throw on negative price", () => {
expect(() => calculateDiscount(-100, 10)).toThrow("Invalid input");
});
it("should throw on invalid percentage", () => {
expect(() => calculateDiscount(100, -10)).toThrow("Invalid input");
expect(() => calculateDiscount(100, 110)).toThrow("Invalid input");
});
});
describe("formatCurrency", () => {
it("should format USD by default", () => {
expect(formatCurrency(1234.56)).toBe("$1,234.56");
});
it("should format other currencies", () => {
expect(formatCurrency(1234.56, "EUR")).toContain("1,234.56");
});
it("should handle zero", () => {
expect(formatCurrency(0)).toBe("$0.00");
});
});
describe("debounce", () => {
it("should delay function execution", () => {
vi.useFakeTimers();
const func = vi.fn();
const debouncedFunc = debounce(func, 300);
debouncedFunc();
expect(func).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(func).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it("should cancel previous calls", () => {
vi.useFakeTimers();
const func = vi.fn();
const debouncedFunc = debounce(func, 300);
debouncedFunc();
debouncedFunc();
debouncedFunc();
vi.advanceTimersByTime(300);
expect(func).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});
Testing React Components
typescript
// components/Button.tsx
import { ButtonHTMLAttributes, ReactNode } from "react";
interface ButtonProps extends ButtonHTMLAttributes {
variant?: "primary" | "secondary" | "danger";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
children: ReactNode;
}
export function Button({
variant = "primary",
size = "md",
isLoading = false,
disabled,
children,
className = "",
...props
}: ButtonProps) {
const baseStyles = "rounded font-medium transition-colors";
const variantStyles = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
danger: "bg-red-600 text-white hover:bg-red-700",
};
const sizeStyles = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg",
};
return (
);
}
// components/Button.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./Button";
describe("Button", () => {
it("should render with default props", () => {
render();
const button = screen.getByRole("button", { name: /click me/i });
expect(button).toBeInTheDocument();
});
it("should apply variant styles", () => {
const { rerender } = render();
expect(screen.getByRole("button")).toHaveClass("bg-blue-600");
rerender();
expect(screen.getByRole("button")).toHaveClass("bg-red-600");
});
it("should apply size styles", () => {
render();
expect(screen.getByRole("button")).toHaveClass("px-6", "py-3");
});
it("should handle loading state", () => {
render();
expect(screen.getByRole("button")).toHaveTextContent("Loading...");
expect(screen.getByRole("button")).toBeDisabled();
});
it("should call onClick handler", async () => {
const handleClick = vi.fn();
render();
const user = userEvent.setup();
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("should not call onClick when disabled", async () => {
const handleClick = vi.fn();
render(
);
const user = userEvent.setup();
await user.click(screen.getByRole("button"));
expect(handleClick).not.toHaveBeenCalled();
});
});
Testing Custom Hooks
typescript
// hooks/use-local-storage.ts
import { useState, useEffect } from "react";
export function useLocalStorage(
key: string,
initialValue: T
): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState(() => {
if (typeof window === "undefined") {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue = (value: T) => {
try {
setStoredValue(value);
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(value));
}
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// hooks/use-local-storage.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useLocalStorage } from "./use-local-storage";
describe("useLocalStorage", () => {
beforeEach(() => {
localStorage.clear();
});
it("should return initial value", () => {
const { result } = renderHook(() => useLocalStorage("test", "initial"));
expect(result.current[0]).toBe("initial");
});
it("should update localStorage on setValue", () => {
const { result } = renderHook(() => useLocalStorage("test", "initial"));
act(() => {
result.current1;
});
expect(result.current[0]).toBe("updated");
expect(localStorage.getItem("test")).toBe(JSON.stringify("updated"));
});
it("should retrieve existing value from localStorage", () => {
localStorage.setItem("test", JSON.stringify("existing"));
const { result } = renderHook(() => useLocalStorage("test", "initial"));
expect(result.current[0]).toBe("existing");
});
it("should handle complex objects", () => {
const { result } = renderHook(() =>
useLocalStorage("user", { name: "John", age: 30 })
);
act(() => {
result.current1;
});
expect(result.current[0]).toEqual({ name: "Jane", age: 25 });
});
});
Integration Testing
Test component interactions and data flow.
typescript
// components/TodoApp.tsx
"use client";
import { useState } from "react";
interface Todo {
id: string;
title: string;
completed: boolean;
}
export function TodoApp() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState("");
const addTodo = () => {
if (!input.trim()) return;
setTodos([
...todos,
{ id: crypto.randomUUID(), title: input, completed: false },
]);
setInput("");
};
const toggleTodo = (id: string) => {
setTodos(
todos.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
};
const deleteTodo = (id: string) => {
setTodos(todos.filter((t) => t.id !== id));
};
return (
Todo List
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTodo()}
placeholder="Add a todo..."
aria-label="New todo"
/>
{todos.map((todo) => (
-
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
aria-label={Toggle ${todo.title}}
/>
style={{
textDecoration: todo.completed ? "line-through" : "none",
}}
>
{todo.title}
))}
Total: {todos.length} | Completed:{" "}
{todos.filter((t) => t.completed).length}
);
}
// components/TodoApp.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoApp } from "./TodoApp";
describe("TodoApp Integration", () => {
it("should add a new todo", async () => {
render( );
const user = userEvent.setup();
const input = screen.getByLabelText("New todo");
const addButton = screen.getByRole("button", { name: /add/i });
await user.type(input, "Buy groceries");
await user.click(addButton);
expect(screen.getByText("Buy groceries")).toBeInTheDocument();
expect(input).toHaveValue("");
});
it("should toggle todo completion", async () => {
render( );
const user = userEvent.setup();
// Add a todo
await user.type(screen.getByLabelText("New todo"), "Test task");
await user.click(screen.getByRole("button", { name: /add/i }));
// Toggle it
const checkbox = screen.getByLabelText("Toggle Test task");
await user.click(checkbox);
expect(checkbox).toBeChecked();
expect(screen.getByText("Test task")).toHaveStyle({
textDecoration: "line-through",
});
});
it("should delete a todo", async () => {
render( );
const user = userEvent.setup();
// Add a todo
await user.type(screen.getByLabelText("New todo"), "Delete me");
await user.click(screen.getByRole("button", { name: /add/i }));
// Delete it
await user.click(screen.getByRole("button", { name: /delete/i }));
expect(screen.queryByText("Delete me")).not.toBeInTheDocument();
});
it("should display correct counts", async () => {
render( );
const user = userEvent.setup();
// Add todos
const input = screen.getByLabelText("New todo");
await user.type(input, "Task 1");
await user.click(screen.getByRole("button", { name: /add/i }));
await user.type(input, "Task 2");
await user.click(screen.getByRole("button", { name: /add/i }));
expect(screen.getByText(/Total: 2/)).toBeInTheDocument();
expect(screen.getByText(/Completed: 0/)).toBeInTheDocument();
// Complete one
await user.click(screen.getByLabelText("Toggle Task 1"));
expect(screen.getByText(/Completed: 1/)).toBeInTheDocument();
});
});
E2E Testing with Playwright
Test complete user journeys in real browsers.
bash
npm install -D @playwright/test
npx playwright install
typescript
// 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: "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"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
typescript
// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Authentication Flow", () => {
test("should register new user", async ({ page }) => {
await page.goto("/auth/register");
await page.fill('input[name="name"]', "John Doe");
await page.fill('input[name="email"]', "john@example.com");
await page.fill('input[name="password"]', "SecurePass123!");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/dashboard");
await expect(page.locator("h1")).toContainText("Welcome, John Doe");
});
test("should login existing user", async ({ page }) => {
await page.goto("/auth/login");
await page.fill('input[name="email"]', "john@example.com");
await page.fill('input[name="password"]', "SecurePass123!");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/dashboard");
});
test("should show error on invalid credentials", async ({ page }) => {
await page.goto("/auth/login");
await page.fill('input[name="email"]', "wrong@example.com");
await page.fill('input[name="password"]', "wrongpassword");
await page.click('button[type="submit"]');
await expect(page.locator('[role="alert"]')).toContainText(
"Invalid credentials"
);
});
test("should logout user", async ({ page }) => {
// Login first
await page.goto("/auth/login");
await page.fill('input[name="email"]', "john@example.com");
await page.fill('input[name="password"]', "SecurePass123!");
await page.click('button[type="submit"]');
// Logout
await page.click('button:has-text("Logout")');
await expect(page).toHaveURL("/auth/login");
});
});
// e2e/blog.spec.ts
test.describe("Blog Functionality", () => {
test("should create and publish blog post", async ({ page }) => {
// Login as admin
await page.goto("/auth/login");
await page.fill('input[name="email"]', "admin@example.com");
await page.fill('input[name="password"]', "admin123");
await page.click('button[type="submit"]');
// Navigate to create post
await page.goto("/dashboard/posts/new");
await page.fill('input[name="title"]', "Test Blog Post");
await page.fill('textarea[name="content"]', "This is test content");
await page.selectOption('select[name="category"]', "technology");
// Save as draft
await page.click('button:has-text("Save Draft")');
await expect(page.locator(".toast")).toContainText("Draft saved");
// Publish
await page.click('button:has-text("Publish")');
await expect(page.locator(".toast")).toContainText("Post published");
// Verify on public page
await page.goto("/blog");
await expect(page.locator('h2:has-text("Test Blog Post")')).toBeVisible();
});
});
Test-Driven Development (TDD)
Write tests first, then implement.
typescript
// Example: Implementing a shopping cart with TDD
// Step 1: Write failing test
describe("ShoppingCart", () => {
it("should start empty", () => {
const cart = new ShoppingCart();
expect(cart.getItems()).toHaveLength(0);
expect(cart.getTotal()).toBe(0);
});
});
// Step 2: Implement minimal code to pass
class ShoppingCart {
private items: CartItem[] = [];
getItems() {
return this.items;
}
getTotal() {
return 0;
}
}
// Step 3: Write next test
it("should add items", () => {
const cart = new ShoppingCart();
cart.addItem({ id: "1", name: "Product", price: 10, quantity: 2 });
expect(cart.getItems()).toHaveLength(1);
expect(cart.getTotal()).toBe(20);
});
// Step 4: Implement feature
class ShoppingCart {
private items: CartItem[] = [];
addItem(item: CartItem) {
this.items.push(item);
}
getItems() {
return this.items;
}
getTotal() {
return this.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
}
// Continue cycle...
Best Practices
1. Follow AAA Pattern - Arrange, Act, Assert
2. One assertion per test - Focus and clarity
3. Test behavior, not implementation - Refactor-safe
4. Use descriptive test names - Documentation
5. Mock external dependencies - Isolation
6. Avoid test interdependence - Run in any order
7. Maintain test code quality - Same standards as production
8. Run tests in CI/CD - Automated quality gates
9. Measure coverage - Aim for 80%+ critical paths
10. Test edge cases - Empty arrays, null, undefined, errors
Conclusion
Comprehensive testing isn't about achieving 100% coverage—it's about strategic confidence. Combine unit tests for logic, integration tests for interactions, and E2E tests for critical flows. Embrace TDD when appropriate, maintain test quality, and make testing a natural part of your development workflow.
Well-tested code is maintainable code, and maintainable code is sustainable code! ✅