Skip to main content

Testing Strategies for Modern Web Applications: Unit, Integration, E2E, and Visual Testing

Master comprehensive testing strategies for web applications. Learn unit, integration, E2E, and visual testing with Jest, Vitest, Playwright, and best practices.

Table of Contents

Introduction

Testing is one of the most critical aspects of modern web development. It ensures your code works as expected, prevents regressions, and gives you confidence when refactoring or adding new features. However, with so many testing strategies, tools, and approaches available, it can be overwhelming to know where to start or how to structure your test suite effectively.

Modern web applications are complex systems with multiple layers: user interfaces, API integrations, state management, and business logic. Each layer requires different testing approaches. Understanding when to use unit tests, integration tests, end-to-end tests, and visual tests is crucial for building a robust testing strategy that catches bugs early without slowing down development.

This comprehensive guide covers all aspects of testing modern web applications. You’ll learn about the testing pyramid, how to write effective tests at each level, choose the right tools, and implement testing patterns that scale with your application. Whether you’re testing React components, API endpoints, or complete user workflows, this guide provides practical examples and best practices you can apply immediately.


Understanding the Testing Pyramid

The testing pyramid is a conceptual model that helps you understand how to distribute your testing efforts across different test types. It visualizes the ideal distribution of tests in your test suite.

The Three Layers

/\
/E2E\ ← Few tests, slow, expensive
/------\
/Integration\ ← Some tests, medium speed
/------------\
/ Unit Tests \ ← Many tests, fast, cheap
/----------------\

Unit Tests (Base of Pyramid):

  • Volume: Many (70-80% of tests)
  • Speed: Fast (milliseconds)
  • Scope: Single function, component, or module
  • Purpose: Verify individual units work correctly in isolation
  • Cost: Low (quick to write and run)

Integration Tests (Middle Layer):

  • Volume: Some (15-20% of tests)
  • Speed: Medium (seconds)
  • Scope: Multiple components or modules working together
  • Purpose: Verify components/modules integrate correctly
  • Cost: Medium (moderate complexity)

End-to-End Tests (Top of Pyramid):

  • Volume: Few (5-10% of tests)
  • Speed: Slow (minutes)
  • Scope: Complete user workflows
  • Purpose: Verify the application works from user’s perspective
  • Cost: High (expensive to write and maintain)

Why the Pyramid Works

Fast feedback: Most tests run quickly, giving you immediate feedback
Cost-effective: Focus expensive tests on critical paths
Maintainable: Unit tests are easier to maintain than E2E tests
Confidence: Different test types catch different types of bugs

Inverted pyramid: Too many E2E tests lead to slow test suites and maintenance burden
Ice cream cone: Too many integration tests without enough unit tests

Real-World Distribution

For a typical web application, you might have:

// Example distribution for a medium-sized app
{
unitTests: 500, // 75% - Fast, test individual functions/components
integrationTests: 100, // 15% - Test component interactions
e2eTests: 50 // 10% - Test critical user flows
}

💡 Tip: Start with unit tests for new features, then add integration tests for complex interactions, and finally add E2E tests for critical user journeys.


Unit Testing: Foundation of Quality Code

Unit testing is the practice of testing individual units of code in isolation. A unit can be a function, a class method, or a React component. Unit tests should be fast, isolated, and deterministic.

What Makes a Good Unit Test?

Fast: Runs in milliseconds
Isolated: Doesn’t depend on external systems
Deterministic: Same input always produces same output
Focused: Tests one thing at a time
Readable: Clear what it’s testing and why

Unit Testing Pure Functions

Pure functions are the easiest to test because they have no side effects and always return the same output for the same input.

// Pure function - easy to test
function calculateTotal(items, taxRate) {
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
return subtotal * (1 + taxRate);
}
// Unit test
describe("calculateTotal", () => {
it("should calculate total with tax", () => {
const items = [{ price: 10 }, { price: 20 }, { price: 30 }];
const taxRate = 0.1;
const result = calculateTotal(items, taxRate);
expect(result).toBe(66); // (10 + 20 + 30) * 1.1 = 66
});
it("should handle empty array", () => {
const result = calculateTotal([], 0.1);
expect(result).toBe(0);
});
it("should handle zero tax rate", () => {
const items = [{ price: 100 }];
const result = calculateTotal(items, 0);
expect(result).toBe(100);
});
});

