Testing Fundamentals: Why We Test and How Tests Are Organized

Understand why automated testing matters for React applications, the differences between unit, integration, and end-to-end tests, the idea of the test pyramid, and the core vocabulary of isolation, mocks, stubs, spies, and fixtures that every testing framework builds on.

Before writing a single test with a specific tool, you need a mental model of what testing actually buys you and what kinds of tests exist. Every testing framework you will ever use — Vitest, Jest, Playwright, Cypress — is just an implementation of a small number of underlying ideas: run some code, check that it behaved as expected, and report the result. This lesson builds that mental model so that when you pick up any tool in Module CX and beyond, the vocabulary and structure already make sense.

Why Testing Matters

A test is a small program that runs another program and checks its behavior automatically, instead of a human clicking through the UI by hand. The value of this is not abstract correctness for its own sake — it is speed and confidence. A test suite lets you change code and find out in seconds whether you broke something, rather than finding out days later from a bug report or, worse, from a user in production.

In a React application specifically, testing matters because components are composed of other components, hooks depend on other hooks, and state flows in ways that are easy to reason about individually but hard to hold in your head all at once. A refactor of a deeply nested component tree can silently break a sibling feature that shares state or context. Tests are the mechanism that catches this before it ships.

Tests also serve as executable documentation. A well-written test for a useCart hook or a PriceTag component tells a future developer — including future you — exactly what that piece of code is supposed to do, in a form that cannot go stale the way a comment can, because if the code's behavior changes without the test being updated, the test fails.

• Catches regressions automatically when code changes • Gives confidence to refactor without fear of silently breaking behavior • Documents expected behavior in a form that must stay accurate or fail • Shortens the feedback loop from 'ship and hope' to 'run and know' • Forces you to design code that is easier to isolate and reason about

Unit Tests

A unit test exercises the smallest testable piece of code in isolation — a single pure function, a single hook, or a single component rendered on its own — with every external dependency replaced by a controlled substitute. If you are testing a formatCurrency function, a unit test calls it with specific inputs and asserts specific outputs; it does not spin up a server, a database, or a browser.

Unit tests are cheap to write, run in milliseconds, and pinpoint failures precisely: when a unit test fails, you know almost exactly which function or component is wrong, because everything around it was held constant. This precision is what makes unit tests the foundation of a healthy test suite.

In React, a unit test typically targets a pure utility function, a custom hook tested with a hook-testing utility, or a single component rendered with all of its props and context supplied directly by the test rather than by a real parent tree.

Integration Tests

An integration test checks that multiple units work correctly together. Instead of testing useCart and CartSummary separately, an integration test might render a <CartPage> component that uses both, and verify that adding an item through the UI updates the total shown on screen. It exercises real collaboration between pieces, with only the true external boundaries — network calls, timers, browser APIs — replaced by test doubles.

Integration tests catch a category of bug that unit tests structurally cannot: bugs in how pieces communicate. Two components can each pass their own unit tests perfectly and still be broken together, for example if one passes a prop with the wrong shape that the other silently mishandles.

In a React codebase, most of the tests that give you real day-to-day confidence about features tend to be integration tests written with a library like React Testing Library, which renders a subtree and interacts with it the way a user would — clicking buttons, typing into inputs, and asserting on what appears on screen.

End-to-End (E2E) Tests

An end-to-end test drives the entire application exactly as a real user would: a real browser loads the real built application, talks to a real (or realistically staged) backend, and the test clicks through an actual user journey — for example, logging in, adding a product to a cart, and completing checkout — then asserts on what actually appears on the page.

E2E tests give the highest confidence that the system works as a whole, because nothing is mocked away; they catch problems that only show up when the frontend, backend, routing, and build all interact for real. That same completeness is also their cost: they are slow (seconds to minutes per test), can be flaky due to timing and network variability, and a failure only tells you that something, somewhere in a large system, is wrong — not precisely what.

Tools like Playwright and Cypress are the standard choices for E2E testing in modern web applications, and they are typically run less frequently than unit and integration tests — for example, once before a deploy — rather than on every keystroke during development.

The Test Pyramid

The test pyramid is a shorthand for how to balance these three kinds of tests. It recommends having many fast, cheap unit tests at the base, a moderate number of integration tests in the middle, and a small number of slow, expensive end-to-end tests at the top. The shape captures both cost and confidence: as you move up the pyramid, each individual test gives you more real-world confidence but costs more to write, run, and maintain.

