AI EngineeringZero to ProductionHome·About·What’s new·Contact
Software Testing · Chapter TQ4

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.

⏱️ ~2.5 hours🧪 5 labs🎯 Intermediate→Tech-lead

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.
▶ Runnable companionThe code here is also saved under code/tq4-tdd/. Python blocks run offline; config files are ready to drop into a project.

1 · The TDD loop essential

You've almost certainly lived the other way round. You write a chunk of code, it looks right, and only then do you tack on a test — usually one that pokes at whatever the code already happens to do. It passes. You feel safe. But that test never had a chance to fail, so you never proved it actually checks anything: it's a test written to agree with the code, not to challenge it. Weeks later a refactor quietly breaks the behaviour, the test still passes because it was always toothless, and the bug ships. Test-Driven Development flips the order precisely to kill that failure mode.

The mental model is a heartbeat — a tiny three-beat rhythm you repeat dozens of times an hour: Red, Green, Refactor. Think of it like a builder who lays a single guide-string before laying each row of bricks. You don't build the whole wall and then check if it's straight; you set the string (the test), lay one row to meet it (the code), then step back and tidy the mortar (the refactor) — every single row.

Why this precise order, and not "code then test"? Because writing the test first forces you to answer one question before you're emotionally invested in an implementation: what does "done" actually mean? The failing test is an executable specification. It also guarantees the test has teeth — you have literally watched it fail — which the after-the-fact test never gives you. The most common beginner mistake is skipping the Red step ("I'm sure it fails") and writing the assertion after the code; do that and you've thrown away the one guarantee TDD exists to give you. The second most common mistake is over-building in the Green step — writing the whole feature before a test asks for it — which quietly drags you back to code-first with extra steps.

Red: failing test define done Green: pass it simplest code Refactor clean up
🗺️ How to read this diagram

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

The loop sounds tidy on a diagram; the doubt hits the moment you try it. "Write a failing test first" feels backwards, even wasteful — how do you test a function you haven't written? So most people abandon it on the first hard problem and go back to coding. The cure is to watch it work on something with a genuine twist, where the design is not obvious up front. Converting Roman numerals to integers is perfect: it looks trivial until IV shows up and breaks your first instinct.

Here's the naive mental model everyone starts with: "add up the value of each letter." III → 1+1+1 = 3. Great. Then you write the test roman_to_int("IV") == 4 and run it — and your add-everything code returns 6 (5 + 1). Red. That failing test just did something a spec document never could: it forced a design decision. You can't make it green without discovering the real rule — a smaller symbol placed before a larger one is subtracted, not added. TDD didn't just verify the code; it dragged the correct algorithm out of you, one failing case at a time.

That is the deeper claim of this section: the test is not checking the design, the test is driving the design. Working outside-in — from the behaviour you want back toward the code that produces it — you become the first user of your own function. You feel its awkward edges before anyone else does. The disciplined rhythm is: add exactly one new failing assertion (III, then the tricky IV, then a big multi-symbol MCMXCIV), make it green with the minimum change, repeat. Each red bar names precisely the next piece of behaviour the code is missing.

The lab below collapses that whole sequence into one finished, runnable script so you can see the destination — but read it bottom-first. The assert lines are the tests that came first; the function above them is what those tests pulled into existence. The common mistake here is writing all the asserts at once and then all the code at once: that's not TDD, it's just testing-after wearing a costume. The value is in the small laps, where every step is guarded before the next begins.

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.

first instinct: add every letter I = 1 V = 5 IV → 6 ✗ Red: the failing test roman_to_int("IV") == 4 rule the test forced: subtract if smaller I < V −1 +5 4 ✓ Green: scan right-to-left,smaller-before-larger subtracts the test didn't just check the code — it dragged the correct algorithm out III → 3 · IV → 4 · MCMXCIV → 1994 (one failing case at a time) The failing test drove the design. Add-everything gives IV → 6 (red); you can't turn it green without discovering the real rule — a smaller symbol before a larger one is subtracted — which yields 4.
🗺️ How to read this diagram

Read the two panels left then right: the left is the naive first attempt that fails, the right is the rule the failing test pulled into existence.

  • The left panel shows the instinct everyone starts with — add the value of each letter. For IV that is 1 + 5, and the red box shows the wrong answer 6: the test roman_to_int("IV") == 4 fails.
  • The right panel shows the rule that failure forced: when a smaller symbol sits before a larger one (I < V), you subtract it (−1 +5), and the green box gives the correct 4.
  • The dashed divider separates "wrong, before the test" from "right, after the test". The bottom line lists the small laps — III, then the tricky IV, then a big MCMXCIV — each one added as its own failing case first.