Unit Testing React Components

When testing React components, focus on testing behavior, not implementation details. Use React Testing Library’s philosophy: “The more your tests resemble the way your software is used, the more confidence they can give you.”

// Component to test
import { useState } from "react";
function Counter({ initialValue = 0 }) {
const [count, setCount] = useState(initialValue);
return (
<div>
<button onClick={() => setCount(count - 1)} aria-label="decrement">
-
</button>
<span data-testid="count">{count}</span>
<button onClick={() => setCount(count + 1)} aria-label="increment">
+
</button>
</div>
);
}
// Unit test
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import Counter from "./Counter";
describe("Counter", () => {
it("should render with initial value", () => {
render(<Counter initialValue={5} />);
expect(screen.getByTestId("count")).toHaveTextContent("5");
});
it("should increment count when increment button is clicked", () => {
render(<Counter />);
const incrementButton = screen.getByLabelText("increment");
const countDisplay = screen.getByTestId("count");
fireEvent.click(incrementButton);
expect(countDisplay).toHaveTextContent("1");
});
it("should decrement count when decrement button is clicked", () => {
render(<Counter initialValue={5} />);
const decrementButton = screen.getByLabelText("decrement");
const countDisplay = screen.getByTestId("count");
fireEvent.click(decrementButton);
expect(countDisplay).toHaveTextContent("4");
});
it("should handle multiple clicks", () => {
render(<Counter />);
const incrementButton = screen.getByLabelText("increment");
fireEvent.click(incrementButton);
fireEvent.click(incrementButton);
fireEvent.click(incrementButton);
expect(screen.getByTestId("count")).toHaveTextContent("3");
});
});

Testing Custom Hooks

Testing custom hooks requires using @testing-library/react-hooks or React Testing Library’s renderHook utility.

// Custom hook
import { useState, useEffect } from "react";
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Unit test
import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { useDebounce } from "./useDebounce";
describe("useDebounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should return initial value immediately", () => {
const { result } = renderHook(() => useDebounce("test", 500));
expect(result.current).toBe("test");
});
it("should debounce value changes", async () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "initial", delay: 500 } },
);
expect(result.current).toBe("initial");
rerender({ value: "updated", delay: 500 });
expect(result.current).toBe("initial"); // Still initial
vi.advanceTimersByTime(500);
await waitFor(() => {
expect(result.current).toBe("updated");
});
});
});

Unit Testing Utilities and Helpers

Utility functions are perfect candidates for unit testing because they’re typically pure functions.

// Utility function
export function formatCurrency(amount, currency = "USD", locale = "en-US") {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(amount);
}
// Unit test
import { describe, it, expect } from "vitest";
import { formatCurrency } from "./formatCurrency";
describe("formatCurrency", () => {
it("should format USD currency by default", () => {
expect(formatCurrency(1000)).toBe("$1,000.00");
});
it("should format EUR currency", () => {
expect(formatCurrency(1000, "EUR")).toBe("€1,000.00");
});
it("should format with different locale", () => {
expect(formatCurrency(1000, "EUR", "de-DE")).toBe("1.000,00 €");
});
it("should handle decimal amounts", () => {
expect(formatCurrency(1234.56)).toBe("$1,234.56");
});
it("should handle zero", () => {
expect(formatCurrency(0)).toBe("$0.00");
});
});

💡 Pro Tip: When writing unit tests, follow the AAA pattern: Arrange (set up), Act (execute), Assert (verify). This makes tests more readable and maintainable.


Integration Testing: Testing Component Interactions

Integration tests verify that multiple units work together correctly. In React applications, this typically means testing how components interact with each other, with context providers, or with external services.

What to Test in Integration Tests

Component composition: How components work together
Context providers: Components consuming context
API integration: Components fetching and displaying data
Form submissions: Complete form workflows
Navigation: Routing and page transitions

Testing Component Composition

// Parent component
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <LoadingSpinner />;
if (!user) return <ErrorMessage message="User not found" />;
return (
<div>
<UserHeader user={user} />
<UserDetails user={user} />
<UserActions userId={userId} />
</div>
);
}
// Integration test
import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import UserProfile from "./UserProfile";
// Mock the API call
vi.mock("./api", () => ({
fetchUser: vi.fn(),
}));
describe("UserProfile Integration", () => {
it("should render user profile with all components", async () => {
const mockUser = {
id: "1",
name: "John Doe",
email: "john@example.com",
avatar: "avatar.jpg",
};
const { fetchUser } = await import("./api");
fetchUser.mockResolvedValue(mockUser);
render(<UserProfile userId="1" />);
// Wait for loading to complete
await waitFor(() => {
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});
// Verify all components rendered
expect(screen.getByText("John Doe")).toBeInTheDocument();
expect(screen.getByText("john@example.com")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument();
});
it("should show error message when user fetch fails", async () => {
const { fetchUser } = await import("./api");
fetchUser.mockRejectedValue(new Error("User not found"));
render(<UserProfile userId="999" />);
await waitFor(() => {
expect(screen.getByText("User not found")).toBeInTheDocument();
});
});
});

Testing Context Providers

// Theme context
const ThemeContext = createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// Component using context
function ThemeToggle() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<button onClick={toggleTheme} aria-label="toggle theme">
Current theme: {theme}
</button>
);
}
// Integration test
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { ThemeProvider } from "./ThemeProvider";
import ThemeToggle from "./ThemeToggle";
describe("ThemeProvider Integration", () => {
it("should provide theme context to children", () => {
render(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>,
);
expect(screen.getByText(/current theme: light/i)).toBeInTheDocument();
});
it("should toggle theme when button is clicked", () => {
render(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>,
);
const toggleButton = screen.getByLabelText("toggle theme");
fireEvent.click(toggleButton);
expect(screen.getByText(/current theme: dark/i)).toBeInTheDocument();
});
});

Testing Form Workflows

Forms are perfect for integration testing because they involve multiple interactions and state changes.

// Login form component
function LoginForm({ onSuccess }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError("");
try {
await login({ email, password });
onSuccess();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
{error && <div role="alert">{error}</div>}
<button type="submit" disabled={loading}>
{loading ? "Logging in..." : "Login"}
</button>
</form>
);
}
// Integration test
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import LoginForm from "./LoginForm";
vi.mock("./api", () => ({
login: vi.fn(),
}));
describe("LoginForm Integration", () => {
it("should submit form with valid credentials", async () => {
const onSuccess = vi.fn();
const { login } = await import("./api");
login.mockResolvedValue({ token: "abc123" });
render(<LoginForm onSuccess={onSuccess} />);
fireEvent.change(screen.getByPlaceholderText("Email"), {
target: { value: "user@example.com" },
});
fireEvent.change(screen.getByPlaceholderText("Password"), {
target: { value: "password123" },
});
fireEvent.click(screen.getByRole("button", { name: /login/i }));
await waitFor(() => {
expect(login).toHaveBeenCalledWith({
email: "user@example.com",
password: "password123",
});
expect(onSuccess).toHaveBeenCalled();
});
});
it("should display error message on login failure", async () => {
const { login } = await import("./api");
login.mockRejectedValue(new Error("Invalid credentials"));
render(<LoginForm onSuccess={vi.fn()} />);
fireEvent.change(screen.getByPlaceholderText("Email"), {
target: { value: "user@example.com" },
});
fireEvent.change(screen.getByPlaceholderText("Password"), {
target: { value: "wrongpassword" },
});
fireEvent.click(screen.getByRole("button", { name: /login/i }));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(
"Invalid credentials",
);
});
});
});

End-to-End Testing: Testing User Flows

End-to-end (E2E) tests simulate real user interactions with your application. They test complete workflows from the user’s perspective, ensuring that all parts of your application work together correctly.

When to Use E2E Tests

Critical user journeys: Login, checkout, account creation
Cross-browser compatibility: Ensure features work in different browsers
Regression testing: Prevent breaking existing functionality
User workflows: Complete multi-step processes

Don’t use for: Simple unit test scenarios, testing implementation details

E2E Testing with Playwright

