4.5.14 · D3Software Engineering

Worked examples — TDD — Red-Green-Refactor cycle

3,579 words16 min readBack to topic

Before symbols and jargon: three words you must own from line one.

Look at the loop first — everything below is one trip around it.

Figure — TDD — Red-Green-Refactor cycle

Figure (s01) — description: three chalk circles on a dark board form a triangle. The pink circle at the top is labelled RED, the blue circle lower-right is GREEN, the yellow circle lower-left is REFACTOR. Curved chalk arrows run RED → GREEN ("make it pass"), GREEN → REFACTOR ("clean up"), and REFACTOR → RED ("make it fail"), forming a closed loop. The arrow never skips: you cannot jump from Red straight to Refactor, because there is nothing green to protect yet.


The scenario matrix

Every worked example below is tagged with the cell of this matrix it covers. Together they fill every cell.

# Case class What makes it tricky Example that covers it
C1 Happy path, single behaviour The simplest Red→Green Example 1
C2 Triangulation (force generalization) 2nd test kills a hardcode Example 2
C3 Branch / sign case Different input class → different output Example 3
C4 Zero / degenerate input Empty list, 0, None Example 4
C5 Error / limiting case Divide-by-zero, overflow-edge Example 5
C6 Refactor that breaks a test Green→Red during Refactor Example 6
C7 Wrong-reason Red (false Red) Test fails from a typo, not the assertion Example 7
C8 Real-world word problem Turning a story into a first test Example 8
C9 Exam twist "Which test passes without new code?" Example 9

Example 1 — Happy path (C1)

Forecast: Before reading on — after the first failing test, what is the simplest code that turns it green? (Hint: it need not do real arithmetic.)

  1. RED — write the failing test.

    def test_add_2_and_3():
        assert add(2, 3) == 5

    Why this step? We name the behaviour ("2 plus 3 is 5") before the code exists. Run it → fails because add is undefined. This is the unit test acting as a spec.

  2. GREEN — simplest pass.

    def add(a, b):
        return 5

    Why this step? The minimum that passes. Yes it's a lie — but a truthful lie: it passes the one test we have. One test cannot force honesty, so we don't pretend it does.

Verify: Run the test → add(2, 3) == 5 is True. Green. The forecast answer was: return 5 (a hardcode).


Example 2 — Triangulation kills the hardcode (C2)

Forecast: Which line of add is now impossible to keep? What's the smallest edit?

  1. RED — second data point.

    def test_add_10_and_7():
        assert add(10, 7) == 17

    Why this step? return 5 gives 5, but we asserted 17. The test fails for the right reason (assertion mismatch, not a crash). Two points pin down a line the way one point cannot.

  2. GREEN — generalize.

    def add(a, b):
        return a + b

    Why this step? Now both 5 and 17 must come out. The only edit small enough to satisfy both is real addition. The tests pulled the algorithm into existence.

Verify: add(2,3) == 5 and add(10,7) == 17. Both green. This is triangulation: multiple concrete examples squeeze the code from a fake into the truth, just like two lines of sight fix a point on a map.


Example 3 — Branch / sign case (C3)

Forecast: How many failing tests, minimum, before the code has all three branches?

The picture below is the map we are driving tests across — glance at it now, then again after each step.

Figure — TDD — Red-Green-Refactor cycle