The reasoning is economic, not moral. If your entire suite were E2E tests, it would take unacceptably long to run and would be brittle — a single unrelated CSS change could break a dozen unrelated journeys. If your entire suite were unit tests, you would have blazing-fast feedback but very little confidence that the pieces actually work together in the running application. The pyramid shape is the empirically useful compromise: broad coverage of individual behavior, moderate coverage of collaboration, and a thin layer of full-system smoke tests for the journeys that matter most.

The diagram below shows the shape: End-to-End tests are the narrow tip because there should be relatively few of them; Integration tests form a wider middle band; Unit tests form the broad base because they should be the most numerous.

Isolation

Isolation is the practice of testing a piece of code without the influence of its real collaborators, so that a failing test points at exactly one thing. If OrderSummary depends on a formatPrice utility and an API client, an isolated test of OrderSummary replaces both of those with controlled substitutes rather than letting the real network call happen or the real formatting logic run unmonitored.

Isolation is a spectrum, not a binary. A strict unit test isolates almost everything; an integration test deliberately keeps some real collaboration and only isolates the true external boundary, such as the network. Choosing the right amount of isolation for a given test is itself a skill — isolate too much and your tests stop catching real bugs; isolate too little and your tests become slow and hard to diagnose.

Mocks, Stubs, Spies, and Fixtures

These four words describe the different kinds of test doubles — stand-ins for real dependencies — and they are often used loosely, but they mean distinct things. A stub is a replacement that returns canned, pre-programmed data when called, with no concern for whether or how it was called; you use a stub when you just need a function to return something so the rest of the code under test can run, for example a stub fetchUser that always returns a fixed user object.

A mock is a replacement that additionally records how it was called — with what arguments, how many times — so the test can assert on the interaction itself, not just its return value. You use a mock when the behavior you care about is 'did this get called correctly,' such as asserting that onSubmit was called exactly once with the form's values.

A spy wraps a real function (or the real implementation stays in place) while still recording calls to it, so you get real behavior plus the ability to assert on how it was used. Spies are useful when you want the genuine logic to execute but also want to verify a side effect, such as confirming that a real saveDraft function was invoked after a debounce delay.

A fixture is fixed, predetermined test data used to set up a known starting state — a sample user object, a sample array of products, a sample API response payload. Fixtures exist so tests are deterministic: instead of generating random or live data, every test run starts from exactly the same known inputs, which is what makes a failing test reproducible.

• Stub — returns canned data; you don't care how it was called • Mock — records calls and lets you assert on how it was invoked • Spy — wraps real behavior while also recording calls to it • Fixture — fixed, reusable sample data for deterministic setup

Code Examples

The same underlying behavior — a 25% discount producing $75 — is verified three times at three different levels. The unit test is fastest and pinpoints the math. The integration test confirms the component wires the function to the DOM correctly. The E2E test confirms the entire real system, including promo-code entry and network calls, actually produces the right number for a real user. Notice the cost gradient: the unit test needs no rendering at all, the integration test renders a component tree in a simulated DOM, and the E2E test needs a running application and a real browser engine. This is exactly the pyramid's economic argument made concrete.

expect(applyDiscount(100, 25)).toBe(75);

All three tests exercise the same notifyUser function against the same EmailClient interface, but each test double plays a different role. The stub only needs to return true so the code path completes. The mock replaces the whole dependency AND records every call so the test can assert on arguments and call count. The spy keeps the real send logic running (so to.includes("@") genuinely executes) while still recording the call for assertions — the best of both when you want real behavior with observability.

const sendSpy = vi.spyOn(realClient, "send");

Common mistake: A common beginner mistake is reaching for a mock everywhere, even when a simple stub would do — this makes tests brittle, because they start failing on call-count or argument changes that have nothing to do with the behavior actually being verified.

Instead of constructing a fresh, slightly-different user object inline in every test (which invites typos and inconsistency), the fixtures live in one shared file and are imported wherever needed. Every test run starts from exactly the same known User shapes, so a failure is reproducible and not the result of random or ad hoc test data.

export const adminUserFixture: User = { id: "u_001", name: "Priya Shah", role: "admin" };