Playwright is a modern E2E testing framework that supports multiple browsers and provides excellent debugging tools.

// E2E test: User login and profile update flow
import { test, expect } from "@playwright/test";
test.describe("User Authentication Flow", () => {
test("should login and update profile", async ({ page }) => {
// Navigate to login page
await page.goto("https://example.com/login");
// Fill in login form
await page.fill('input[type="email"]', "user@example.com");
await page.fill('input[type="password"]', "password123");
await page.click('button[type="submit"]');
// Wait for navigation to dashboard
await page.waitForURL("**/dashboard");
await expect(page).toHaveURL(/dashboard/);
// Navigate to profile page
await page.click('a[href="/profile"]');
await expect(page).toHaveURL(/profile/);
// Update profile information
await page.fill('input[name="name"]', "John Updated");
await page.fill('input[name="bio"]', "Updated bio");
await page.click('button[type="submit"]');
// Verify success message
await expect(page.locator(".success-message")).toBeVisible();
await expect(page.locator(".success-message")).toContainText(
"Profile updated",
);
});
test("should handle login failure gracefully", async ({ page }) => {
await page.goto("https://example.com/login");
await page.fill('input[type="email"]', "wrong@example.com");
await page.fill('input[type="password"]', "wrongpassword");
await page.click('button[type="submit"]');
// Verify error message appears
await expect(page.locator(".error-message")).toBeVisible();
await expect(page.locator(".error-message")).toContainText(
"Invalid credentials",
);
// Verify user is still on login page
await expect(page).toHaveURL(/login/);
});
});

E2E Testing Shopping Cart Flow

import { test, expect } from "@playwright/test";
test.describe("Shopping Cart Flow", () => {
test("should add items to cart and complete checkout", async ({ page }) => {
// Browse products
await page.goto("https://example.com/products");
// Add first product to cart
await page.click('[data-testid="product-1"] button');
await expect(page.locator('[data-testid="cart-count"]')).toHaveText("1");
// Add second product to cart
await page.click('[data-testid="product-2"] button');
await expect(page.locator('[data-testid="cart-count"]')).toHaveText("2");
// Go to cart
await page.click('[data-testid="cart-icon"]');
await expect(page).toHaveURL(/cart/);
// Verify items in cart
const cartItems = page.locator('[data-testid="cart-item"]');
await expect(cartItems).toHaveCount(2);
// Proceed to checkout
await page.click('button:has-text("Checkout")');
await expect(page).toHaveURL(/checkout/);
// Fill shipping information
await page.fill('input[name="name"]', "John Doe");
await page.fill('input[name="address"]', "123 Main St");
await page.fill('input[name="city"]', "New York");
await page.selectOption('select[name="country"]', "US");
// Fill payment information
await page.fill('input[name="cardNumber"]', "4111111111111111");
await page.fill('input[name="expiry"]', "12/25");
await page.fill('input[name="cvv"]', "123");
// Complete order
await page.click('button:has-text("Place Order")');
// Verify order confirmation
await expect(page).toHaveURL(/order-confirmation/);
await expect(page.locator("h1")).toContainText("Order Confirmed");
});
});

E2E Testing with Page Object Model

The Page Object Model (POM) pattern helps organize E2E tests by encapsulating page interactions in reusable classes.

// Page Object: LoginPage
class LoginPage {
constructor(page) {
this.page = page;
this.emailInput = page.locator('input[type="email"]');
this.passwordInput = page.locator('input[type="password"]');
this.submitButton = page.locator('button[type="submit"]');
this.errorMessage = page.locator(".error-message");
}
async goto() {
await this.page.goto("https://example.com/login");
}
async login(email, password) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async getErrorMessage() {
return await this.errorMessage.textContent();
}
}
// Page Object: DashboardPage
class DashboardPage {
constructor(page) {
this.page = page;
this.welcomeMessage = page.locator(".welcome-message");
this.profileLink = page.locator('a[href="/profile"]');
}
async isVisible() {
await this.page.waitForURL("**/dashboard");
}
async goToProfile() {
await this.profileLink.click();
}
}
// E2E test using Page Objects
import { test, expect } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";
import { DashboardPage } from "./pages/DashboardPage";
test("should login using page objects", async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.login("user@example.com", "password123");
await dashboardPage.isVisible();
expect(await dashboardPage.welcomeMessage.textContent()).toContain("Welcome");
});

