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

  1. The Red-Green-Refactor cycle
  2. The rules of the game
  3. Papyrus kata: apply_coupon, iteration by iteration
  4. Refactoring with a safety net
  5. TDD as a design tool
  6. 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:

  1. Don't write production code except to make a red test pass.
  2. Don't write more test than needed to fail (a compilation/import error already counts as a failure).
  3. 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)

pytestred: 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)

# papyrus/coupons.py
def apply_coupon(amount: float, code: str) -> float:
    return 18.63

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:

def apply_coupon(amount: float, code: str) -> float:
    return round(amount * 0.90, 2)

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")

pytestit'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.99

Green 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 if cascade 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:

    if amount < 0:
        raise ValueError(f"Negative amount: {amount}")

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

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved