Learning Guides
Menu

Testing Patterns

14 min readFrontend Patterns & Concepts

Testing Patterns

Testing is how you ensure your code works and keeps working. Different types of tests serve different purposes—knowing when to use each is key to efficient testing.

The Testing Pyramid

PLAINTEXT
        ╱╲
       ╱  ╲           E2E Tests
      ╱────╲          (Slow, Expensive, Few)
     ╱      ╲
    ╱────────╲        Integration Tests
   ╱          ╲       (Medium Speed, Medium Count)
  ╱────────────╲
 ╱              ╲     Unit Tests
╱────────────────╲    (Fast, Cheap, Many)

Unit Testing

Test individual functions and components in isolation.

Testing Pure Functions

JAVASCRIPT
// utils/math.js
export function calculateDiscount(price, discountPercent) {
  if (price < 0) throw new Error("Price cannot be negative");
  if (discountPercent < 0 || discountPercent > 100) {
    throw new Error("Discount must be between 0 and 100");
  }
  return price * (1 - discountPercent / 100);
}
 
// utils/math.test.js
import { calculateDiscount } from "./math";
 
describe("calculateDiscount", () => {
  test("calculates correct discount", () => {
    expect(calculateDiscount(100, 20)).toBe(80);
    expect(calculateDiscount(50, 10)).toBe(45);
  });
 
  test("handles 0% discount", () => {
    expect(calculateDiscount(100, 0)).toBe(100);
  });
 
  test("handles 100% discount", () => {
    expect(calculateDiscount(100, 100)).toBe(0);
  });
 
  test("handles decimal values", () => {
    expect(calculateDiscount(99.99, 15)).toBeCloseTo(84.99);
  });
 
  test("throws for negative price", () => {
    expect(() => calculateDiscount(-10, 20)).toThrow(
      "Price cannot be negative",
    );
  });
 
  test("throws for invalid discount", () => {
    expect(() => calculateDiscount(100, -5)).toThrow();
    expect(() => calculateDiscount(100, 150)).toThrow();
  });
});

Testing React Components

