unit

Grow a unit with TDD

A checkout-coupon story grown with red–green–refactor. Read the story and the cycle below before the runner files.

SUT: js-counter / python-calc · id: unit.tdd-red-green

Cached CI results from 10/4/2026, 3:02:04 AM (ci · ff16d82)

The story

A shop is about to turn on checkout coupons. Marketing has already published two codes to customers: SAVE10 is always 10% off, and SAVE20 is 20% off only when the cart is at least $50. Unknown or missing codes must not invent a discount — the shopper pays the subtotal they already see.

One developer owns applyCoupon(). There is no designer in this loop and no browser to click. The next rule can be said in a sentence (“SAVE20 does nothing under $50”). That is the condition that makes TDD worth doing: you can name the next behavior, the unit is isolated, and a failing example will come back in milliseconds.

This is not the meeting-room BDD page. The coupon rules live only here. If you cannot yet say what “done” looks like — a spike against a vendor API, an exploratory UI — write a spike first, then come back and drive the settled design with tests.

How TDD is exercised

The files below are the finished green suite. The cycle that produced them is the method — not the runner brand.

  1. Red — no coupon

    Write a test that applyCoupon(80, "") is 80. Run it. It fails because the function does not exist yet, or still throws. That failure is the specification for the first branch.

  2. Green — return the subtotal

    Implement the smallest pass: return subtotal. Do not add SAVE10 yet. The suite is green; you have earned the next example.

  3. Red — SAVE10

    Add applyCoupon(80, "SAVE10") === 72. Run it. It fails. That is the next published rule, written down before the multiply-by-0.9 line exists.

  4. Green — ten percent off

    Handle SAVE10 only. Leave SAVE20 unimplemented. A second green bar means you did not invent the $50 floor early.

  5. Red, then green — SAVE20’s floor

    Add the under-$50 case first (40 stays 40), then the at-or-above case (80 becomes 64). Each test is one rule. Unknown codes stay full price — a guard, not a new product idea.

  6. Refactor

    Tidy names and comments without changing the examples. If a refactor needs a new behavior, that is another red — not a silent edit.

  7. When to stop

    Stop when the next assertion is unclear, the test would couple to incidental structure, or you are using a green suite as proof the product is right. TDD proves the unit you named, not the shop.

Technical implementation

The tabs below are the runnable artifacts — tool primer, code under test, and the examples that CI captured. Read the story and the exercise steps first so the files have a job.

Testing tool

Vitest · Unit / component test runner (Vite-native) · MIT

Vitest is a fast test runner for JavaScript and TypeScript. Its API matches Jest (describe, it, expect, vi.fn), so teams can move between the two with little rewrite. It runs in Vite’s transform pipeline, which keeps ESM and modern syntax cheap.

A runner process loads each test file, executes describe/it blocks, and reports pass/fail. Assertions come from expect(). The SUT is imported as a normal module — no HTTP server, no browser. Mocks live on vi (vi.fn, vi.mock).

Testing architecture

Unit tests call one function (or a small graph of functions) in the same process. They avoid I/O so failures point at logic, not the environment. The SUT is imported; the runner never starts Express or a browser.

applyCoupon() / apply_coupon() is imported in-process (no HTTP, no browser). The examples in the test files follow the cycle above. Conditions that make this effective: one owner of the design, fast feedback, and a behavior you can name in a sentence.

SUT: js-counter / python-calc · samples/js-counter/src/index.js · run npm test in examples/unit/tdd-red-green/vitest

Code under test · samples/js-counter/src/index.js
/** Tiny shared system under test for unit/UX demos. */

/**
 * Add two numbers and return their sum.
 * @param {number} a - First operand
 * @param {number} b - Second operand
 */
export function add(a, b) {
  // Pure arithmetic — no I/O, no side effects
  return a + b;
}

/**
 * Keep n inside the inclusive range [min, max].
 * @param {number} n - Value to clamp
 * @param {number} min - Lower bound
 * @param {number} max - Upper bound
 */