Figure (s02) — description: a horizontal chalk number line runs from −5 to +5 with tick marks. The pink shaded stretch to the left of centre is labelled x < 0 → "negative"; the blue shaded stretch to the right is labelled x > 0 → "positive"; a single yellow dot exactly at the centre marks x = 0 → "zero", the boundary. Pink and blue sample dots sit at x = −4 and x = +4. Each coloured region is exactly one input class, and TDD wants one test per region — so the figure literally counts our tests for us: pink test, blue test, yellow-dot test.

  1. RED (positive).

    def test_sign_positive():
        assert sign(4) == "positive"

    Why? Start with one class — the blue region on the figure, sampled at x = 4. Fails — sign undefined.

  2. GREEN.

    def sign(x):
        return "positive"

    Why? Hardcode again. Only the blue region is proven so far; the pink and yellow parts of the picture are still uncovered.

  3. RED (negative).

    def test_sign_negative():
        assert sign(-4) == "negative"

    Why? Now we sample the pink region at x = -4. It forces a branch on the sign of the input — the analogue of "which region of the line am I in?" Fails: -4 currently gives "positive".

  4. GREEN.

    def sign(x):
        if x < 0:
            return "negative"
        return "positive"
  5. RED + GREEN (zero — the boundary).

    def test_sign_zero():
        assert sign(0) == "zero"
    def sign(x):
        if x < 0:
            return "negative"
        if x == 0:
            return "zero"
        return "positive"

    Why the zero test matters: 0 is the yellow dot — the boundary between the pink and blue regions on the figure. Boundaries are where off-by-one and < vs <= bugs live. Without this test, sign(0) would silently fall through to "positive" (the blue region eating the boundary).

Verify: sign(4)=="positive", sign(-4)=="negative", sign(0)=="zero". Three coloured regions on the figure, three greens. Minimum failing tests = 3 — one per region.


Example 4 — Zero / degenerate input (C4)

Forecast: What should average([]) do? There is no single "right" answer — TDD makes you decide the spec first. Pick before reading on.

  1. RED (normal case).

    def test_average_normal():
        assert average([2, 4, 6]) == 4

    Why this step? Establish the happy path first — one concrete example of the "usual" behaviour. Fails — average undefined.

  2. GREEN.

    def average(nums):
        return sum(nums) / len(nums)

    Why this step? This is the simplest honest code that passes: the mean is total divided by count. We do not guard the empty list yet — no test demands it, so building the guard now would be gold-plating. We let the next Red pull the guard into existence.

  3. RED (the degenerate case). We decide the spec: an empty list should raise a clear error, not crash cryptically.

    def test_average_empty_raises():
        with pytest.raises(ValueError):
            average([])

    Why this step? An empty list makes len(nums) == 0, so sum/len throws ZeroDivisionError — a confusing message. The test fails for the right reason: it wanted ValueError, it got ZeroDivisionError. We turn a degenerate input into a designed behaviour.

  4. GREEN.

    def average(nums):
        if len(nums) == 0:
            raise ValueError("average of empty list is undefined")
        return sum(nums) / len(nums)

    Why this step? The minimum edit that satisfies the new test: a single guard that fires only when the list is empty, raising the exact exception the test asked for. The normal path is untouched, so the first test stays green — we generalized without regressing.

Verify: average([2,4,6]) == 4.0; and calling average([]) raises ValueError. Degenerate case is now a documented contract, not a landmine.


Example 5 — Error / limiting case (C5)

Forecast: Before the fix, which exception does divide(10, 0) throw? After the fix, which one? And what should divide(1e308, 0.5) produce — a number, or an overflow to infinity?

  1. RED — capture the bug as a test.

    def test_divide_by_zero_raises_value_error():
        with pytest.raises(ValueError):
            divide(10, 0)

    Why this step? This freezes the bug into an executable spec. Right now it fails because Python raises ZeroDivisionError, not ValueError.

  2. GREEN — minimal guard.

    def divide(a, b):
        if b == 0:
            raise ValueError("cannot divide by zero")
        return a / b

    Why this step? The smallest change that turns the Red test green: one guard that fires only at exactly b == 0. Every non-zero denominator still flows through ordinary division, so we fixed the crash without altering correct behaviour.

  3. RED (ordinary case — prove the guard stays out of the way).

    def test_divide_ordinary_case():
        assert divide(9, 3) == 3.0

    Why this step? The guard we just added only fires at b == 0. We must prove it does not accidentally break a perfectly normal division. 9 / 3 is the plainest example of the happy path — it flows straight past the guard and returns 3.0. Passes with the code above, confirming the guard is surgical.

  4. RED (limiting sanity, tiny non-zero denominator).

    def test_divide_small_denominator():
        assert divide(1, 0.5) == 2.0

    Why this step? Guards that our b == 0 check didn't accidentally trap near-zero values. As b shrinks toward 0 but stays non-zero, the result grows but is still a valid division — the guard must only fire at exactly zero. Passes immediately with the code above, confirming the boundary is drawn in the right place.

  5. RED (overflow edge — huge values).

    def test_divide_overflow_edge_stays_finite():
        assert divide(1e308, 0.5) == 2e308      # still finite
    def test_divide_overflow_edge_goes_infinite():
        import math
        assert math.isinf(divide(1e308, 1e-309))  # exceeds float max -> inf

    Why this step? The other extreme of the same axis: when numbers get astronomically large the result can exceed the biggest float Python can hold. We decide the spec: 1e308 / 0.5 = 2e308 is still representable (finite), while 1e308 / 1e-309 overflows to inf. Pinning both down means "very large" is now a documented behaviour, not a surprise crash. Both pass with our existing code — floats already produce inf on overflow rather than raising — so no code change is needed; the tests simply lock the behaviour.

