Testing Patterns
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
╱╲
╱ ╲ 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
// 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
// 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
// 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/awaitin test functionswaitForfrom Testing Library (wait for condition to be true)- Mock functions like
jest.fn()to simulate API responses mockResolvedValue/mockRejectedValuefor Promise outcomes
// 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
// 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).
// 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).
// 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:
- Arrange: Set up the test conditions (create objects, set state)
- Act: Perform the action being tested
- 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.
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
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
// 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
// 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.
// ✅ 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.
// ❌ 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?
// ❌ 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 Type | Scope | Speed | Confidence | When to Use |
|---|---|---|---|---|
| Unit | Single function/component | Fast | Lower | Always, for logic |
| Integration | Multiple units together | Medium | Medium | Feature testing |
| E2E | Full user flow | Slow | High | Critical paths |
Key Principles:
- Write tests that provide confidence, not coverage numbers
- Test behavior, not implementation
- Keep tests fast and independent
- Use the right type of test for the job
- Tests are documentation—make them readable