Test-Driven Development Intermediate¶
🧪 Testing Track · Level 3
When you'd use this
Red-green-refactor cycle, design benefits and TDD workflow.
Drive design by writing the test first — red/green/refactor — for well-specified, regression-proof code.
The TDD cycle¶
Red → green → refactor: write a failing test, make it pass simply, then clean up with the test as a safety net.
┌──────────────────────────────────────────────┐
│ 1. RED — Write a failing test │
│ 2. GREEN — Write minimal code to pass │
│ 3. REFACTOR — Clean up, keep tests green │
│ └───── Repeat ─────────────────────────────┘
Each cycle takes 1-5 minutes. You always have working code.
TDD walkthrough: building a Stack¶
A worked example of the cycle in action — growing a small class one test at a time.
Cycle 1: push and peek¶
# test_stack.py — Step 1: RED (write failing test)
import pytest
from stack import Stack
def test_new_stack_is_empty():
s = Stack()
assert s.is_empty()
def test_push_and_peek():
s = Stack()
s.push(42)
assert s.peek() == 42
assert not s.is_empty()
# stack.py — Step 2: GREEN (minimal code to pass)
class Stack:
def __init__(self):
self._items = []
def is_empty(self):
return len(self._items) == 0
def push(self, item):
self._items.append(item)
def peek(self):
return self._items[-1]
Cycle 2: pop¶
# test_stack.py — add new failing test
def test_pop_returns_last_pushed():
s = Stack()
s.push(1)
s.push(2)
s.push(3)
assert s.pop() == 3
assert s.pop() == 2
assert s.pop() == 1
assert s.is_empty()
def test_pop_empty_raises():
s = Stack()
with pytest.raises(IndexError, match="empty"):
s.pop()
# stack.py — add pop
def pop(self):
if self.is_empty():
raise IndexError("Cannot pop from empty stack")
return self._items.pop()
Cycle 3: size¶
def test_size():
s = Stack()
assert len(s) == 0
s.push("a")
s.push("b")
assert len(s) == 2
s.pop()
assert len(s) == 1
Step 3: REFACTOR¶
After all tests pass, look for improvements:
# Final clean version
class Stack:
"""A LIFO stack with O(1) push, pop and peek."""
def __init__(self):
self._items: list = []
def push(self, item) -> None:
self._items.append(item)
def pop(self):
if not self._items:
raise IndexError("Cannot pop from empty stack")
return self._items.pop()
def peek(self):
if not self._items:
raise IndexError("Cannot peek empty stack")
return self._items[-1]
def is_empty(self) -> bool:
return len(self._items) == 0
def __len__(self) -> int:
return len(self._items)
def __repr__(self) -> str:
return f"Stack({self._items})"
Tests still pass after refactoring — confidence!
TDD benefits¶
Why it pays off: clearer design, built-in regression tests, and confidence to refactor.
| Benefit | How |
|---|---|
| Design feedback | Hard-to-test code = bad design. TDD forces simple interfaces. |
| Documentation | Tests show how the code is meant to be used. |
| Confidence | Refactor freely — tests catch regressions instantly. |
| Focus | Write only the code needed to pass the test. No overengineering. |
| Regression safety | Every bug gets a test before the fix. Never breaks again. |
TDD anti-patterns to avoid¶
Common traps — testing implementation details, giant tests, skipping the refactor step.
Don't do these
- Testing implementation details — test behavior, not internal state
- Writing too many tests before code — one at a time!
- Not refactoring — the cycle is Red-Green-Refactor, not Red-Green-Red-Green
- Testing trivial code —
def get_name(self): return self.namedoesn't need a test - Mocking everything — TDD works best with real objects and simple interfaces
Practice Exercises¶
- TDD a
Queueclass — follow the Red-Green-Refactor cycle strictly for: enqueue, dequeue, peek, size, is_empty. - TDD a
BankAccount— start with deposit, then withdraw (with validation), then transfer between accounts. - TDD a URL shortener — shorten(), expand(), and stats tracking.
- TDD a roman numeral converter — convert integers to Roman numerals one rule at a time.
- TDD a shopping cart with add_item, remove_item, total, apply_discount.
💬 Discussion
Have a question about this topic? Found an error? Share your thoughts below.