// --- discount.ts -----------------------------------------------------
export function applyDiscount(price: number, percentOff: number): number {
  if (percentOff < 0 || percentOff > 100) {
    throw new Error("percentOff must be between 0 and 100");
  }
  return Math.round(price * (1 - percentOff / 100) * 100) / 100;
}

// --- UNIT TEST: tests the pure function in total isolation ------------
import { describe, it, expect } from "vitest";
import { applyDiscount } from "./discount";

describe("applyDiscount (unit)", () => {
  it("reduces the price by the given percentage", () => {
    expect(applyDiscount(100, 25)).toBe(75);
  });

  it("rejects an out-of-range percentage", () => {
    expect(() => applyDiscount(100, 150)).toThrow();
  });
});

// --- INTEGRATION TEST: renders the component that USES the function ---
import { render, screen } from "@testing-library/react";
import { PriceTag } from "./PriceTag"; // internally calls applyDiscount

describe("PriceTag (integration)", () => {
  it("shows the discounted price on screen", () => {
    render(<PriceTag basePrice={100} percentOff={25} />);
    expect(screen.getByText("$75.00")).toBeInTheDocument();
  });
});

// --- E2E TE
…
import { describe, it, expect, vi } from "vitest";

interface EmailClient {
  send(to: string, subject: string): Promise<boolean>;
}

async function notifyUser(client: EmailClient, address: string) {
  const ok = await client.send(address, "Welcome!");
  return ok ? "sent" : "failed";
}