💡 Pro Tip: Keep E2E tests focused on critical user journeys. Aim for 10-20 E2E tests that cover your most important workflows rather than trying to test everything.


Visual Testing: Ensuring UI Consistency

Visual testing (also called visual regression testing) captures screenshots of your UI and compares them against baseline images to detect unintended visual changes.

When to Use Visual Testing

Design system components: Ensure components look consistent
Responsive design: Verify layouts at different breakpoints
Cross-browser testing: Detect browser-specific rendering issues
Accessibility: Verify visual accessibility features

Visual Testing with Playwright

Playwright has built-in screenshot capabilities for visual testing.

import { test, expect } from "@playwright/test";
test.describe("Visual Regression Tests", () => {
test("homepage should match baseline", async ({ page }) => {
await page.goto("https://example.com");
await expect(page).toHaveScreenshot("homepage.png");
});
test("product page should match baseline", async ({ page }) => {
await page.goto("https://example.com/products/1");
await expect(page).toHaveScreenshot("product-page.png");
});
test("mobile view should match baseline", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 }); // iPhone SE
await page.goto("https://example.com");
await expect(page).toHaveScreenshot("homepage-mobile.png");
});
});

Visual Testing Component Library

For component-level visual testing, tools like Chromatic or Percy work well with Storybook.

// Storybook story for visual testing
import Button from "./Button";
export default {
title: "Components/Button",
component: Button,
};
export const Primary = {
args: {
label: "Click me",
variant: "primary",
},
};
export const Secondary = {
args: {
label: "Click me",
variant: "secondary",
},
};
export const Disabled = {
args: {
label: "Click me",
disabled: true,
},
};

⚠️ Important: Visual tests can be flaky due to timing, fonts, or browser differences. Use them as a supplement to functional tests, not a replacement.


Choosing the Right Testing Tools

The JavaScript ecosystem offers many testing tools. Here’s a comparison to help you choose:

Unit Testing Frameworks

Jest:

  • ✅ Most popular, extensive ecosystem
  • ✅ Built-in mocking and assertions
  • ✅ Great for React applications
  • ❌ Can be slower than alternatives
  • ❌ Requires more configuration

Vitest:

  • ✅ Fast (uses Vite)
  • ✅ Jest-compatible API
  • ✅ Native ESM support
  • ✅ Great TypeScript support
  • ❌ Newer, smaller ecosystem

Mocha:

  • ✅ Flexible and lightweight
  • ✅ Works with any assertion library
  • ❌ Requires more setup
  • ❌ Less opinionated

E2E Testing Frameworks

Playwright:

  • ✅ Fast and reliable
  • ✅ Multiple browser support
  • ✅ Great debugging tools
  • ✅ Auto-waiting built-in
  • ✅ Modern API

Cypress:

  • ✅ Great developer experience
  • ✅ Time-travel debugging
  • ✅ Large community
  • ❌ Limited cross-browser support
  • ❌ Runs in browser (limitations)

Puppeteer:

  • ✅ Direct Chrome DevTools Protocol access
  • ✅ Good for automation
  • ❌ Chrome/Chromium only
  • ❌ Lower-level API

Testing Library Recommendations

React Testing Library:

  • ✅ Encourages testing behavior, not implementation
  • ✅ Accessible queries
  • ✅ Works with any test runner

Testing Library:

  • ✅ Framework-agnostic
  • ✅ Consistent API across frameworks
  • ✅ Focus on accessibility

For a modern React/TypeScript application:

{
"devDependencies": {
"vitest": "^1.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/user-event": "^14.0.0",
"@playwright/test": "^1.40.0",
"jsdom": "^23.0.0"
}
}

💡 Pro Tip: Start with Vitest for unit tests and Playwright for E2E tests. They’re modern, fast, and have excellent TypeScript support.


Testing Patterns and Best Practices

The AAA Pattern

Structure your tests using the Arrange-Act-Assert pattern:

