4.5.14 · D2Software Engineering

Visual walkthrough — TDD — Red-Green-Refactor cycle

1,885 words9 min readBack to topic

Before any code, three plain words you must own:

We will build fizzbuzz(n): given a whole number n, return the string "Fizz" if it divides by 3, "Buzz" if by 5, "FizzBuzz" if by both, and otherwise the number itself as text.


Step 1 — The empty file (the true starting point)

WHAT. We begin with nothing. No function, no tests. This is the zero we derive everything from.

WHY. TDD's first rule is: do not write production code until a failing test demands it. So the very first thing that may exist is a test — and it will fail, because the thing it tests does not exist yet. We must see that emptiness to understand what "Red for the right reason" means.

PICTURE. The blueprint below shows two empty columns — the test column (left) and the code column (right). Both are blank. The amber arrow marks where the pressure comes from: tests push code into being, never the other way.

Figure — TDD — Red-Green-Refactor cycle

Step 2 — RED: write the smallest possible test

WHAT. We write one tiny test:

def test_one_returns_1():
    assert fizzbuzz(1) == "1"

Read the assertion term by term:

WHY. We pick the smallest behaviour imaginable — input 1 gives the string "1". Small means the test is easy to satisfy, so the Green step will be trivial. assert is the word that says "I claim this is true; blow up if it isn't."

PICTURE. The test column now has one red block. When we run it, Python complains fizzbuzz is not defined — the code column is still empty. This is Red, but for a shallow reason (the function is missing). We note that: a missing-function failure still counts as Red, and it tells us exactly what to create next.

Figure — TDD — Red-Green-Refactor cycle

Step 3 — GREEN: fake it with a hardcoded answer

WHAT. We write the minimum code that makes Step 2's test pass:

def fizzbuzz(n):
    return "1"      # hardcoded on purpose

WHY. This looks absurd — it ignores n entirely and always returns "1". That is deliberate. The rule is: write the least code that turns the light green. Returning "1" is genuinely the least. Hardcoding is not cheating; it is a placeholder that the next test will be forced to knock down.

PICTURE. The code column now holds one block, and the test block flips from red to green — the prediction "1" matches the returned "1". Notice the input n=1 flows in but is ignored (greyed arrow); the constant "1" flows out.

Figure — TDD — Red-Green-Refactor cycle

Step 4 — RED again: triangulate to kill the fake

WHAT. We add a second example that the hardcoded answer cannot survive:

def test_two_returns_2():
    assert fizzbuzz(2) == "2"

WHY. Our code always returns "1", so feeding it 2 yields "1", but we predicted "2" — mismatch, Red. This technique of adding a second concrete example to force generalization is called triangulation: two points define a line where one point defined only a dot. One example can be faked; two examples that differ cannot.

PICTURE. Two test blocks now: the first still green, the new one red. The red one is the lever that will pry the constant "1" out of the code.

Figure — TDD — Red-Green-Refactor cycle

GREEN. The smallest fix that satisfies both:

def fizzbuzz(n):
    return str(n)

Now 1 → "1" and 2 → "2". Both green. The fake is dead; the code finally uses n.


Step 5 — RED → GREEN: the first real rule (Fizz)

WHAT. A new behaviour that str(n) cannot produce:

def test_three_returns_Fizz():
    assert fizzbuzz(3) == "Fizz"

Currently fizzbuzz(3) returns "3", not "Fizz" → Red. The minimal fix:

def fizzbuzz(n):
    if n % 3 == 0:
        return "Fizz"
    return str(n)

Term by term, the new line is:

WHY. The percent sign % is the modulo operator: it returns what's left over after dividing. 6 % 3 == 0 (3 fits into 6 exactly), but 7 % 3 == 1. A remainder of 0 is exactly the mathematical meaning of "divisible by 3", which is precisely the FizzBuzz rule — so modulo is the right tool, not subtraction or a division check.

PICTURE. The code now has a fork: inputs divisible by 3 take the amber path to "Fizz"; everything else falls through to str(n). Three green tests hang below.

Figure — TDD — Red-Green-Refactor cycle

Step 6 — The overlap edge case (FizzBuzz)

WHAT. After adding Buzz the same way (n % 5 == 0 → "Buzz"), one tricky input remains: a number divisible by both 3 and 5, like 15.