describe("notifyUser", () => {
  it("STUB: returns canned data, we only check the return value", async () => {
    const stubClient: EmailClient = {
      send: async () => true, // always succeeds, no call recording
    };
    expect(await notifyUser(stubClient, "[email protected]")).toBe("sent");
  });

  it("MOCK: asserts on HOW the dependency was called", async () => {
    const mockSend = vi.fn().mockResolvedValue(true);
    const mockClient: EmailClient = { send: mockSend };

    await notifyUser(mockClient, "[email protected]");

    expect(mockSend).toHaveBeenCalledTimes(1);
    expect(mockSend).toHaveBeenCalledWith("[email protected]", "Welcome!");
  });

  it("SPY: wraps the real implementation but still records calls", async () => {
    const realClient: EmailClient = {
      send: async (to, subject) => to.includes("@"),
    };
    const sendSpy = vi.spyOn(realClient, "send");

    const result = await notifyUser(re
…
// --- fixtures/users.ts -------------------------------------------------
export interface User {
  id: string;
  name: string;
  role: "admin" | "member";
}

export const adminUserFixture: User = {
  id: "u_001",
  name: "Priya Shah",
  role: "admin",
};

export const memberUserFixture: User = {
  id: "u_002",
  name: "Diego Ramirez",
  role: "member",
};

// --- usePermissions.test.ts ---------------------------------------------
import { describe, it, expect } from "vitest";
import { canDeletePost } from "./permissions";
import { adminUserFixture, memberUserFixture } from "./fixtures/users";

describe("canDeletePost", () => {
  it("allows an admin fixture to delete", () => {
    expect(canDeletePost(adminUserFixture)).toBe(true);
  });

  it("forbids a member fixture from deleting", () => {
    expect(canDeletePost(memberUserFixture)).toBe(false);
  });
});

The Test Pyramid

Width represents how many tests you should have at each level; height (roughly) represents how much of the running system each test exercises. Most tests should live at the wide unit base; the fewest, slowest tests belong at the narrow E2E tip.

/\
                /  \
               / E2E \          <- few, slow, high confidence,
              /--------\           full system, real browser
             /          \
            / Integration \     <- moderate count, medium speed,
           /----------------\      checks components working together
          /                  \
         /       Unit          \  <- many, fast, cheap,
        /----------------------\    tests one function/component alone

Important Rules

Prefer many small unit tests over few large ones — a focused test that fails tells you exactly what broke.

Use integration tests for the features and user flows you most want to protect against regression.

Keep end-to-end tests few and reserved for your most critical, revenue- or trust-affecting journeys.

Reach for a stub when you only need a return value, a mock when you need to assert on a call, and a spy when you need both real behavior and observability.

Keep fixtures in shared files so multiple tests reuse identical, known-good sample data.

A test suite shaped like an inverted pyramid (mostly E2E, few unit tests) is a warning sign, not a badge of thoroughness.

How real teams shape their suites

Frontend teams at companies with mature testing practices typically report suite compositions close to the pyramid shape: thousands of unit tests running in well under a minute, a few hundred integration tests running in a couple of minutes, and a few dozen E2E tests reserved for critical flows and run once per deploy in CI rather than on every save. When a production incident occurs, the standard remediation is not 'add an E2E test' by default — it's to first ask whether the bug could have been caught by a fast unit or integration test, and only reach for E2E when the bug genuinely only manifests from real cross-system interaction.

Writing almost exclusively end-to-end tests because they 'feel most real,' resulting in a 40-minute test suite that developers stop running locally.

Push most coverage down to fast unit and integration tests, and reserve E2E tests for the handful of journeys where full-system confidence is worth the slowness — for example, checkout or sign-up.

An inverted pyramid gives you the illusion of thorough coverage while making the feedback loop so slow that people avoid running the suite, which defeats the purpose of having tests at all.

Using a mock to assert that a formatting function was 'called with the right arguments' when all you actually care about is its return value.

Use a plain stub (or just call the real pure function) and assert on the output value itself.

Asserting on call mechanics for something that is really about output ties the test to implementation details, so a harmless refactor (like inlining the call) breaks a test that never needed to know how the value was produced.

[concept] What is the primary reason the test pyramid recommends more unit tests than E2E tests? A. Unit tests are considered more 'correct' in principle than E2E tests B. Unit tests are cheap and fast, so you can afford many of them, while E2E tests are slow and expensive, so you should have few C. E2E tests cannot be automated D. Unit tests replace the need for any manual QA

B. Unit tests are cheap and fast, so you can afford many of them, while E2E tests are slow and expensive, so you should have few — The pyramid shape reflects an economic tradeoff: unit tests run in milliseconds and are cheap to maintain, so you can have thousands of them, while E2E tests are slow and comparatively brittle, so they should be reserved for the highest-value, full-system journeys.

A test replaces a dependency with a fake that always returns the same fixed value, and the test never checks whether or how that fake was called. What kind of test double is this? A. Mock B. Spy C. Stub D. Fixture

C. Stub — A stub returns canned data on request with no concern for how it was invoked. Recording and asserting on invocations is what distinguishes a mock (and, differently, a spy) from a plain stub.

Which statement correctly distinguishes an integration test from a unit test? A. An integration test never touches React components, only a unit test does B. An integration test verifies that multiple units work together correctly, while a unit test verifies one piece in isolation C. Integration tests are always slower to write than E2E tests D. There is no real difference; the terms are interchangeable

B. An integration test verifies that multiple units work together correctly, while a unit test verifies one piece in isolation — Integration tests deliberately let real collaborators interact (for example, a component and the hook it uses) while still isolating the true external boundary, such as the network — this is what lets them catch bugs in how pieces communicate that isolated unit tests cannot.

In the testing pyramid diagram, why is the End-to-End layer drawn as the narrowest section at the top? A. Because E2E tests are the least important tests to write B. Because there should be the fewest E2E tests relative to unit and integration tests, due to their cost and slowness C. Because E2E tests only test a narrow part of the codebase D. Because E2E tests are always written last, so they appear at the top of the file

B. Because there should be the fewest E2E tests relative to unit and integration tests, due to their cost and slowness — Width in the pyramid diagram represents relative test count, not importance. E2E tests give the highest system-level confidence but are the slowest and most expensive, so the recommended quantity is small — hence the narrow top.

You are adding a 'apply coupon code' feature to a shopping cart. List which parts of the feature you would test at the unit level, which at the integration level, and which (if any) deserve a single end-to-end test. Justify each choice in one sentence.

Unit: the pure validateCouponCode and computeDiscountedTotal functions, tested directly with valid, invalid, and boundary inputs. Integration: a <CouponForm> component test that types a code, submits it, and asserts the displayed total updates, with the network call stubbed. End-to-end: one test that goes from the real product page through applying a real promo code to seeing the discounted total at checkout, because this is a high-value journey worth the cost of full-system coverage.

Start from the pure logic (validating and computing a discount), then the component that wires that logic to the UI, then the full user journey through a real browser.