JAVASCRIPT
// components/Button.jsx
export function Button({ onClick, children, disabled, variant = "primary" }) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant}`}
    >
      {children}
    </button>
  );
}
 
// components/Button.test.jsx
import { render, screen, fireEvent } from "@testing-library/react";
import { Button } from "./Button";
 
describe("Button", () => {
  test("renders children", () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText("Click me")).toBeInTheDocument();
  });
 
  test("calls onClick when clicked", () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
 
    fireEvent.click(screen.getByText("Click me"));
 
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
 
  test("does not call onClick when disabled", () => {
    const handleClick = jest.fn();
    render(
      <Button onClick={handleClick} disabled>
        Click me
      </Button>,
    );
 
    fireEvent.click(screen.getByText("Click me"));
 
    expect(handleClick).not.toHaveBeenCalled();
  });
 
  test("applies variant class", () => {
    render(<Button variant="secondary">Click me</Button>);
 
    expect(screen.getByText("Click me")).toHaveClass("btn-secondary");
  });
});

Testing Hooks

JAVASCRIPT
// hooks/useCounter.js
import { useState, useCallback } from "react";
 
export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
 
  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = useCallback(() => setCount((c) => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);
 
  return { count, increment, decrement, reset };
}
 
// hooks/useCounter.test.js
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";
 
describe("useCounter", () => {
  test("starts with initial value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
 
  test("starts with 0 by default", () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });
 
  test("increments count", () => {
    const { result } = renderHook(() => useCounter(0));
 
    act(() => {
      result.current.increment();
    });
 
    expect(result.current.count).toBe(1);
  });
 
  test("decrements count", () => {
    const { result } = renderHook(() => useCounter(5));
 
    act(() => {
      result.current.decrement();
    });
 
    expect(result.current.count).toBe(4);
  });
 
  test("resets to initial value", () => {
    const { result } = renderHook(() => useCounter(10));
 
    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });
 
    expect(result.current.count).toBe(10);
  });
});

Testing Async Code

Modern frontend applications are heavily asynchronous—API calls, timers, user interactions. Testing async code requires special techniques to handle timing and ensure tests wait for operations to complete.

Key challenges with async testing:

  • Tests might finish before async operations complete
  • Need to mock network requests (don't call real APIs in tests)
  • Must handle both success and error cases
  • Timing issues can cause flaky tests

Tools for async testing:

  • async/await in test functions
  • waitFor from Testing Library (wait for condition to be true)
  • Mock functions like jest.fn() to simulate API responses
  • mockResolvedValue / mockRejectedValue for Promise outcomes
JAVASCRIPT
// api/users.js
export async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error("User not found");
  }
  return response.json();
}
 
// api/users.test.js
import { fetchUser } from "./users";
 
// Mock fetch globally
global.fetch = jest.fn();
 
describe("fetchUser", () => {
  beforeEach(() => {
    fetch.mockClear();
  });
 
  test("returns user data on success", async () => {
    const mockUser = { id: 1, name: "John" };
    fetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve(mockUser),
    });
 
    const user = await fetchUser(1);
 
    expect(fetch).toHaveBeenCalledWith("/api/users/1");
    expect(user).toEqual(mockUser);
  });
 
  test("throws on error response", async () => {
    fetch.mockResolvedValueOnce({
      ok: false,
      status: 404,
    });
 
    await expect(fetchUser(999)).rejects.toThrow("User not found");
  });
 
  test("throws on network error", async () => {
    fetch.mockRejectedValueOnce(new Error("Network error"));
 
    await expect(fetchUser(1)).rejects.toThrow("Network error");
  });
});

Integration Testing

Integration tests verify that multiple units work correctly together. Unlike unit tests (which test isolated functions), integration tests check the connections between components, hooks, and external services.

Why integration tests matter:

  • Unit tests can pass while integration fails ("works on my machine")
  • Catch issues in component communication
  • More confidence than unit tests alone
  • Still faster than E2E tests

What to test in integration tests:

  • Component renders correctly with its children
  • State flows properly through the component tree
  • API calls are made with correct parameters
  • Loading, error, and success states render properly
  • User interactions trigger expected outcomes

The key difference from unit tests:

  • Unit tests mock everything except the unit under test
  • Integration tests mock only external boundaries (APIs, timers)
  • Components render with their real children
JAVASCRIPT
// features/TodoList/TodoList.test.jsx
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "./TodoList";
 
// Mock API
jest.mock("./api", () => ({
  fetchTodos: jest.fn(),
  addTodo: jest.fn(),
  deleteTodo: jest.fn(),
}));
 
import { fetchTodos, addTodo, deleteTodo } from "./api";
 
describe("TodoList Integration", () => {
  beforeEach(() => {
    jest.clearAllMocks();
    fetchTodos.mockResolvedValue([
      { id: 1, text: "Learn testing", completed: false },
    ]);
  });
 
  test("loads and displays todos", async () => {
    render(<TodoList />);
 
    // Shows loading state
    expect(screen.getByText("Loading...")).toBeInTheDocument();
 
    // Then shows todos
    await waitFor(() => {
      expect(screen.getByText("Learn testing")).toBeInTheDocument();
    });
  });
 
  test("adds a new todo", async () => {
    const user = userEvent.setup();
    addTodo.mockResolvedValue({ id: 2, text: "New todo", completed: false });
 
    render(<TodoList />);
    await waitFor(() => screen.getByText("Learn testing"));
 
    // Type in input and submit
    const input = screen.getByPlaceholderText("Add a todo");
    await user.type(input, "New todo");
    await user.click(screen.getByRole("button", { name: "Add" }));
 
    // Verify API called
    expect(addTodo).toHaveBeenCalledWith({ text: "New todo" });
 
    // Verify new todo appears
    await waitFor(() => {
      expect(screen.getByText("New todo")).toBeInTheDocument();
    });
  });
 
  test("deletes a todo", async () => {
    const user = userEvent.setup();
    deleteTodo.mockResolvedValue({ success: true });
 
    render(<TodoList />);
    await waitFor(() => screen.getByText("Learn testing"));
 
    // Click delete button
    await user.click(screen.getByRole("button", { name: "Delete" }));
 
    // Confirm deletion
    await user.click(screen.getByRole("button", { name: "Confirm" }));
 
    // Verify todo removed
    await waitFor(() => {
      expect(screen.queryByText("Learn testing")).not.toBeInTheDocument();
    });
  });
 
  test("handles error states", async () => {
    fetchTodos.mockRejectedValue(new Error("Failed to fetch"));
 
    render(<TodoList />);
 
    await waitFor(() => {
      expect(screen.getByText("Failed to load todos")).toBeInTheDocument();
    });
 
    // Retry button
    expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
  });
});

End-to-End Testing

E2E tests simulate real user behavior in a real browser. They test the entire application stack—frontend, backend, database—working together.

When E2E tests are essential:

  • Critical user flows (checkout, signup, payment)
  • Flows that span multiple pages
  • Interactions with real browser APIs (file upload, geolocation)
  • Verifying production-like environment works

Trade-offs of E2E tests:

  • Pros: Highest confidence, test real user experience
  • Cons: Slow, expensive, can be flaky, harder to debug

Best practices:

  • Test critical paths, not every feature
  • Use realistic but stable test data
  • Run on CI/CD before deployment
  • Parallelize tests when possible
  • Take screenshots/videos for debugging failures

Popular E2E frameworks:

  • Playwright (Microsoft, cross-browser, fast)
  • Cypress (developer-friendly, time-travel debugging)
  • Puppeteer (Chrome/Chromium only, lightweight)

Playwright Example

Playwright is powerful because it supports all major browsers, runs tests in parallel, and has excellent auto-waiting built in (no manual waits needed).

JAVASCRIPT
// e2e/checkout.spec.js
import { test, expect } from "@playwright/test";
 
test.describe("Checkout Flow", () => {
  test.beforeEach(async ({ page }) => {
    // Seed test data
    await page.goto("/");
  });
 
  test("completes checkout successfully", async ({ page }) => {
    // Add item to cart
    await page.goto("/products/1");
    await page.click('button:has-text("Add to Cart")');
 
    // Verify cart updated
    await expect(page.locator(".cart-count")).toHaveText("1");
 
    // Go to cart
    await page.click('[data-testid="cart-icon"]');
    await expect(page).toHaveURL("/cart");
 
    // Proceed to checkout
    await page.click('button:has-text("Checkout")');
    await expect(page).toHaveURL("/checkout");
 
    // Fill shipping info
    await page.fill('[name="email"]', "test@example.com");
    await page.fill('[name="name"]', "John Doe");
    await page.fill('[name="address"]', "123 Test St");
    await page.fill('[name="city"]', "Test City");
    await page.fill('[name="zip"]', "12345");
 
    // Fill payment info
    await page.fill('[name="cardNumber"]', "4242424242424242");
    await page.fill('[name="expiry"]', "12/25");
    await page.fill('[name="cvc"]', "123");
 
    // Submit order
    await page.click('button:has-text("Place Order")');
 
    // Verify success
    await expect(page).toHaveURL(/\/order-confirmation/);
    await expect(page.locator("h1")).toHaveText("Thank you for your order!");
  });
 
  test("validates required fields", async ({ page }) => {
    await page.goto("/checkout");
    await page.click('button:has-text("Place Order")');
 
    // Check for validation errors
    await expect(page.locator(".error-email")).toBeVisible();
    await expect(page.locator(".error-name")).toBeVisible();
  });
 
  test("handles payment failure", async ({ page }) => {
    // Use test card that triggers failure
    await page.fill('[name="cardNumber"]', "4000000000000002");
    // ... fill other fields
 
    await page.click('button:has-text("Place Order")');
 
    await expect(page.locator(".payment-error")).toHaveText(
      "Your card was declined",
    );
  });
});

Testing Accessibility

Accessibility testing ensures your application works for everyone, including users with disabilities who rely on screen readers, keyboard navigation, or other assistive technologies.

Why automated accessibility testing matters:

  • Catches common issues (missing alt text, low contrast, unlabeled buttons)
  • Runs on every PR/deploy (prevents regressions)
  • WCAG compliance often has legal requirements
  • Improves experience for all users

What automated tests CAN'T catch:

  • Logical reading order
  • Quality of alt text (descriptive vs. useless)
  • Complex interaction patterns
  • Overall user experience with assistive tech

Recommendation: Combine automated tests with manual testing using screen readers (VoiceOver, NVDA, JAWS).

JAVASCRIPT
// e2e/accessibility.spec.js
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
 
test.describe("Accessibility", () => {
  test("homepage has no accessibility violations", async ({ page }) => {
    await page.goto("/");
 
    const results = await new AxeBuilder({ page }).analyze();
 
    expect(results.violations).toEqual([]);
  });
 
  test("form is keyboard accessible", async ({ page }) => {
    await page.goto("/contact");
 
    // Tab through form
    await page.keyboard.press("Tab");
    await expect(page.locator('[name="name"]')).toBeFocused();
 
    await page.keyboard.press("Tab");
    await expect(page.locator('[name="email"]')).toBeFocused();
 
    // Submit with Enter
    await page.keyboard.press("Tab");
    await page.keyboard.press("Tab");
    await page.keyboard.press("Enter");
  });
});

Testing Patterns

These patterns help you write cleaner, more maintainable tests. They're not specific to any testing framework—they're general principles that apply everywhere.

Arrange-Act-Assert (AAA)

The most fundamental testing pattern. Every test should have three distinct phases:

  1. Arrange: Set up the test conditions (create objects, set state)
  2. Act: Perform the action being tested
  3. Assert: Verify the expected outcome

This structure makes tests easy to read and understand at a glance. When a test fails, you immediately know which phase to investigate.

JAVASCRIPT
test("calculates total with discount", () => {
  // Arrange
  const cart = {
    items: [
      { price: 100, quantity: 2 },
      { price: 50, quantity: 1 },
    ],
    discount: 10,
  };
 
  // Act
  const total = calculateCartTotal(cart);
 
  // Assert
  expect(total).toBe(225); // (100*2 + 50) - 10% = 225
});

Given-When-Then (BDD Style)

Behavior-Driven Development (BDD) uses natural language to describe tests. This makes tests readable by non-developers (product managers, QA) and serves as living documentation.

Structure:

  • Given: The initial context/state
  • When: The action or event that triggers behavior
  • Then: The expected outcome

Benefits:

  • Tests read like specifications
  • Encourages thinking from user's perspective
  • Makes test purpose immediately clear
  • Helps identify missing test cases
JAVASCRIPT
describe("Shopping Cart", () => {
  describe("given a cart with items", () => {
    let cart;
 
    beforeEach(() => {
      cart = createCart();
      cart.add({ id: 1, name: "Product", price: 100 });
    });
 
    describe("when applying a valid coupon", () => {
      beforeEach(() => {
        cart.applyCoupon("SAVE20");
      });
 
      test("then the discount is applied", () => {
        expect(cart.discount).toBe(20);
      });
 
      test("then the total reflects the discount", () => {
        expect(cart.total).toBe(80);
      });
    });
 
    describe("when applying an invalid coupon", () => {
      test("then it throws an error", () => {
        expect(() => cart.applyCoupon("INVALID")).toThrow();
      });
    });
  });
});

Test Data Builders

Creating test objects manually is tedious and creates duplication. Test data builders provide factory functions that create objects with sensible defaults, allowing you to override only what matters for each test.

Why use builders:

  • Reduce boilerplate in tests
  • Single place to update when data structure changes
  • Tests focus on what's important (overrides only)
  • Create consistent, realistic test data
JAVASCRIPT
// test/builders/userBuilder.js
export function createUser(overrides = {}) {
  return {
    id: 1,
    email: "test@example.com",
    name: "Test User",
    role: "user",
    createdAt: new Date("2024-01-01"),
    ...overrides,
  };
}
 
export function createAdmin(overrides = {}) {
  return createUser({
    role: "admin",
    permissions: ["read", "write", "delete"],
    ...overrides,
  });
}
 
// Usage in tests
test("admin can delete users", () => {
  const admin = createAdmin();
  const user = createUser({ id: 2 });
 
  const result = deleteUser(admin, user.id);
 
  expect(result.success).toBe(true);
});

Mock Factories

Similar to test data builders, mock factories create reusable mock implementations. Instead of recreating the same mock setup in every test, centralize it in a factory function.

Benefits:

  • Consistent mock behavior across tests
  • Easy to update when API changes
  • Tests are more readable (less setup noise)
  • Can create specialized mocks for different scenarios
JAVASCRIPT
// test/mocks/apiMocks.js
export function mockFetchSuccess(data) {
  return jest.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve(data),
  });
}
 
export function mockFetchError(status, message) {
  return jest.fn().mockResolvedValue({
    ok: false,
    status,
    json: () => Promise.resolve({ error: message }),
  });
}
 
// Usage
test("handles API success", async () => {
  global.fetch = mockFetchSuccess({ users: [{ id: 1 }] });
  // ...
});
 
test("handles API error", async () => {
  global.fetch = mockFetchError(500, "Server error");
  // ...
});

Best Practices

These principles separate good test suites from frustrating ones. Follow them to avoid common pitfalls.

What to Test

The goal of testing is confidence, not coverage. A test suite with 100% coverage that doesn't catch bugs is useless. Focus on testing behavior that matters.

JAVASCRIPT
// ✅ DO test:
// - Business logic
// - User interactions
// - Edge cases
// - Error handling
// - Accessibility
 
// ❌ DON'T test:
// - Implementation details
// - Third-party libraries
// - Framework internals
// - Styling (unless functional)

Test Independence

Each test should be able to run in isolation. Tests that depend on other tests running first are fragile—they break when run in different orders or in parallel.

Signs of dependent tests:

  • Tests pass when run together but fail alone
  • Tests fail randomly (race conditions)
  • Tests modify shared state

The fix: Use beforeEach to set up fresh state for each test.

JAVASCRIPT
// ❌ Tests depend on order
let counter = 0;
 
test("increments", () => {
  counter++;
  expect(counter).toBe(1);
});
 
test("checks value", () => {
  expect(counter).toBe(1); // Fails if run alone!
});
 
// ✅ Tests are independent
describe("Counter", () => {
  let counter;
 
  beforeEach(() => {
    counter = 0;
  });
 
  test("increments", () => {
    counter++;
    expect(counter).toBe(1);
  });
 
  test("starts at zero", () => {
    expect(counter).toBe(0);
  });
});

Descriptive Test Names

Test names should describe the behavior being tested, not the implementation. When a test fails, the name should tell you exactly what broke.

A good test name answers:

  • What is being tested?
  • Under what conditions?
  • What is the expected outcome?
JAVASCRIPT
// ❌ Unclear
test("works", () => {
  /* ... */
});
test("handles error", () => {
  /* ... */
});
 
// ✅ Clear and descriptive
test("calculates 20% discount on orders over $100", () => {
  /* ... */
});
test("displays validation error when email format is invalid", () => {
  /* ... */
});
test("retries failed requests up to 3 times with exponential backoff", () => {
  /* ... */
});

Summary

Test TypeScopeSpeedConfidenceWhen to Use
UnitSingle function/componentFastLowerAlways, for logic
IntegrationMultiple units togetherMediumMediumFeature testing
E2EFull user flowSlowHighCritical paths

Key Principles:

  1. Write tests that provide confidence, not coverage numbers
  2. Test behavior, not implementation
  3. Keep tests fast and independent
  4. Use the right type of test for the job
  5. Tests are documentation—make them readable