export function clamp(n, min, max) {
  // Guard: a flipped range is a caller bug, not a silent no-op
  if (min > max) {
    throw new Error("min must be <= max");
  }
  // Raise floor first, then lower the ceiling
  return Math.min(max, Math.max(min, n));
}

/**
 * Apply a region tax rate from an injected service.
 * @param {{ getTaxRate: (code: string) => number }} taxService - Collaborator that owns tax rates
 * @param {number} amount - Pre-tax amount
 * @param {string} region - Region code passed to the tax service
 */
export function priceWithTax(taxService, amount, region) {
  // Ask the dependency for the rate (easy to mock in unit tests)
  const rate = taxService.getTaxRate(region);
  // Gross = net × (1 + rate), e.g. 100 at 10% → 110
  return amount * (1 + rate);
}

/**
 * Apply a published checkout coupon to a subtotal.
 * Grown via TDD in unit.tdd-red-green (this scenario only — BDD uses reserveRoom).
 * @param {number} subtotal - Cart total before the coupon
 * @param {string} [coupon] - Published code, or empty / unknown
 */
export function applyCoupon(subtotal, coupon) {
  if (typeof subtotal !== "number" || subtotal < 0) {
    throw new Error("subtotal must be a non-negative number");
  }
  // SAVE10: always 10% off
  if (coupon === "SAVE10") {
    return subtotal * 0.9;
  }
  // SAVE20: 20% off only when the cart is at least 50
  if (coupon === "SAVE20" && subtotal >= 50) {
    return subtotal * 0.8;
  }
  // Missing, unknown, or SAVE20-below-minimum → pay the original subtotal
  return subtotal;
}

/**
 * Reserve seats in a meeting room.
 * Specified in Gherkin in unit.bdd-given-when-then (this scenario only — TDD uses applyCoupon).
 * @param {number} partySize - Guests who want the room
 * @param {number} seatsFree - Seats still open
 * @returns {{ confirmed: boolean, seatsFree: number }}
 */
export function reserveRoom(partySize, seatsFree) {
  if (typeof partySize !== "number" || partySize < 1) {
    throw new Error("partySize must be at least 1");
  }
  if (typeof seatsFree !== "number" || seatsFree < 0) {
    throw new Error("seatsFree must be a non-negative number");
  }
  if (partySize > seatsFree) {
    return { confirmed: false, seatsFree };
  }
  return { confirmed: true, seatsFree: seatsFree - partySize };
}
Test · examples/unit/tdd-red-green/vitest/coupon.test.js · MIT · run in examples/unit/tdd-red-green/vitest: npm test
// TDD cycle: red (failing example) → green (smallest pass) → refactor.
// These tests are the order the coupon rules were added — not a dump after the fact.
import { describe, it, expect } from "vitest";
import { applyCoupon } from "../../../../samples/js-counter/src/index.js";

describe("applyCoupon (TDD)", () => {
  // Red #1: no published code → pay what you already owed
  it("leaves the subtotal unchanged when there is no coupon", () => {
    expect(applyCoupon(80, "")).toBe(80);
  });

  // Red #2: first real rule — SAVE10 is always 10% off
  it("applies SAVE10 as ten percent off", () => {
    expect(applyCoupon(80, "SAVE10")).toBe(72);
  });

  // Red #3: SAVE20 only after a $50 cart (below the floor stays full price)
  it("ignores SAVE20 when the subtotal is under 50", () => {
    expect(applyCoupon(40, "SAVE20")).toBe(40);
  });

  // Green follow-up for the same rule: at/above the floor, 20% off
  it("applies SAVE20 as twenty percent off when the subtotal is at least 50", () => {
    expect(applyCoupon(80, "SAVE20")).toBe(64);
  });

  // Guard: unknown marketing codes must not invent a discount
  it("ignores an unknown coupon code", () => {
    expect(applyCoupon(80, "NOSUCH")).toBe(80);
  });
});