Learning Goals
3 min- Explain the red-green-refactor cycle.
- Write a failing test first, then minimal code to pass it.
- Refactor safely under a green test.
- Understand why TDD improves design, not just coverage.
Warm-Up · Test First Sounds Backwards
5 min🔴 RED write a small test for behaviour that doesn't exist yet
→ run it → it FAILS (the feature isn't built)
🟢 GREEN write the SIMPLEST code that makes the test pass
→ run it → it PASSES
🔵 REFACTOR clean up the code (and tests) — tests stay green
→ repeat for the next small behaviourWriting the test first forces you to decide what the code should do before how it does it. You get a spec, a safety net, and 100% meaningful coverage for free — because every line exists to satisfy a test. The discipline is small steps: one tiny behaviour at a time.
New Concept · The Cycle in Detail
14 minThe rules of the cycle
- Never write production code without a failing test demanding it.
- Write only enough test to fail (a compile error counts as failing).
- Write only enough code to pass — resist building ahead.
- Refactor only when green; the tests prove you didn't break anything.
Why "simplest code to pass" — even if it's silly
# 🔴 test def test_add(): assert add(2, 3) == 5 # 🟢 simplest code that passes — yes, even this: def add(a, b): return 5 # "fake it" — passes the ONE test
That looks absurd, but it's deliberate: it forces you to add a second test that drives out the real logic. "Fake it till you make it" keeps you honest about what the tests actually demand.
# 🔴 a second test breaks the fake def test_add_other(): assert add(10, 1) == 11 # fails — return 5 is wrong # 🟢 now the real implementation is forced def add(a, b): return a + b
Refactor under green
Once tests pass, improve the code — rename, extract functions, remove duplication — running tests after each change. If a refactor breaks a test, you know instantly and undo it. The green bar is your seatbelt.
The rhythm is fast
A TDD cycle should take SECONDS to a few minutes. test → fail → code → pass → clean → repeat. If a step takes long, your step was too big — shrink it.
Worked Example · TDD a FizzBuzz
12 minBuild FizzBuzz one test at a time. Each step: red, then green.
# 🔴 step 1 def test_returns_number(): assert fizzbuzz(1) == "1" # 🟢 simplest: def fizzbuzz(n): return str(n) # 🔴 step 2 — multiples of 3 def test_fizz(): assert fizzbuzz(3) == "Fizz" # 🟢 def fizzbuzz(n): if n % 3 == 0: return "Fizz" return str(n) # 🔴 step 3 — multiples of 5 def test_buzz(): assert fizzbuzz(5) == "Buzz" # 🟢 def fizzbuzz(n): if n % 3 == 0: return "Fizz" if n % 5 == 0: return "Buzz" return str(n) # 🔴 step 4 — multiples of both def test_fizzbuzz(): assert fizzbuzz(15) == "FizzBuzz" # 🟢 (note: must check 15 FIRST) def fizzbuzz(n): if n % 15 == 0: return "FizzBuzz" if n % 3 == 0: return "Fizz" if n % 5 == 0: return "Buzz" return str(n)
🔵 Refactor — same behaviour, cleaner code, tests still green:
def fizzbuzz(n): out = "" if n % 3 == 0: out += "Fizz" if n % 5 == 0: out += "Buzz" return out or str(n)
$ pytest -v # all 4 tests still pass after the refactor test_returns_number PASSED test_fizz PASSED test_buzz PASSED test_fizzbuzz PASSED
Read the diff
Each test drove exactly one new behaviour, and the code grew only to satisfy tests. The final refactor changed the implementation completely — but the four tests guaranteed the behaviour stayed identical. That confidence to refactor freely is TDD's biggest gift.
Try It Yourself
13 minTDD an is_leap_year(y) function. One test at a time: divisible by 4, not by 100, but yes by 400. Watch each test go red then green.
Deliberately use "return a constant" for your first green, then add a second test that forces the real logic. Feel why one test isn't enough.
Once your function passes 5+ tests, rewrite its internals completely. Confirm the tests catch you if you change behaviour.
Mini-Challenge · TDD a Stack
8 minTDD a Stack class from scratch: push, pop, peek, is_empty, and popping an empty stack raises. Write each test first, watch it fail, then implement. Don't write any method before a test demands it.
Show the test-first sequence
import pytest # tests grow one behaviour at a time: def test_new_stack_empty(): assert Stack().is_empty() def test_push_not_empty(): s = Stack(); s.push(1); assert not s.is_empty() def test_pop_returns_last(): s = Stack(); s.push(1); s.push(2); assert s.pop() == 2 def test_peek_doesnt_remove(): s = Stack(); s.push(9); assert s.peek() == 9 and not s.is_empty() def test_pop_empty_raises(): with pytest.raises(IndexError): Stack().pop()
Non-negotiables: write each test BEFORE its method, confirm it fails, then implement. The class emerges from the tests.
Recap
3 minTDD: 🔴 write a failing test → 🟢 simplest code to pass → 🔵 refactor under green → repeat, in small fast steps. "Fake it" forces a second test that drives real logic. The payoff: a spec written as tests, fearless refactoring, and meaningful coverage by construction. Next: a full TDD walk-through building a calculator.
Vocabulary Card
- TDD
- Test-Driven Development — write the test before the code.
- red / green / refactor
- Failing test → minimal passing code → clean up under green.
- fake it till you make it
- Pass with a constant, then add tests that force the real implementation.
- baby steps
- One tiny behaviour per cycle, seconds to minutes each.
Homework
4 minTDD a small class or function from scratch (a Stack, a Queue, a Roman-numeral converter, a tip calculator). Commit after EACH green so the history shows the red-green-refactor rhythm. Submit the test file, the code, and your commit log.
Use the Stack sequence as a model. The commit log is the deliverable that proves you went test-first, one behaviour per cycle.