describe("calculateTotal", () => {
it("should calculate total with tax", () => {
// Arrange: Set up test data
const items = [{ price: 10 }, { price: 20 }];
const taxRate = 0.1;
// Act: Execute the function
const result = calculateTotal(items, taxRate);
// Assert: Verify the result
expect(result).toBe(33);
});
});

Test Naming Conventions

Use descriptive test names that explain what is being tested:

// ✅ Good: Clear and descriptive
it("should return error when email is invalid", () => {});
it("should increment counter when button is clicked", () => {});
it("should display loading state while fetching data", () => {});
// ❌ Bad: Vague and unclear
it("works", () => {});
it("test1", () => {});
it("should work correctly", () => {});

One Assertion Per Test (When Possible)

While not always practical, try to test one thing per test:

// ✅ Good: Focused tests
it("should return true for valid email", () => {
expect(isValidEmail("user@example.com")).toBe(true);
});
it("should return false for invalid email", () => {
expect(isValidEmail("invalid")).toBe(false);
});
// ❌ Less ideal: Multiple concerns
it("should validate email", () => {
expect(isValidEmail("user@example.com")).toBe(true);
expect(isValidEmail("invalid")).toBe(false);
expect(isValidEmail("")).toBe(false);
});

Test Independence

Each test should be independent and able to run in isolation:

// ❌ Bad: Tests depend on each other
let counter = 0;
it("should increment counter", () => {
counter++;
expect(counter).toBe(1);
});
it("should increment counter again", () => {
counter++; // Depends on previous test
expect(counter).toBe(2);
});
// ✅ Good: Tests are independent
it("should increment counter from 0", () => {
const counter = 0;
const result = increment(counter);
expect(result).toBe(1);
});
it("should increment counter from 5", () => {
const counter = 5;
const result = increment(counter);
expect(result).toBe(6);
});

Testing Edge Cases

Always test edge cases and boundary conditions:

describe("divide", () => {
it("should divide two positive numbers", () => {
expect(divide(10, 2)).toBe(5);
});
it("should handle division by zero", () => {
expect(() => divide(10, 0)).toThrow("Cannot divide by zero");
});
it("should handle negative numbers", () => {
expect(divide(-10, 2)).toBe(-5);
});
it("should handle decimal results", () => {
expect(divide(1, 3)).toBeCloseTo(0.333, 3);
});
});

Test Organization and Structure

File Structure

Organize tests to mirror your source code structure:

src/
components/
Button/
Button.tsx
Button.test.tsx
Form/
Form.tsx
Form.test.tsx
utils/
formatCurrency.ts
formatCurrency.test.ts
hooks/
useDebounce.ts
useDebounce.test.ts

Use describe blocks to group related tests:

describe("UserService", () => {
describe("createUser", () => {
it("should create user with valid data", () => {});
it("should throw error for invalid email", () => {});
it("should throw error for missing name", () => {});
});
describe("updateUser", () => {
it("should update user fields", () => {});
it("should throw error for non-existent user", () => {});
});
describe("deleteUser", () => {
it("should delete user by id", () => {});
it("should throw error for non-existent user", () => {});
});
});

Test Setup and Teardown

Use beforeEach, afterEach, beforeAll, and afterAll for setup and cleanup:

describe("DatabaseService", () => {
let db;
beforeAll(async () => {
// Set up test database once
db = await createTestDatabase();
});
beforeEach(async () => {
// Clean database before each test
await db.clear();
});
afterAll(async () => {
// Clean up test database
await db.destroy();
});
it("should insert user", async () => {
const user = await db.insertUser({ name: "John" });
expect(user.id).toBeDefined();
});
});

Mocking and Test Doubles

Mocking allows you to isolate units under test by replacing dependencies with controlled alternatives.

Types of Test Doubles

Stub: Provides predefined responses to method calls
Mock: Records interactions and verifies they occurred
Spy: Wraps real object and records interactions
Fake: Working implementation with simplified behavior

Mocking Functions

import { vi } from "vitest";
// Mock a function
const fetchUser = vi.fn();
// Set return value
fetchUser.mockReturnValue({ id: 1, name: "John" });
// Set implementation
fetchUser.mockImplementation(async (id) => {
return { id, name: "John" };
});
// Verify calls
expect(fetchUser).toHaveBeenCalledWith(1);
expect(fetchUser).toHaveBeenCalledTimes(1);