def test_fifteen_returns_FizzBuzz():
    assert fizzbuzz(15) == "FizzBuzz"

WHY. This is the degenerate/overlap case — the input satisfies two rules at once. If our code checked n % 3 first and returned early, it would answer "Fizz" for 15 and never reach the Buzz check. The test catches exactly this collision. Every "either/or" ladder hides an "and" case; TDD forces us to write a test for it instead of hoping.

PICTURE. A Venn-style split on the blueprint: the "÷3" circle and the "÷5" circle overlap; 15 sits in the overlap and must collect both words.

Figure — TDD — Red-Green-Refactor cycle

Step 7 — REFACTOR: collapse the branches (behaviour unchanged)

WHAT. All tests are green (Fizz, Buzz, FizzBuzz, plain numbers). Now — and only now — we tidy:

def fizzbuzz(n):
    out = ""
    if n % 3 == 0: out += "Fizz"
    if n % 5 == 0: out += "Buzz"
    return out or str(n)

Term by term:

  • out = "" — start with an empty string, a blank slate to append to.
  • out += "Fizz" — the += means "glue onto the end". So 15 collects "Fizz" then "Buzz""FizzBuzz", solving Step 6's overlap for free.
  • out or str(n) — Python's or returns the left value if it is "truthy"; an empty string is falsy, so if nothing was appended we fall back to the number as text.

WHY. The two independent ifs (not elif) let both words attach, so the overlap case needs no special branch. We add no new test — behaviour is identical to Step 6, and the green suite proves the refactor is safe. That proof is the entire payoff of having written the tests first.

PICTURE. Left: the old tangled tree with a special "both" branch. Right: the flat two-if accumulator. An "=" bridge labelled "same behaviour" connects them; the green test suite sits underneath both, unchanged.

Figure — TDD — Red-Green-Refactor cycle
Recall Why refactor only on green?

Refactoring on green means ::: every tiny structural change is immediately re-checked by passing tests, so if you break behaviour the suite goes red instantly and you know it was this change.


Step 8 — The zero and negative edge cases

WHAT. Two inputs we never tested: 0 and negatives like -3.

WHY. 0 % 3 == 0 and 0 % 5 == 0 are both true, so fizzbuzz(0) returns "FizzBuzz". In Python -3 % 3 == 0 as well (Python's modulo follows the sign of the divisor), so fizzbuzz(-3) == "Fizz". A careful TDD author writes tests for these boundary inputs so the behaviour is pinned down on purpose rather than left as an accident.

PICTURE. A number line on the blueprint from -6 to 6, each multiple of 3 (amber) and 5 (cyan) tagged with what fizzbuzz returns — including 0 glowing at the centre as the double-hit.

Figure — TDD — Red-Green-Refactor cycle

The one-picture summary

Every state the code passed through, stacked as one blueprint: empty → "1" (fake) → str(n) → add Fizz → add Buzz → handle overlap → refactored accumulator. Red blocks on the left drive each rightward growth of the code; the green suite at the bottom only ever grows and never shrinks.

Figure — TDD — Red-Green-Refactor cycle
Recall Feynman: tell the whole walkthrough to a 12-year-old

We wanted a machine that reads out numbers but shouts "Fizz" on 3s, "Buzz" on 5s, and "FizzBuzz" when it's both. Instead of building the whole machine, we asked one tiny question at a time: "For 1, do you say '1'?" It couldn't answer, so we made the dumbest machine possible — it just always says "1". Then we asked "For 2?" and caught it lying, so we upgraded it to actually read the number. Then "For 3, say Fizz?" forced it to learn the divide-by-3 trick. Then Buzz. Then we threw it the sneaky number 15 that's a 3 and a 5, which made it learn to say both words. Finally, once every question was answered "yes," we cleaned up the wiring into a neat little machine — and because all our old questions were still saved, we could prove we hadn't broken anything. That saved checklist of questions never disappears: bump the machine tomorrow and it turns red the instant something stops working.

Related ideas: Unit Testing, Refactoring, Regression Testing, Test Doubles - Mocks Stubs Fakes, Code Coverage.

Recall Quick self-check

After Step 4 we changed return "1" to return str(n). Did we add a new test to prove the change was safe? ::: No — the existing red test test_two_returns_2 turned green and test_one_returns_1 stayed green, so the two tests together proved it. That is triangulation.