Back to blog

Comprehensive Testing Strategies for Modern Web Applications

10 min readBy Mustafa Akkaya
#Testing#Jest#Vitest#Playwright#Cypress#TDD#Quality Assurance

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! ✅