TDD: red → green → refactor
Test-Driven Development: write the failing test first, then the code — a design tool as much as a safety net, with a pragmatic policy for when to mandate it.
Learning objectives
- Run the Red→Green→Refactor loop.
- Build a feature test-first.
- Use test-pain as a design signal.
- Decide when TDD fits; institutionalize bug-fix-first.
code/tq4-tdd/. Python blocks run offline; config files are ready to drop into a project.1 · The TDD loop essential
This is the whole heartbeat of Test-Driven Development (TDD): a tiny three-step loop you repeat over and over. The surprising rule is that you write the test before the code — you describe what "working" looks like first, watch it fail, then make it pass.
- Red (left box) — write a failing test first. You add a test for a behaviour that doesn't exist yet, so it fails (test runners show failures in red). This is on purpose: it proves the test actually checks something and it pins down exactly what "done" means before you write any code.
- Green (middle box) — make it pass the simplest way. Write the least code needed to turn that red test green. No cleverness, no extra features — just get to passing. A passing test shows green, hence the name.
- Refactor (right box) — clean up. Now that the test guards the behaviour, you can safely tidy the code (rename things, remove duplication) and re-run the test to confirm you didn't break anything.
- Read the arrows left to right: Red → Green → Refactor. Then you loop back to Red for the next small behaviour. Each lap adds one more tested piece of the feature.
In short: "Red, green, refactor" is just "prove it's broken, make it work, make it clean" — one small behaviour at a time, with a test leading the way each lap.
2 · TDD in action (runs, step by step) intermediate
Build roman_to_int test-first. Each block adds a failing case then the code to pass it — here shown as one runnable script proving the final result.
tdd_roman.pyVALUES = {"I":1,"V":5,"X":10,"L":50,"C":100,"D":500,"M":1000}
def roman_to_int(s):
total, prev = 0, 0
for ch in reversed(s):
val = VALUES[ch]
total += val if val >= prev else -val # subtract when smaller than prev
prev = val
return total
# the tests that DROVE this design, now all green:
assert roman_to_int("III") == 3
assert roman_to_int("IV") == 4 # the case that forced the subtract logic
assert roman_to_int("MCMXCIV") == 1994
print("TDD complete: all cases green")
TDD complete: all cases green
This is the finished result of doing TDD on a Roman-numeral converter. Instead of writing all the logic up front, the author wrote one failing test, added just enough code, then repeated — and the tricky IV = 4 case is what forced the clever rule you see here. Read it bottom-first: the assert lines are the tests that drove the design.
VALUESis a dictionary that maps each Roman letter to its number (I→1,V→5, …). Looking a letter up is how the code turns a character into a value.- The loop walks the string right to left (
reversed(s)). For each letter it grabsval, then the key rule: if this letter is at least as big as the one to its right it adds it, otherwise it subtracts it. That single line is what makesIVmean 4 (add 5, subtract 1), not 6. prev = valremembers the current letter's value so the next letter (further left) can compare against it.return totalhands back the final number.- The three
assertlines are the tests.assert X == Ymeans "crash if X isn't Y" — so if all three run silently, every case passed. The middle one (IVshould be 4) is the failing test that originally forced the subtract logic to exist.
What the output means: It prints TDD complete: all cases green. "Green" means every assert passed; if any had failed you'd see an AssertionError instead and nothing after it.
Try this: Temporarily change the second test to assert roman_to_int("IV") == 6 and run it — you'll get an AssertionError. That red failure is exactly what step one of TDD looks like before you write the code to satisfy it.
3 · TDD as design pressure advanced
Writing the test first makes you the first user of your API. If the test is awkward to write, the design is wrong — TDD surfaces that immediately, nudging toward small, injectable units.
4 · Professional — always TDD a bug fix professional
The one place TDD is nearly always right: reproduce the bug with a failing test first, then fix. Now it can never silently return.
bugfix.pydef parse_range(s):
# bug: didn't handle a single number like "5"
if "-" in s:
a, b = s.split("-"); return list(range(int(a), int(b)+1))
return [int(s)] # <- the fix, driven by the test below
# RED (reproduces the reported bug), now GREEN after the fix:
assert parse_range("1-3") == [1, 2, 3]
assert parse_range("5") == [5] # the previously-broken case
print("bug reproduced by a test, then fixed -> permanent regression guard")
bug reproduced by a test, then fixed -> permanent regression guard
This shows the one place almost everyone agrees TDD is worth it: fixing a bug. The rule is reproduce the bug with a failing test first, then fix the code. That failing test becomes a permanent guard so the same bug can never quietly come back.
parse_range(s)turns a string like"1-3"into a list of numbers[1, 2, 3]. Theif "-" in s:branch handles ranges by splitting on the dash and building the numbers in between.- The bug: originally it only handled dashed ranges, so a lone number like
"5"broke. The last linereturn [int(s)]is the fix — when there's no dash, treat the whole string as one number and return it in a list. - The two
assertlines are the tests. The first ("1-3") confirms the old behaviour still works; the second ("5") is the test that reproduced the reported bug — it would have failed before the fix, and passes now.
What the output means: It prints bug reproduced by a test, then fixed -> permanent regression guard. Both asserts passed, so the bug is fixed and locked down: the test stays in the suite forever as a "regression guard".
Try this: Delete the last line (return [int(s)]) and re-run. The "5" assert now fails — you've just seen the bug the test was written to catch. Put the line back and it goes green again.
5 · Tech-lead — when to mandate TDD tech-lead
A lead is pragmatic: mandate TDD where it pays (clear rules, non-trivial logic, all bug fixes), relax it for spikes/UI. Codify it so the team knows the expectation.
tdd_policy.pydef should_tdd(kind, clarity, logic_complexity): # clarity/complexity 1-5
if kind == "bugfix": return True # always
if kind in ("spike", "prototype"): return False
return clarity >= 3 and logic_complexity >= 3 # clear + non-trivial
for case in [("bugfix",1,1), ("feature",4,4), ("feature",4,1), ("spike",5,5)]:
print(case, "->", "TDD" if should_tdd(*case) else "skip")
('bugfix', 1, 1) -> TDD
('feature', 4, 4) -> TDD
('feature', 4, 1) -> skip
('spike', 5, 5) -> skip
TDD isn't always worth the effort, so this little function encodes a team's policy for when to require it. It's ordinary Python — a function that returns True (do TDD) or False (skip it) based on the kind of work and how clear/complex it is.
should_tdd(kind, clarity, logic_complexity)takes the type of task and two 1-to-5 ratings. The lines run top to bottom and the first matchingreturnwins, so order matters.if kind == "bugfix": return True— bug fixes always get TDD, no debate (that's the rule from the previous lab). Next, spikes and prototypes (throwaway experiments) skip it because the code won't survive.- For everything else, the final line demands TDD only when the work is both clear and meaty:
clarity >= 3 and logic_complexity >= 3. Both must be true, so a clear-but-trivial feature returnsFalse. - The
forloop feeds four sample cases through the function.*caseunpacks each tuple into the three arguments, and it prints the case next to the decision.
What the output means: Four lines pairing each case with a verdict: bugfix and the clear+complex feature print TDD; the trivial feature (logic_complexity 1) and the spike print skip — matching the policy rules exactly.
Try this: Change the third case to ("feature",4,3) and predict the verdict before running: both ratings now hit the >= 3 bar, so it flips from skip to TDD.
Exercise TQ4.1 — TDD a feature
Context: A rules-based classifier is the ideal TDD target: each rule can be demanded by its own failing test before it exists, so no behavior sneaks in unrequested.
Your task: TDD password_strength(pw) returning weak/medium/strong: write one failing test, make it pass, then add each new rule (length, digits, symbols) as its own new failing test — never adding a rule without a test demanding it.
Requirements:
- Start with a single failing test and the minimal code that passes it
- Add rules incrementally — length, then digits, then symbols — each introduced by a new failing test first
- Classify into the three strength levels weak / medium / strong
- No rule appears in the implementation before a test requires it
- The final suite documents every rule as an assertion
- Runs offline under
unittest
💡 Hint: Let the tests pull each rule in one at a time; if you find yourself adding logic no test asked for, delete it and write the test first.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Test-Driven Development inverts the order: the failing test comes first and defines "done", then you write the simplest code that passes it. The assertions are the spec.
Your task: Demonstrate red→green on fizzbuzz(n): show the test failing with no implementation, then passing after the simplest code that satisfies it.
Requirements:
- Write the test before the implementation exists (the red state)
- Implement the simplest
fizzbuzzthat turns the test green - Cover a plain number, a Fizz, a Buzz, and a FizzBuzz case
- The multiple-of-15 case is handled before the 3 and 5 cases
- Stop once the assertions pass — no extra behavior
- Runs offline under
unittest
💡 Hint: Written test-first, the assertions are the specification — you are done the moment they pass, which is what keeps you from gold-plating.
Show solution
The test defines "done" before the code exists:
import unittest
def fizzbuzz(n):
# GREEN: simplest thing that passes
if n % 15 == 0: return "FizzBuzz"
if n % 3 == 0: return "Fizz"
if n % 5 == 0: return "Buzz"
return str(n)
class TestFizzBuzz(unittest.TestCase):
def test_cases(self):
self.assertEqual(fizzbuzz(1), "1")
self.assertEqual(fizzbuzz(3), "Fizz")
self.assertEqual(fizzbuzz(5), "Buzz")
self.assertEqual(fizzbuzz(15), "FizzBuzz")
if __name__ == "__main__":
unittest.main(verbosity=2) # RED before impl; GREEN after
Written test-first, the assertions are the spec. You stop when they pass — no gold-plating.
Context: TDD works in small steps: each new failing test forces exactly one more piece of behavior. Building up incrementally keeps the implementation honest and minimal.
Your task: Build roman(n) test-first in small increments — one, then a subtractive case like four, then a larger multi-symbol number — with each test pulling the implementation forward.
Requirements:
- Add tests in increasing complexity: a simple numeral, a subtractive numeral, then a large number
- Each new test is written before the code that satisfies it
- The subtractive cases (e.g. 4→IV) are demanded by a test before they are implemented
- The final implementation converts numbers via an ordered value/symbol table
- A representative large case (e.g. 1994→MCMXCIV) passes
- Runs offline under
unittest
💡 Hint: An ordered table of (value, symbol) pairs from largest to smallest, consumed greedily, handles both ordinary and subtractive numerals in one loop.
Show solution
Each new test forces exactly one more piece of behavior:
import unittest
def roman(n):
table = [(1000,"M"),(900,"CM"),(500,"D"),(400,"CD"),(100,"C"),
(90,"XC"),(50,"L"),(40,"XL"),(10,"X"),(9,"IX"),
(5,"V"),(4,"IV"),(1,"I")]
out = []
for value, sym in table:
while n >= value:
out.append(sym); n -= value
return "".join(out)
class TestRoman(unittest.TestCase):
def test_one(self): self.assertEqual(roman(1), "I") # step 1
def test_four(self): self.assertEqual(roman(4), "IV") # step 2
def test_fourteen(self): self.assertEqual(roman(14), "XIV") # step 3
def test_1994(self): self.assertEqual(roman(1994), "MCMXCIV")
if __name__ == "__main__":
unittest.main(verbosity=2) # 4 tests OK
The subtractive cases (4→IV) are added as tests before the table row that handles them — the tests pull the implementation forward.
Context: When a test is painful to write, that pain is a design signal: the function under test is doing too much. Splitting it into small pure functions makes each test trivial.
Your task: Show a test that is brittle because one function parses, validates, and formats all at once, then split it into small functions so each gets its own trivial test.
Requirements:
- Describe why testing the do-everything function is painful
- Split the responsibility into separate
parse,valid, andfmtfunctions - Each split function is pure (no side effects) and independently testable
- Write one small, isolated test per function
- The three tiny tests replace the single sprawling one
- Runs offline under
unittest
💡 Hint: If a test needs elaborate setup to reach one behavior, that behavior wants its own function — let the difficulty of the test drive the refactor.
Show solution
When a test is hard to write, listen to it — the design is off:
import unittest
# PAINFUL: parse + validate + format in one function forces a big, brittle test.
# EASIER after splitting into small pure functions:
def parse(line): return dict(zip(("name", "age"), line.split(",")))
def valid(rec): return rec["age"].isdigit() and int(rec["age"]) >= 0
def fmt(rec): return f"{rec['name']} ({rec['age']})"
class TestPipeline(unittest.TestCase):
def test_parse(self): self.assertEqual(parse("ada,42"), {"name":"ada","age":"42"})
def test_valid(self): self.assertTrue(valid({"name":"ada","age":"42"}))
def test_format(self): self.assertEqual(fmt({"name":"ada","age":"42"}), "ada (42)")
if __name__ == "__main__":
unittest.main(verbosity=2) # each piece tested in isolation, trivially
Three tiny, independent tests replaced one sprawling one. TDD surfaced the design smell before it hardened into the codebase.
Context: The professional habit for bug fixes: reproduce the bug as a failing test first, then fix it. A fix without a test invites the bug straight back.
Your task: Write a regression test that reproduces an off-by-one bug (a dropped tail element in pagination), show it red against the buggy code, then green against the fix.
Requirements:
- The regression test names the reported behavior (e.g. the last item is kept)
- It fails against the buggy implementation and passes against the fix
- Cover both the odd-length case (that exposed the bug) and an exact-multiple case
- The fixed
pageslices the list so no tail element is dropped - The regression test stays in the suite to prevent recurrence
- Runs offline under
unittest
💡 Hint: Turn the bug report itself into the assertion first — that failing test is the precise, permanent guard that keeps the fix from silently regressing.
Show solution
A bug fix without a test invites the bug back:
import unittest
# Bug report: last item dropped from pagination.
def page(items, size):
# BUGGY: range(0, len, size) with a wrong stop drops the tail
# FIXED version below:
return [items[i:i+size] for i in range(0, len(items), size)]
class TestPage(unittest.TestCase):
def test_regression_last_item_kept(self):
# This test reproduces the reported bug first.
self.assertEqual(page([1,2,3,4,5], 2), [[1,2],[3,4],[5]])
def test_exact_multiple(self):
self.assertEqual(page([1,2,3,4], 2), [[1,2],[3,4]])
if __name__ == "__main__":
unittest.main(verbosity=2) # regression test now guards the fix
The regression test names the reported behavior. It fails against the buggy version and passes against the fix — and stays forever to prevent recurrence.
Context: Tech-lead reality: TDD is not always the right tool. It pays off for well-specified logic and bug fixes, but slows down exploratory spikes where you are still learning the shape of the problem.
Your task: Encode a policy function that recommends TDD for bug fixes and well-specified logic-heavy work, but not for throwaway exploratory spikes.
Requirements:
tdd_recommended(context)takes a dict of booleans about the work- A bug fix always recommends TDD (regression test first)
- An exploratory spike does not recommend TDD
- Well-specified plus logic-heavy work recommends TDD
- Each decision returns both a boolean and a short reason string
- The policy is explicit so a team applies it consistently
💡 Hint: Order the checks so the strongest signals (bug fix, spike) short-circuit first, and always return a reason alongside the boolean so the call is defensible in review.
Show solution
A pragmatic, defensible policy rather than dogma:
def tdd_recommended(context):
# context: dict of booleans about the work
if context.get("bug_fix"):
return True, "always TDD a bug fix (regression test first)"
if context.get("exploratory_spike"):
return False, "spike to learn, then delete or re-do test-first"
if context.get("well_specified") and context.get("logic_heavy"):
return True, "clear spec + branching logic -> TDD pays off"
return False, "prefer tests-after here; keep them though"
print(tdd_recommended({"bug_fix": True}))
# (True, 'always TDD a bug fix (regression test first)')
print(tdd_recommended({"exploratory_spike": True}))
# (False, 'spike to learn, then delete or re-do test-first')
print(tdd_recommended({"well_specified": True, "logic_heavy": True}))
# (True, 'clear spec + branching logic -> TDD pays off')
Bug fixes and well-specified logic get TDD; throwaway spikes don't. The policy is explicit so a team applies it consistently.
Context: A rule that lives only in people's heads erodes. Making "a bug fix ships with its regression test" a CI gate turns the culture into something the pipeline enforces, not goodwill.
Your task: Write a GitHub Actions workflow (worked YAML) that runs the test suite on every pull request — the gate that ensures a fix cannot merge without its green suite. (Needs a CI runner.)
Requirements:
- The workflow triggers on
pull_request(and typically pushes tomain) - Steps check out the repo and set up a pinned Python version
- A step installs the project and pytest
- A step runs
pytest; a non-zero exit fails the PR - A comment documents the review convention: a bug-fix PR must add a previously-failing regression test
💡 Hint: Because the job runs on pull_request, a red test blocks the merge automatically — the pipeline, not a reviewer's memory, enforces the rule.
Show solution
Worked YAML — a GitHub Actions workflow that gates every PR on the suite:
# .github/workflows/tests.yml (needs GitHub Actions)
name: tests
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install
run: pip install -e . pytest
- name: Run tests (fails the PR if any test is red)
run: pytest -q
# Convention enforced in review: a bug-fix PR must add a test that
# fails on the parent commit. The green suite here is the gate.
Because the job runs on pull_request, a fix without its (previously-failing) regression test can't merge silently — the culture is enforced by the pipeline, not goodwill.
✓ Checkpoint — you can move on when you can…
- Run Red→Green→Refactor.
- Build a feature test-first.
- Use test-pain as a design signal.
- Apply a TDD policy; always TDD bug fixes.
Knowledge check check yourself
In the Roman-numeral example, which specific test case "forced" the subtract-when-smaller-than-previous logic, and how does that illustrate TDD as design pressure?
Show answer
roman_to_int("IV") == 4 case forced it: without the subtract rule the code would return 6. Writing that failing test first made the author the API's first user and drove the design toward the correct rule — if a test is awkward to write, that pain signals the design is wrong.The lesson says features can debate TDD but bug fixes shouldn't. What is the "bug-fix-first" rule and what does the failing test become afterward?
Show answer
bugfix.