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.
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.
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.
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.
Green — ten percent off
Handle SAVE10 only. Leave SAVE20 unimplemented. A second green bar means you did not invent the $50 floor early.
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.
Refactor
Tidy names and comments without changing the examples. If a refactor needs a new behavior, that is another red — not a silent edit.
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);
});
});
Jest is the long-standing default runner in many Node and React codebases. It bundles a runner, assertion library, mocking (jest.fn), and snapshot testing. This repo uses ESM via NODE_OPTIONS=--experimental-vm-modules.
Jest discovers test files, wraps them in a VM, and provides describe/it/expect as globals (or via @jest/globals in ESM). The SUT is imported in-process. Isolation is per-file by default, not a real network hop.
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/jest
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/jest/coupon.test.js · MIT
· run in examples/unit/tdd-red-green/jest: 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 { 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);
});
});
pytest is the usual Python runner: files named test_*.py, functions named test_*, rich fixtures, and first-class parametrize. Assertions are plain assert statements that pytest rewrites with better diffs.
pytest collects test functions, optionally injects fixtures, and reports failures with the rewritten assertion. For unit examples here, the SUT is imported from samples/python-calc after sys.path is adjusted. No HTTP is involved.
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/python-calc/calc.py · run pytest in examples/unit/tdd-red-green/pytest
Code under test · samples/python-calc/calc.py
"""Tiny Python twin of the JS counter SUT."""
from typing import Optional
def add(a: float, b: float) -> float:
# Pure arithmetic — no I/O, no side effects
return a + b
def clamp(n: float, min_value: float, max_value: float) -> float:
# Guard: a flipped range is a caller bug, not a silent no-op
if min_value > max_value:
raise ValueError("min must be <= max")
# Raise floor first, then lower the ceiling
return min(max_value, max(min_value, n))
def price_with_tax(tax_service, amount: float, region: str) -> float:
# Ask the dependency for the rate (easy to mock in unit tests)
rate = tax_service.get_tax_rate(region)
# Gross = net × (1 + rate), e.g. 100 at 10% → 110
return amount * (1 + rate)
def apply_coupon(subtotal: float, coupon: Optional[str] = None) -> float:
"""Apply a published checkout coupon. Twin of JS applyCoupon()."""
if subtotal < 0:
raise ValueError("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" and subtotal >= 50:
return subtotal * 0.8
# Missing, unknown, or SAVE20-below-minimum → pay the original subtotal
return subtotal
def reserve_room(party_size: int, seats_free: int) -> dict:
"""Reserve seats. Twin of JS reserveRoom() — used only by the BDD scenario."""
if party_size < 1:
raise ValueError("partySize must be at least 1")
if seats_free < 0:
raise ValueError("seatsFree must be a non-negative number")
if party_size > seats_free:
return {"confirmed": False, "seats_free": seats_free}
return {"confirmed": True, "seats_free": seats_free - party_size}
Test · examples/unit/tdd-red-green/pytest/test_coupon.py · MIT
· run in examples/unit/tdd-red-green/pytest: pytest
# 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 sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(ROOT / "samples" / "python-calc"))
from calc import apply_coupon
def test_no_coupon_leaves_the_subtotal():
# Red #1: no published code → pay what you already owed
assert apply_coupon(80, "") == 80
def test_save10_is_ten_percent_off():
# Red #2: first real rule — SAVE10 is always 10% off
assert apply_coupon(80, "SAVE10") == 72
def test_save20_ignored_below_minimum():
# Red #3: SAVE20 only after a $50 cart
assert apply_coupon(40, "SAVE20") == 40
def test_save20_is_twenty_percent_off_at_minimum():
# Green follow-up for the same rule: at/above the floor, 20% off
assert apply_coupon(80, "SAVE20") == 64
def test_unknown_coupon_is_ignored():
# Guard: unknown marketing codes must not invent a discount
assert apply_coupon(80, "NOSUCH") == 80
Python twin uses samples/python-calc.apply_coupon — same cycle, same examples.