Mocking Modules

// Mock an entire module
vi.mock("./api", () => ({
fetchUser: vi.fn(),
createUser: vi.fn(),
updateUser: vi.fn(),
}));
// Mock with implementation
vi.mock("./api", async () => {
const actual = await vi.importActual("./api");
return {
...actual,
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "John" }),
};
});

Mocking HTTP Requests

import { vi } from "vitest";
import { rest } from "msw";
import { setupServer } from "msw/node";
// Set up MSW server
const server = setupServer(
rest.get("/api/user/:id", (req, res, ctx) => {
return res(ctx.json({ id: req.params.id, name: "John" }));
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Test
it("should fetch user data", async () => {
const user = await fetchUser("1");
expect(user.name).toBe("John");
});

Mocking React Components

// Mock a component
vi.mock("./ExpensiveComponent", () => ({
default: vi.fn(() => <div>Mocked Component</div>),
}));
// Mock with props
vi.mock("./ExpensiveComponent", () => ({
default: ({ children }) => <div data-testid="mocked">{children}</div>,
}));

💡 Pro Tip: Mock at the boundaries (API calls, external services) rather than internal implementation details. This makes tests more resilient to refactoring.


Testing Asynchronous Code

Asynchronous code requires special handling in tests. Understanding how to test promises, async/await, and timers is crucial.

Testing Promises

// Function that returns a promise
async function fetchData(url) {
const response = await fetch(url);
return response.json();
}
// Test with async/await
it("should fetch data", async () => {
const data = await fetchData("https://api.example.com/data");
expect(data).toEqual({ id: 1, name: "Test" });
});
// Test promise rejection
it("should handle fetch errors", async () => {
await expect(fetchData("invalid-url")).rejects.toThrow();
});

Testing Async Functions with waitFor

React Testing Library’s waitFor helps test asynchronous UI updates:

import { waitFor } from "@testing-library/react";
it("should display data after loading", async () => {
render(<DataComponent />);
// Wait for loading to disappear
await waitFor(() => {
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});
// Verify data is displayed
expect(screen.getByText("Data loaded")).toBeInTheDocument();
});

Testing Timers

import { vi, beforeEach, afterEach } from "vitest";
describe("Timer functions", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should call callback after delay", () => {
const callback = vi.fn();
setTimeout(callback, 1000);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
});
it("should handle intervals", () => {
const callback = vi.fn();
setInterval(callback, 100);
vi.advanceTimersByTime(300);
expect(callback).toHaveBeenCalledTimes(3);
});
});

For more details on testing async code, check out our guide on JavaScript Promises and Async/Await.


Performance Testing

Performance testing ensures your application meets performance requirements and identifies bottlenecks.

Measuring Component Render Performance

import { render } from "@testing-library/react";
import { performance } from "perf_hooks";
it("should render large list efficiently", () => {
const start = performance.now();
render(<LargeList items={generateItems(1000)} />);
const end = performance.now();
const renderTime = end - start;
expect(renderTime).toBeLessThan(100); // Should render in under 100ms
});

Load Testing with Playwright

import { test, expect } from "@playwright/test";
test("should handle concurrent users", async ({ page }) => {
const startTime = Date.now();
await page.goto("https://example.com");
await page.click('button:has-text("Load Data")');
await page.waitForSelector(".data-loaded");
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(2000); // Should load in under 2 seconds
});

Memory Leak Testing

it("should not leak memory", async () => {
const initialMemory = process.memoryUsage().heapUsed;
// Perform operations that might cause leaks
for (let i = 0; i < 100; i++) {
render(<Component />);
cleanup();
}
// Force garbage collection if available
if (global.gc) {
global.gc();
}
const finalMemory = process.memoryUsage().heapUsed;
const memoryIncrease = finalMemory - initialMemory;
// Memory increase should be reasonable
expect(memoryIncrease).toBeLessThan(10 * 1024 * 1024); // Less than 10MB
});

Continuous Integration and Testing

Integrating tests into your CI/CD pipeline ensures code quality and catches issues before they reach production.

GitHub Actions Example

