Until now the sequence was always: write code, then protect it with tests. Test-Driven Development (TDD) reverses the arrow: the test is written before the code that will make it pass exists. It sounds like a fakir's trick, but it's a design discipline with precise rules and measurable benefits — and also with limits worth knowing without fanaticism. You'll practice it on a new piece of Papyrus that Ana has been asking for for weeks: discount coupons for the summer campaign. It will be born entirely through TDD, test by test.
Contents
- The Red-Green-Refactor cycle
- The rules of the game
- Papyrus kata:
apply_coupon, iteration by iteration - Refactoring with a safety net
- TDD as a design tool
- Benefits and honest criticisms
The Red-Green-Refactor cycle
TDD is a three-phase cycle, short (minutes, not hours), repeated until the feature is done:
graph LR
R["RED<br/>Write a failing test<br/>(define WHAT you want)"] --> V["GREEN<br/>Write the MINIMUM code<br/>that makes it pass"]
V --> F["REFACTOR<br/>Improve the code<br/>without changing behavior"]
F --> R
| Phase | What you do | What's forbidden |
|---|---|---|
| Red | You write a small test for the next behavior and watch it fail | Writing two tests at once; skipping the run "because I already know it fails" |
| Green | You write the minimum — however crude — so that the whole suite passes | Adding functionality "while you're at it"; chasing elegance |
| Refactor | You clean up duplication and improve names, with the green suite as your net | Changing behavior; refactoring with red tests |
Watching the test fail first is not an empty ritual: it's the proof that the test tests something. A test born green may be checking nothing (remember the method missing the test_ prefix in 09-02, which "passed" because it didn't exist).
The rules of the game
The three classic rules (formulated by Robert C. Martin), in practical form:
- Don't write production code except to make a red test pass.
- Don't write more test than needed to fail (a compilation/import error already counts as a failure).
- Don't write more production code than needed to pass the red test.
Plus one cross-cutting attitude: baby steps. Each cycle adds a tiny behavior. The temptation to "solve it all at once" is exactly what TDD comes to tame: small steps mean that when something breaks, the cause is in the last ten lines.
Papyrus kata: apply_coupon, iteration by iteration
Ana's request: "I want coupons on the final amount of a sale. PAPYRUS10 takes 10% off, MEMBER5 takes 5% off. An invalid code must give a clear error, coupons don't stack, and amounts always to 2 decimals." A kata is a deliberate exercise to practice the mechanics; this one will be real: the code will end up in papyrus/coupons.py.
Iteration 1 — Red: the first test defines the API
The module doesn't even exist yet. We start by writing how we'd like to use it:
# tests/test_coupons.py
import pytest
from papyrus.coupons import apply_coupon
def test_papyrus10_takes_ten_percent_off():
# Hamlet x2 with VAT is 20.70 (canonical figure); -10% = 18.63
assert apply_coupon(20.70, "PAPYRUS10") == pytest.approx(18.63)pytest → red: ModuleNotFoundError: No module named 'papyrus.coupons'. Perfect: rule 2 says an import failure already counts as a red test. Without noticing, we've made three design decisions: the function is called apply_coupon, it takes (amount, code) in that order, and it returns the amount (it doesn't print it, doesn't mutate anything).
Iteration 1 — Green: the minimum code (even if it hurts)
Returning a constant? Yes. It's the smallest legal step, and it's called fake it. It looks absurd, but it disciplines something valuable: the code only generalizes when a test demands it. pytest → green. Nothing to refactor yet.
Iteration 2 — Red: triangulate to force the formula
We force generalization with a second example (this is called triangulation):
def test_papyrus10_with_another_amount():
assert apply_coupon(100.00, "PAPYRUS10") == pytest.approx(90.00)Red: 18.63 != 90.0. The constant no longer cuts it; now the formula is the minimum:
Green (both tests pass — the whole suite always runs).
Iteration 3 — Red: the second coupon
def test_member5_takes_five_percent_off():
# An Odyssey with no member card (13.00) with MEMBER5 → 12.35:
# the same price a member would pay, because ×0.95×1.04 commutes
assert apply_coupon(13.00, "MEMBER5") == pytest.approx(12.35)Red: it returned 11.70 (it applied the 10%). Minimum for green: telling codes apart.
def apply_coupon(amount: float, code: str) -> float:
if code == "MEMBER5":
return round(amount * 0.95, 2)
return round(amount * 0.90, 2)Green. It smells bad (any unknown code gets 10% off?), but that smell is the next test, not a sneaky fix: rule 3 forbids getting ahead of yourself.
Iteration 4 — Red: invalid code → clear error
The error deserves its own exception in the M7 hierarchy:
# In papyrus/errors.py, next to its sisters:
class InvalidCouponError(PapyrusError):
def __init__(self, code: str):
self.code = code
super().__init__(f"invalid coupon: {code!r}")from papyrus.errors import InvalidCouponError
def test_invalid_code_raises_error():
with pytest.raises(InvalidCouponError, match="SUMMER99"):
apply_coupon(20.70, "SUMMER99")Red (it returned 18.63 instead of failing). Green:
from papyrus.errors import InvalidCouponError
def apply_coupon(amount: float, code: str) -> float:
if code == "PAPYRUS10":
return round(amount * 0.90, 2)
if code == "MEMBER5":
return round(amount * 0.95, 2)
raise InvalidCouponError(code)Iteration 5 — Red: no stacking
What does "don't stack" mean in this API? Our design decision: the function accepts one code; any attempt to combine ("PAPYRUS10+MEMBER5") is not a valid code and must be rejected:
def test_coupons_do_not_stack():
with pytest.raises(InvalidCouponError):
apply_coupon(20.70, "PAPYRUS10+MEMBER5")pytest → it's already green! The current code rejects any unknown code. A useless test? No: Ana's requirements are now written down. If someone "improves" the function tomorrow to split codes on +, this test will turn red and defend the business rule. A test born green demands one extra check: break it by hand for a second (change the expected amount) to verify it runs, then restore it.
Iteration 6 — Red: rounding as a contract
def test_rounds_to_two_decimals():
# 9.99 - 10% = 8.991 → must come out as 8.99, never 8.991
assert apply_coupon(9.99, "PAPYRUS10") == 8.99Green on the first run (the round was already there), and again we pin it down as a contract: if a refactor loses the rounding, there's a net.
Refactoring with a safety net
Six tests in green. Now — and only now — we make it pretty. The cascade of ifs duplicates the "percentage + round" pattern; a dictionary expresses it better (M4 to the rescue):
# papyrus/coupons.py — final version after the refactor
from papyrus.errors import InvalidCouponError
COUPONS: dict[str, float] = {
"PAPYRUS10": 0.10,
"MEMBER5": 0.05,
}
def apply_coupon(amount: float, code: str) -> float:
"""Apply a coupon to the final amount and return the result to 2 decimals.
Raises InvalidCouponError if the code doesn't exist. Not stackable.
"""
if code not in COUPONS:
raise InvalidCouponError(code)
return round(amount * (1 - COUPONS[code]), 2)pytest → all six tests still green. That's the feeling that defines TDD: we rewrote the entire function without fear, because the suite guarantees the behavior didn't change. Adding the Christmas coupon will be one line in the dictionary... preceded, of course, by its test.
TDD as a design tool
Look at what happened in iteration 1: by writing the call first, we designed the API from the caller's point of view. The signature apply_coupon(amount: float, code: str) -> float came from usage, not from a diagram. TDD systematically pushes toward small functions, without hidden effects and easy to call — because whatever is hard to test is felt immediately in the test. If the test needs to set up half the world, the design is screaming that the function depends on too much (the same signal we anticipated with unittest.mock in 09-03). The red test is, before verification, an executable specification.
Benefits and honest criticisms
| TDD pays off when... | TDD gets in the way when... |
|---|---|
| The business logic has clear rules (prices, coupons, stock) | You're exploring and don't yet know what you want (prototypes, notebooks) |
| The cost of a bug is high (money, data) | The code is throwaway or trivial (a one-afternoon script) |
| You'll maintain the code for years | The API depends on an external system you don't understand yet |
| You want to refactor without fear | Testing needs heavy infrastructure you don't master yet (GUI, hardware) |
Real benefits: coverage that is born with the code (not as a postponable chore), usage-oriented design, executable documentation and fearless refactoring. Legitimate criticisms: tests are code that also gets maintained (they double the cost of changing a business rule on purpose); badly understood TDD produces tests coupled to the implementation that break with every refactor (test behavior, not internals); and strict discipline can be slow in exploratory phases. The mature stance isn't religious: TDD for business logic with clear rules — like Papyrus's prices —, tests-after for the exploratory stuff, and always, always, a regression test whenever you catch a bug (you'll see it in the next lesson).
Common Mistakes and Tips
- Not running the red test before coding. It's mistake number one: a badly written test can be born green and protect nothing. Red first, always seen with your own eyes.
- Writing five tests in one go and then the code. That's "test-first in batches", not TDD: you lose the test↔design dialogue and the baby steps. One test, one green, each time.
- Skipping the refactor. Green isn't the finish line, it's the halfway point. If you pile up greens without cleaning, the code degenerates and you'll end up blaming TDD for your technical debt.
- Testing the implementation instead of the behavior. A test that checks "it uses a dictionary named COUPONS" breaks with every legitimate refactor. Our tests only look at inputs and outputs: that's why refactoring the
ifcascade into the dictionary didn't touch a single test line. - Chasing 100% coverage as a goal. Coverage measures which lines run, not which contracts are verified. Six tests with intent are worth more than twenty tautological ones.
- Tip: keep the cycle short. If you've been in red for twenty minutes, the step was too big: delete, split the problem and come back with a smaller test.
Exercises
Exercise 1
Ana adds the campaign coupon "SUMMER15" (−15%). Do it with strict TDD: write the test (use apply_coupon(20.00, "SUMMER15"), work out the expected value yourself), watch it fail, make the minimal change and confirm green. How many production lines did you touch?
Exercise 2
New requirement: a negative amount makes no sense and must raise ValueError (not InvalidCouponError: the problem is the amount, not the coupon — consistent with sell's criterion in M7). Full cycle: red test → minimum → green.
Exercise 3
Julia typed "papyrus10" in lowercase and got an error. Ana decides coupons should be case-insensitive. Write the red test, make it pass with the minimal change and answer: did any earlier test turn red with your change? Why is it important to run the whole suite on every green?
Solutions
Exercise 1 — expected: 20.00 × 0.85 = 17.00.
def test_summer15_takes_fifteen_percent_off():
assert apply_coupon(20.00, "SUMMER15") == pytest.approx(17.00)Red (InvalidCouponError). Green with one line of production code: adding "SUMMER15": 0.15, to the COUPONS dictionary. That's the dividend of the final iteration's refactor: data and logic ended up separated.
Exercise 2
def test_negative_amount_raises_valueerror():
with pytest.raises(ValueError):
apply_coupon(-5.00, "PAPYRUS10")Red (it returns -4.5). Minimum for green, at the top of the function:
Exercise 3
def test_lowercase_coupon_is_valid():
assert apply_coupon(100.00, "papyrus10") == pytest.approx(90.00)Red. Minimal change: normalize on entry — code = code.upper() as the first line (or code.strip().upper() if you want to tolerate spaces; that would demand its own test). No earlier test breaks: "PAPYRUS10".upper() is still "PAPYRUS10" and "PAPYRUS10+MEMBER5" still doesn't exist. But you only know that because you ran the whole suite: every green is a statement about all the contracts at once, not just the latest one. That habit — full suite on every cycle — is what turns six tests into a net rather than six loose ropes.
Conclusion
Papyrus has a new feature — papyrus/coupons.py with apply_coupon(amount, code) and its InvalidCouponError in the M7 hierarchy — that never existed for a single minute without tests: it was born red-green-refactor, with the API designed from usage, triangulation to force generalization and a fearless final refactor thanks to the six-test net. You also have the honest map: TDD shines in business logic with clear rules and gets in the way during exploration; use it as a tool, not as a religion. But let's be realistic: however many tests you write, bugs will keep coming — in legacy code, in the edge case nobody imagined, in production on a Friday. The next lesson is about exactly that: what to do when something is already broken. Reproduce, isolate, fix and — closing the circle with this lesson — protect the fix with a regression test. It's time to learn to debug with a method.
Python Programming Course
Module 1: Introduction to Python
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