In short: The IV case couldn't go green under the add-everything design, so the test forced the subtract rule — TDD designed the algorithm, it didn't merely verify it.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · the finished TDD result (runs)
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
▶ How this works

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.

  1. VALUES is 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.
  2. The loop walks the string right to left (reversed(s)). For each letter it grabs val, 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 makes IV mean 4 (add 5, subtract 1), not 6.
  3. prev = val remembers the current letter's value so the next letter (further left) can compare against it. return total hands back the final number.
  4. The three assert lines are the tests. assert X == Y means "crash if X isn't Y" — so if all three run silently, every case passed. The middle one (IV should 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

Here's a scene from every codebase. You sit down to write a test for one function, and before you can assert anything you have to build a database connection, stub out a payment gateway, fake three environment variables, and construct a 40-line object just to reach the one line you actually care about. You mutter "testing is so much work" and either write a shallow test or skip it. That frustration is real — but the lesson almost everyone draws from it is exactly backwards. The problem is not the test. The pain is a message about the design.

Think of a test as a customer trying to use your function through the front door. If the customer needs a 40-step ritual to place a simple order, you don't blame the customer — you fix the shop. A function that's hard to test is hard to use; the test is just the first honest attempt to use it in isolation, and it's reporting back that the function does too much or depends on too much. When you write the test first, you get that report before you've written a single line of implementation to fall in love with — which is why TDD is a design tool, not merely a safety net.

What does the pain actually point at? Almost always one of two smells. Either the unit has too many responsibilities (it parses and validates and formats and saves, so no test can isolate one behaviour), or it has too many hard dependencies baked in (it reaches out and constructs its own database or clock, so you can't substitute a fake). The fix for the first is to split it into small, single-purpose functions; the fix for the second is to inject the dependency — pass it in as an argument so a test can hand over a stand-in. Both moves make the unit smaller, purer, and — not coincidentally — easier to reason about for the next human who reads it.

So the discipline is: when a test feels awkward, stop and listen instead of pushing through. The awkwardness is design feedback arriving at the cheapest possible moment — before the shape has hardened into the codebase and a dozen callers depend on it. The mistake is to grit your teeth and write the elaborate scaffolding anyway; every line of that scaffolding is the test telling you to refactor, and you're paying to ignore it. Let the difficulty of the test drive the split (this is where TDD hands you straight over to the single-responsibility principle in SD3).

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.

painful test: one unit does everything do_it() parse · validateformat · save builds its own DB + clock 40-line test setup split + inject easy test: small, injectable units parse() validate() format() save(db) db is passed IN each green unit gets a trivial, isolated test — the fake db is just handed in Test-pain is a design report. A unit that parses, validates, formats and builds its own dependencies needs elaborate scaffolding to test; splitting it into small pure functions and injecting the db makes each test trivial.
🗺️ How to read this diagram

Compare the two halves: the left is the painful design the test complains about, the right is what listening to that pain produces.

  • The red box on the left is one function doing four jobs (parse, validate, format, save) and constructing its own database and clock. Because of that, the amber strip below it shows the cost: a test needs a huge pile of setup just to reach one behaviour.
  • The arrow labelled "split + inject" is the fix the pain points at — break the responsibilities apart and pass dependencies in.
  • The green boxes on the right are small pure functions, each trivially testable on its own. The muted strip shows the second move: the db is passed in as an argument, so a test can hand over a stand-in instead of a real database.

In short: When the test is hard to write, don't push through the scaffolding — split the unit and inject its dependencies, and the same test becomes easy.

"Hard to test" = "redesign"Ten lines of scaffolding to test one function means it does too much or depends on too much. Let that pain split responsibilities (SD3).

4 · Professional — always TDD a bug fix professional

Every engineer knows this sick feeling. A bug comes in, you find the offending line, you change it, it looks fixed, you ship. Three weeks later the same bug is back — a well-meaning refactor, a merge, or a "cleanup" quietly undid your fix, and nothing caught it because there was no test standing guard. You fix it again. This is the most demoralising loop in software: fixing the same thing twice. Bug-fix-first TDD is the single cheapest habit that ends it, and it's the one place even TDD skeptics agree the practice is non-negotiable.

The rule is one sentence: reproduce the bug with a failing test before you touch the code. Think of it like a crime scene — before you clean anything up, you photograph exactly what went wrong. Write a test that asserts the correct behaviour for the exact input that broke, and run it. It goes red. That red bar is proof of two things at once: the bug is real (you've reproduced it, not just imagined it), and your test can actually detect it. Only then do you write the fix and watch it turn green.

Why does the order matter so much for bug fixes specifically? Because a bug is, by definition, a case your existing tests missed — so the bug itself is handing you the precise test that was absent. Skip the red step and you fall into the classic trap: you write the fix, then write a test, and the test passes on the first run. But you never saw it fail, so you have no evidence it's testing the bug rather than something adjacent. Worse, a subtle fix can be wrong in a way that a hastily-written after-test also gets wrong. The failing-first test is the only version that proves your fix addresses the reported behaviour.

And here's the compounding payoff: once green, that test never leaves. It becomes a permanent regression guard — a tiny, self-documenting record that says "this specific thing broke once, and here's the proof it can't break again." Do this for every bug and your test suite slowly turns into an accumulated history of every mistake your system has ever made and survived. That's why leads make it a team rule regardless of how they feel about TDD for new features: it's the highest-reliability-per-keystroke move in the whole discipline.

The one place TDD is nearly always right: reproduce the bug with a failing test first, then fix. Now it can never silently return.

bug report "5" breaks failing test reproduce · red write the fix minimal change green — kept regression guard test FIRST, code SECOND — you must watch it go red before it goes green stays in the suite forever Bug-fix-first, drawn. Turn the report into a failing test (red) before touching the code; the fix turns it green, and that test never leaves — the same bug can't silently return.
🗺️ How to read this diagram

Follow the arrows left to right — it is a strict order, and the surprising part is that the test comes before the fix.

  • The first box is the incoming bug report (here, the input "5" breaks the code).
  • The red box is the key move: write a test that reproduces that exact bug and watch it fail. Red proves both that the bug is real and that your test can actually detect it.
  • The third box is the fix — the minimal change to turn the bar green. The green box is the payoff: the now-passing test stays in the suite forever as a regression guard, so the bug can never quietly come back.

In short: Report → red test → fix → green test that lives on. Skipping the red step throws away the one proof that your test guards the actual bug.

Python · bug-fix-first (runs)
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
▶ How this works

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.

  1. parse_range(s) turns a string like "1-3" into a list of numbers [1, 2, 3]. The if "-" in s: branch handles ranges by splitting on the dash and building the numbers in between.
  2. The bug: originally it only handled dashed ranges, so a lone number like "5" broke. The last line return [int(s)] is the fix — when there's no dash, treat the whole string as one number and return it in a list.
  3. The two assert lines 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

TDD converts people, and converts get zealous. Someone reads the first four sections, feels the click, and declares that all code must be written test-first, forever, no exceptions. Then they try to TDD an exploratory data spike where they don't yet know what the answer looks like, or a bit of throwaway UI glue, and the tests become guesswork they rewrite five times an hour. The team's velocity tanks, morale sours, and someone concludes "TDD doesn't work" — when the real failure was applying one tool to every job. A tech-lead's value here is refusing both extremes: neither dogma nor abandonment, but a written policy the team can apply without re-litigating it every standup.

The judgement rests on a simple question: does the code have a knowable spec before you write it? TDD needs a target to aim the failing test at. Where the target is crisp, test-first is a gift; where you're still discovering the target, test-first is friction. That splits the world into a few clear buckets:

The point of codifying this — as the runnable policy function below does — is that it removes the argument from the individual and puts it in the process. When "should I TDD this?" has a written answer, a junior engineer isn't guessing and a reviewer isn't imposing a personal preference; the team just applies the rule. The mistake a new lead makes is leaving the policy implicit ("we do TDD here") and then being surprised when everyone interprets it differently. Make it explicit, make it defensible, and let it flex where the work genuinely calls for exploration.

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.

knowable spec before you code? the one question a lead asks yes → TDD pays no / throwaway → skip bug fix always — spec = report clear + logic-heavy feature spike / prototype — learning trivial / UI glue — test after codify it: should_tdd() removes the argument from the person and puts it in the process One question, four buckets. If the spec is knowable up front (bug fix, clear logic-heavy feature) TDD pays; if you're still discovering it (spike) or it's trivial glue, skip test-first — and write the policy down so nobody re-litigates it.
🗺️ How to read this diagram

Start at the top box and follow the two arrows down — the whole policy hangs off one question.

  • The top box is the question a tech-lead asks of any task: is there a knowable spec before you write the code? TDD needs a target to aim the failing test at.
  • The left (green) arrow is "yes" — the two green buckets get TDD: bug fixes always (their spec is the bug report) and clear, logic-heavy features (real branching to design and protect).
  • The right (red) arrow is "no or throwaway" — the two red buckets skip test-first: spikes/prototypes (you're still learning the shape) and trivial or pure-UI glue (a light test after is enough).

In short: Knowable spec → TDD; still discovering or trivial → skip. Writing it into a should_tdd() function turns a personal preference into a team rule.

Python · a policy the team can apply (runs)
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
▶ How this works

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.

  1. 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 matching return wins, so order matters.
  2. 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.
  3. 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 returns False.
  4. The for loop feeds four sample cases through the function. *case unpacks 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.

Bug-fix-first is non-negotiableFeatures can debate TDD; bug fixes shouldn't. A failing test that reproduces the bug, then the fix, means that bug is gone forever. Leads make this a team rule — it's the cheapest reliability win there is.

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.

Exercise 1 · Red → GreenBeginner

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 fizzbuzz that 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.

Exercise 2 · TDD in action, step by stepIntermediate

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.

Exercise 3 · Test-pain as a design signalAdvanced

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, and fmt functions
  • 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.

Exercise 4 · Always TDD a bug fixExpert

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 page slices 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.

Exercise 5 · When to mandate TDD (a policy)Professional

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.

Exercise 6 · Institutionalize bug-fix-first in CIIndustry scenario

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 to main)
  • 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

✓ Knowledge check

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
The 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.
✓ Knowledge check

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
Reproduce the reported bug with a failing test first, then write the fix. Once green, that test stays in the suite forever as a permanent regression guard, so the same bug can never silently return — the lesson's policy function returns True (do TDD) for any bugfix.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in