Verify: divide(10,0)ValueError; divide(9, 3) == 3.0 (ordinary happy path, step 3); divide(1, 0.5) == 2.0 (tiny denominator, step 4); divide(1e308, 0.5) == 2e308 (finite); divide(1e308, 1e-309) is inf. Zero is fenced off, the ordinary case is safe, the tiny-denominator limit stays honest, and the overflow edge is nailed down. Every one of these lives forever as a regression guard.


Example 6 — A refactor that breaks a test (C6)

Forecast: In Refactor you must not change behaviour. If tests were green and now go red, whose fault is it — the test or the new code?

Starting point (green):

def total_price(items):        # items: list of (price, qty)
    total = 0
    for price, qty in items:
        total = total + price * qty
    return total

Test (green): total_price([(10, 2), (5, 3)]) == 35.

  1. REFACTOR (buggy attempt). You "simplify" using a comprehension but mistype * as +:

    def total_price(items):
        return sum(price + qty for price, qty in items)   # BUG: + should be *

    Why show the wrong one? This buggy version computes (10+2)+(5+3) = 20, not 35. Seeing the exact wrong code makes the next step's Red concrete.

  2. RUN TESTS — instant Red.

    # test_total_price now FAILS:
    #   expected 35, got 20

    The green suite turns red the moment behaviour changed. Why this step? The whole point of refactoring on green is that any behaviour change is caught immediately — you're never more than one test-run from safety.

  3. REFACTOR (correct). Restore the multiplication:

    def total_price(items):
        return sum(price * qty for price, qty in items)   # correct

    Tests green again → the refactor is now provably behaviour-preserving. Why this step? A refactor is only legitimate once the suite is green again; green is the proof that structure changed but behaviour did not.

Verify: correct total_price([(10,2),(5,3)]) == 35; buggy sum(price+qty...) == 20 ≠ 35, so the suite correctly rejects it. This is Refactoring under a safety net.


Example 7 — The false Red / wrong-reason failure (C7)

Forecast: If a test fails because of a typo in the test itself, does that prove the code is broken?

def test_greet():
    assert greet("Sam") == "Hello, Sam"   # code returns "Hello, Sam!"

Suppose greet already correctly returns "Hello, Sam!" (with a !).

  1. RED appears. The test fails: "Hello, Sam!" != "Hello, Sam". Why this step? A Red must fail because the behaviour is missing — not because the test's expectation is wrong. Here the code is right; the test's expected string is wrong. So before touching anything we pause and ask which side is lying.

  2. Diagnose. Read the failure message. It shows expected "Hello, Sam", got "Hello, Sam!". The got value is actually correct — so the assertion is the bug. Why this step? The got field is the code's real output; the expected field is what the test wanted. When got is obviously the correct string, the disagreement lives in the test, not the function. Reading the message — instead of blindly editing code — is what stops us corrupting a working function.

  3. Fix the test, not the code.

    def test_greet():
        assert greet("Sam") == "Hello, Sam!"

    Why this step? Since the diagnosis pinned the bug on the assertion, the correct minimal edit is to the test's expected value, bringing it in line with the correct behaviour. Editing greet here would break correct code to satisfy a broken test — the exact trap below.