name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install
- name: Run unit tests
run: pnpm test:unit
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: test-results/

Test Scripts in package.json

{
"scripts": {
"test": "vitest",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest watch",
"test:ui": "vitest --ui"
}
}

Code Coverage

Aim for meaningful coverage, not just high percentages:

vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["node_modules/", "src/**/*.test.ts", "src/**/*.test.tsx"],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
},
});

💡 Pro Tip: Focus on testing critical paths and business logic rather than achieving 100% coverage. High coverage doesn’t guarantee quality tests.


Common Testing Pitfalls and How to Avoid Them

Pitfall 1: Testing Implementation Details

Bad: Testing internal state or implementation

// ❌ Testing implementation details
it("should set isLoading to true", () => {
const component = render(<DataComponent />);
expect(component.instance().state.isLoading).toBe(true);
});

Good: Testing behavior from user’s perspective

// ✅ Testing behavior
it("should show loading spinner while fetching data", () => {
render(<DataComponent />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});

Pitfall 2: Over-Mocking

Bad: Mocking everything

// ❌ Over-mocking
vi.mock("./utils");
vi.mock("./hooks");
vi.mock("./components");
// ... everything is mocked

Good: Mock only external dependencies

// ✅ Mock only what's necessary
vi.mock("./api"); // External API calls
// Use real implementations for internal code

Pitfall 3: Brittle Tests

Bad: Tests that break with minor changes

// ❌ Brittle: Depends on specific HTML structure
expect(container.querySelector("div > div > button")).toBeInTheDocument();

Good: Tests that focus on behavior

// ✅ Resilient: Uses semantic queries
expect(screen.getByRole("button", { name: /submit/i })).toBeInTheDocument();

Pitfall 4: Not Testing Error Cases

Bad: Only testing happy paths

// ❌ Missing error cases
it("should login user", () => {
// Only tests success case
});

Good: Testing both success and failure

// ✅ Comprehensive
it("should login user with valid credentials", () => {});
it("should show error with invalid credentials", () => {});
it("should handle network errors", () => {});

Pitfall 5: Slow Test Suites

Bad: Making unit tests slow

// ❌ Slow: Real API calls in unit tests
it("should fetch data", async () => {
const data = await realApiCall(); // Slow!
});

Good: Fast, isolated tests

// ✅ Fast: Mocked dependencies
it("should fetch data", async () => {
const data = await mockedApiCall(); // Fast!
});

Pitfall 6: Flaky Tests

Bad: Tests that sometimes pass, sometimes fail

// ❌ Flaky: No waiting for async operations
it("should display data", () => {
render(<DataComponent />);
expect(screen.getByText("Data")).toBeInTheDocument(); // Might not be loaded yet
});

Good: Reliable tests with proper waiting

// ✅ Reliable: Waits for async operations
it("should display data", async () => {
render(<DataComponent />);
await waitFor(() => {
expect(screen.getByText("Data")).toBeInTheDocument();
});
});

For more on avoiding React-specific pitfalls, see our guide on 10 Most Common Pitfalls in React.js.


Conclusion

Testing is an essential part of building reliable, maintainable web applications. By understanding the testing pyramid and implementing a balanced mix of unit, integration, and E2E tests, you can catch bugs early, refactor with confidence, and deliver high-quality software.

Remember these key takeaways:

  • Start with unit tests: They’re fast, cheap, and catch most bugs
  • Add integration tests: Verify components work together correctly
  • Use E2E tests sparingly: Focus on critical user journeys
  • Test behavior, not implementation: Your tests will be more resilient
  • Keep tests fast: Slow tests slow down development
  • Mock at boundaries: Isolate external dependencies
  • Test edge cases: Don’t just test happy paths

The tools and patterns covered in this guide—Jest, Vitest, Playwright, React Testing Library—provide a solid foundation for testing modern web applications. Start with the basics, gradually add more sophisticated tests, and remember that good tests are maintainable, readable, and provide value.

As you continue building your test suite, refer to testing tool documentation like the Jest cheatsheet and Vitest cheatsheet for quick reference. Keep iterating on your testing strategy, and don’t be afraid to refactor tests as your application evolves.

Happy testing! 🧪