Exercises — TDD — Red-Green-Refactor cycle
Before we start, one shared vocabulary reminder built from zero, so no word is used before it is earned:
Figure 1 — the Red-Green-Refactor loop (map of this whole page). Alt text: three coloured circles labelled RED (top), GREEN (lower-left), REFACTOR (lower-right), joined by gray arrows flowing clockwise Red → Green → Refactor → back to Red.

Read Figure 1 like this: the three coloured circles are the phases, and the gray arrows always flow the same way — from Red (write a failing test, top) down to Green (least code to pass, lower-left), across to Refactor (clean while staying green, lower-right), then back up to Red for the next tiny behavior. You never jump backwards and you never skip a circle; that unbroken loop is the discipline these exercises drill.
Because "triangulation" is the trickiest idea and it appears from Level 2 onward, here is the picture of it before you need it:
Figure 2 — triangulation: one test is ambiguous, two pin the rule. Alt text: a graph of input n vs expected output; a blue dot at (2,4) sits on many dashed gray lines (wrong rules); an orange dot at (3,9) leaves only the solid green curve n·n as the survivor.

Read Figure 2 like this: one example (the blue dot) can be "explained" by infinitely many wrong rules — the dashed gray lines all pass through it, so a single test like square(2)==4 is satisfied even by the fake return 4. Add a second example (the orange dot) and only one line survives (the solid green line) — the honest rule n*n. That "two points pin down the line" is exactly what triangulation means: small concrete examples that force the code to generalize instead of hardcode.
Level 1 — Recognition (can you name the parts?)
L1-Q1 — Which phase?
For each action, name the phase it belongs to: Red, Green, or Refactor.
- You rename
xtototal_priceand re-run the suite; still green. - You write
assert cart_total([]) == 0for a function that doesn't exist yet. - You add
return 0so an empty-cart test stops failing.
Recall Solution
- Refactor — renaming changes readability, not behavior; you did it only while green.
- Red — you wrote a failing test for behavior that doesn't exist. (It fails because the function/return is missing — the "right reason".)
- Green — the minimum code (even a hardcoded
0) to make a specific test pass.
L1-Q2 — Right reason to fail?
A test fails with ImportError: cannot import name 'divide'. Is this a valid Red or a broken Red?
Recall Solution
Broken Red. A valid Red fails because an assertion is false — the behavior is genuinely missing or wrong. An ImportError/typo means the test never even ran the assertion, so it proves nothing about your logic. Fix: create the stub (def divide(a, b): pass) so the test runs and fails on the assertion. Only then is Red trustworthy.
Level 2 — Application (run the cycle yourself)
L2-Q3 — First two cycles of square(n)
Drive a function square(n) that returns n*n, using triangulation (the two-dots idea from Figure 2 — small examples that force generality). Write: Cycle-1 Red test, Cycle-1 Green code, Cycle-2 Red test, Cycle-2 Green code.
Recall Solution
Cycle 1 — Red (smallest behavior — the blue dot):
def test_square_of_2_is_4():
assert square(2) == 4Cycle 1 — Green (fake it, minimum to pass — one of the dashed wrong lines):
def square(n):
return 4 # hardcoded — passes the single testCycle 2 — Red (add the orange dot; the hardcode must break):
def test_square_of_3_is_9():
assert square(3) == 9This fails: square(3) returns 4, not 9. Good — the second example triangulates (now only the honest rule survives).
Cycle 2 — Green (the solid green line — simplest thing passing both):
def square(n):
return n * nBoth tests pass. We generalized only because a second test forced us to.
L2-Q4 — Turn a bug into a regression guard (and cover the edge cases)
percent(50, 200) should return 25.0 (50 is 25% of 200), but the current code crashes on percent(5, 0). Write the Red test that captures the divide-by-zero, then the Green fix (raise ValueError). Then also drive three more boundaries with their own failing tests: a negative whole, a negative part, and a non-numeric input — each must raise ValueError. Give the value of percent(50, 200).
Recall Solution
Red — reproduce the zero bug as a test (note the import pytest at the top of the test file):
import pytest
def test_percent_of_zero_raises():
with pytest.raises(ValueError):
percent(5, 0)Fails now: the code raises ZeroDivisionError, not ValueError.
Red — the three extra boundaries (each a separate failing test, driven one at a time):
import pytest
def test_percent_negative_whole_raises():
with pytest.raises(ValueError):
percent(5, -10)
def test_percent_negative_part_raises():
with pytest.raises(ValueError):
percent(-50, 200)
def test_percent_non_numeric_raises():
with pytest.raises(ValueError):
percent("five", 200)Green — minimal guards covering every edge case:
def percent(part, whole):
if not isinstance(part, (int, float)) or not isinstance(whole, (int, float)):
raise ValueError("part and whole must be numbers")
if whole <= 0:
raise ValueError("whole must be positive")
if part < 0:
raise ValueError("part cannot be negative")
return part / whole * 100- The type guard rejects
"five"before any arithmetic (non-numeric input). whole <= 0catches bothwhole == 0(the original divide-by-zero) andwhole < 0(nonsense).part < 0rejects a negative amount, which has no meaning here. A valid call: . All four bug/boundary tests stay in the suite forever — regression guards, so none of these cases can silently return.
Level 3 — Analysis (spot what went wrong)
L3-Q5 — Diagnose the broken cycle
A developer writes production code first, then a test that passes on the first run without ever going red. What guarantee of TDD (Test-Driven Development) did they lose, and what's the concrete risk?
Recall Solution
They lost the "prove the test can fail" guarantee. Because it never went Red, they never learned the test has teeth. Concrete risk: the test might assert something already-true-by-accident (e.g. asserting f(x) is not None when the function can't return None anyway). Such a test will never catch a future bug — it's a silent pass. Fix: temporarily break the production code (or comment out the fix) to confirm the test goes red, then restore.
L3-Q6 — Is this a legal Refactor?
Tests are green. A developer changes if b == 0: raise ValueError to if b == 0: raise ValueError and also adds a new rule: negative divisors now return None. They run tests — still green (no test covers negatives). Is this a valid Refactor?
Recall Solution
No. Refactor means change structure, not observable behavior. Adding "negatives return None" is new behavior, so it must be driven by its own Red test first — it is not a refactor at all. The suite staying green only means the behavior is untested, not that it's unchanged. Correct move: write a failing test specifying what negatives should do, then Green, then optionally Refactor.
L3-Q7 — Why did the giant test hurt?
A 40-line test exercises login, cart, and checkout in one function. It fails. Explain, using the idea of failure localization, why this is worse than three small tests.
Recall Solution
A failing test tells you the whole block is broken but not which behavior. With one behavior per test, a red result names the culprit directly ("checkout total wrong"). The giant test collapses three independent signals into one bit of information, so you must debug manually to find the sub-fault — exactly the pain TDD's tiny loops remove. Granularity = diagnostic power.
Level 4 — Synthesis (drive a whole small feature)
L4-Q8 — Drive fizzbuzz for one number, all four rules, showing every cycle
Using the parent note's rules (multiples of 3 → "Fizz", of 5 → "Buzz", of both → "FizzBuzz", else the number as a string), drive fizzbuzz(n) by explicit Red → Green cycles for inputs 4, 6, 10, 15, then do the closing Refactor and state the output for each input.
Recall Solution
Cycle 1 — Red (plain number case):
def test_4(): assert fizzbuzz(4) == "4"Cycle 1 — Green (fake it — the simplest pass):
def fizzbuzz(n):
return "4"Cycle 2 — Red (force generalization off the hardcode):
def test_6(): assert fizzbuzz(6) == "Fizz"Cycle 2 — Green (add the Fizz branch; plain numbers now use str(n)):
def fizzbuzz(n):
if n % 3 == 0: return "Fizz"
return str(n)Cycle 3 — Red (drive the Buzz rule):
def test_10(): assert fizzbuzz(10) == "Buzz"Cycle 3 — Green:
def fizzbuzz(n):
if n % 3 == 0: return "Fizz"
if n % 5 == 0: return "Buzz"
return str(n)Cycle 4 — Red (drive the both-rules case — this one breaks the code above, which would return "Fizz" for 15):
def test_15(): assert fizzbuzz(15) == "FizzBuzz"Cycle 4 — Green (special-case both first):
def fizzbuzz(n):
if n % 3 == 0 and n % 5 == 0: return "FizzBuzz"
if n % 3 == 0: return "Fizz"
if n % 5 == 0: return "Buzz"
return str(n)Refactor (all four tests green — remove the branchy duplication, behavior identical, so no new test needed):
def fizzbuzz(n):
out = ""
if n % 3 == 0: out += "Fizz"
if n % 5 == 0: out += "Buzz"
return out or str(n)Outputs: fizzbuzz(4)="4", fizzbuzz(6)="Fizz", fizzbuzz(10)="Buzz", fizzbuzz(15)="FizzBuzz". The or str(n) line handles the "neither rule fired" case — the fully general branch. Because the refactor left every test green, it is provably safe.
L4-Q9 — Drive is_leap_year, one cycle at a time
Build is_leap_year(y) (rule: divisible by 4, except centuries not divisible by 400) with explicit Red → Green cycles, simplest case first, then the final code and the results for 1900, 2000, 2004, 2001.
Recall Solution
Cycle 1 — Red (the base "not divisible by 4 → no" case):
def test_2001(): assert is_leap_year(2001) == FalseCycle 1 — Green (fake it — the simplest pass):
def is_leap_year(y):
return FalseCycle 2 — Red (force the % 4 rule):
def test_2004(): assert is_leap_year(2004) == TrueCycle 2 — Green:
def is_leap_year(y):
return y % 4 == 0Cycle 3 — Red (century exception — 1900 is divisible by 4 but is NOT a leap year):
def test_1900(): assert is_leap_year(1900) == FalseCycle 3 — Green:
def is_leap_year(y):
if y % 100 == 0: return False
return y % 4 == 0Cycle 4 — Red (exception-to-the-exception — 2000 is a century but divisible by 400, so it IS leap):
def test_2000(): assert is_leap_year(2000) == TrueCycle 4 — Green (final):
def is_leap_year(y):
if y % 400 == 0: return True
if y % 100 == 0: return False
return y % 4 == 0Refactor: nothing to clean — three tidy guards, all four tests green.
Results: 1900 → False, 2000 → True, 2004 → True, 2001 → False. Each cycle's Red test broke the too-simple version, driving exactly one new branch.
Level 5 — Mastery (senior-level judgment)
L5-Q10 — When is a Test Double the right call?
Your code under test calls a live payment API. Writing a Red test that hits the real API is slow and flaky. What TDD-compatible technique lets you keep tiny fast cycles, and what's the risk you must offset?
Recall Solution
Use a test double — a stand-in object that replaces the real dependency. A stub returns a canned answer (e.g. "payment OK") so your logic can run without the network; a mock additionally asserts it was called correctly (right amount, right card). This keeps the Red-Green loop in seconds instead of network round-trips, and removes flakiness (no real API to time-out or rate-limit). Risk to offset: doubles test your code against your assumptions about the API, not the real thing — if the real API changes, your green suite won't notice. Offset it with a small number of slower integration tests (run in Continuous Integration) that hit a sandbox environment and run less often. Net result: TDD stays fast on doubles, integration keeps you honest against reality.
L5-Q11 — Refactor pressure vs. design
During Green you notice fizzbuzz and a new fizzbuzzbang share 80% of their logic. TDD says "refactor on green." Which SOLID idea guides how you extract, and what must never change while you do it?
Recall Solution
Guiding idea: Single Responsibility / Open-Closed — extract the shared "apply divisibility rules" logic into one place so each rule is added without editing the core (open for extension, closed for modification). What must never change during the extraction: observable behavior — every existing test stays green at every micro-step. If a test goes red, the refactor introduced a behavior change and must be reverted or re-driven with a new test.
L5-Q12 — The economics argument, quantified
A defect costs 1 unit if caught in the Red step and roughly 100 units in production (a common order-of-magnitude figure). If TDD catches 9 of every 10 defects at Red and lets 1 slip to production, what is the average cost per defect, and how does that compare to catching all 10 in production?
Recall Solution
- With TDD: defects at unit + defect at units units for defects.
- Average per defect with TDD units.
- All in production: units, average units.
- Ratio: cheaper per defect. The takeaway matches the parent note: shrinking the detection window (Red catches it seconds after you type it) is the dominant lever on cost — one slipped defect still dwarfs nine cheap ones, so the goal is to push the catch-rate at Red as high as possible.
Recall One-line self-check ladder
L1 recognizes phases ::: name Red / Green / Refactor and explain "right reason to fail". L2 runs a cycle ::: fake it, triangulate with a second example, capture a bug and its boundaries as tests. L3 analyzes ::: never-red tests are toothless; new behavior is not a refactor; small tests localize failure. L4 synthesizes ::: drive a whole feature one Red at a time, simplest case first. L5 masters ::: doubles vs integration, SOLID-guided refactor, cost economics, coverage is not correctness.
Recall Feynman check — say it back in one breath
TDD (Test-Driven Development) is: write a tiny test that fails for the right reason, add the least code to make it pass, tidy without changing behavior, and repeat — using small examples (triangulation) to force the code to generalize and edge-case tests to guard every boundary, so every behavior you care about has a test that will turn red the instant it breaks.