Verify: greet("Sam") == "Hello, Sam!" is True. Green — and we did not corrupt correct code to chase a bad test.


Example 8 — Real-world word problem (C8)

Forecast: Read the story and list the distinct behaviours hiding in it before you write any test.

The story contains three behaviours: below-threshold (no discount), at-threshold (5 exactly), above-threshold (discount applies).

  1. RED (below threshold).

    def test_bill_no_discount():
        assert bill(3) == 360      # 3 * 120

    Why? Translate the plainest sentence first. Fails — bill undefined.

  2. GREEN.

    def bill(n):
        return n * 120
  3. RED (at & above threshold — the boundary 5).

    def test_bill_discount_at_5():
        assert bill(5) == 540      # 5*120=600, minus 10% = 540

    Why the boundary 5? The story says "5 or more" — the boundary is inclusive. Testing exactly 5 catches the classic > 5 vs >= 5 mistake.

  4. GREEN.

    def bill(n):
        gross = n * 120
        if n >= 5:
            return gross * 0.9
        return gross

Verify: bill(3) == 360, bill(5) == 540.0, bill(6) == 648.0. Units check: rupees in, rupees out; 600 × 0.9 = 540. All three story-behaviours covered — each was one matrix cell.


Example 9 — Exam twist (C9)

Forecast: Answer in your head before reading — name the test and give yes/no.

  1. Identify the earlier test. test_one_returns_1 asserts fizzbuzz(1) == "1". Why this step? Generalizing return "1" into return str(n) is a behaviour change for new inputs, so we must confirm it did not silently break behaviour we already locked in. The only earlier locked behaviour is fizzbuzz(1) == "1".

  2. Evaluate the new code on the old input. With return str(n), calling fizzbuzz(1) gives str(1) == "1". So test_one_returns_1 still passes. Why this step? We run the old assertion against the new code to prove the generalization is behaviour-preserving for the case we already cared about. It is: "1" still comes out.

  3. Decide whether a new test is needed. No new test is required. Both test_one_returns_1 and test_two_returns_2 already exist and both re-run automatically against the new code, so the old case is re-checked for free. Why this step? A kept test is a permanent re-check. Adding a third test asserting the same fizzbuzz(1) == "1" would be redundant duplication, not new coverage — TDD adds a test only to drive new behaviour.

Answer: test_one_returns_1 still passes (because str(1) == "1"), and no new test is needed — the existing suite re-validates it automatically.

Verify: str(1) == "1" (old test green) and str(2) == "2" (new test green). The generalization preserved the old behaviour while adding the new one.


How the cases connect

tests stay green

a test goes red

fails for wrong reason

Every new behaviour

Write failing test - RED

Minimum code - GREEN

Tidy - REFACTOR

Refactor broke behaviour - fix it

Fix the test not the code

Recall Self-check: which matrix cell?

A function crashes on None input and you write a test that expects a clear error. Which cell? ::: C4 (zero / degenerate input) — and possibly C5 if you assert a specific exception. Your green suite goes red the instant you rename a variable during cleanup. Which cell? ::: C6 (refactor that breaks a test) — investigate, the rename must have changed behaviour. A test fails because you imported the wrong module. Which cell? ::: C7 (wrong-reason Red) — fix the test/import, not the production code. The story says "5 or more" and you test exactly 5. Which cell? ::: C8 boundary of C3-style branching — the inclusive threshold.

Recall Related vault topics
  • Foundations of the loop: Red-Green-Refactor, Unit Testing, Refactoring
  • Bug capture that stays forever: Regression Testing
  • Isolating the unit under test: Test Doubles - Mocks Stubs Fakes
  • Measuring which cells you covered: Code Coverage
  • Same idea at feature level: BDD - Behavior Driven Development
  • Running the whole suite on every push: Continuous Integration
  • Designs that make tests easy: SOLID Principles