PYTH-230 · Lesson 5 of 12 · 27 min

Test-driven development

▶ Video coming soon A video version of this lesson is on the way.

So far, Jordan and Sam have been writing tests for code that already exists. In mid-November Rosa asked for something new: Ridgeline gift cards for the Christmas season, sold in the cafe and online, usable for coffee, drinks and bags of beans. Sam suggested trying a different way of working for the new feature: write the tests first, before any of the code. The approach is called test-driven development, usually shortened to TDD, and it was popularized by Kent Beck in the early 2000s.

This lesson walks through TDD step by step on the gift card feature, then looks at the habits that make it work, the benefits teams report, and its limits. Even developers who do not use TDD all the time borrow its central habit: before writing code, decide how you will know it works.

Red, green, refactor

TDD repeats a short cycle, often only a few minutes long. First, write one small test for the next bit of behavior you want, and run it to watch it fail. The failure, shown in red by most test runners, proves the test is checking something the code does not yet do. Second, write the simplest code that makes the test pass, turning the runner green. Third, with all tests green, refactor: improve the names and structure of both the code and the tests without changing what they do, rerunning the tests after each change. Then pick the next behavior and start again.

A circular cycle of three stages joined by arrows. Red: write a small failing test. Green: write the simplest code that passes. Refactor: clean up code and tests while staying green. Arrows lead from red to green to refactor and back to red. In the center: one behavior at a time, minutes per loop.
The TDD cycle: red, green, refactor, repeated in small steps

Before starting, it helps to write a test list: a short note of the behaviors you expect to need, in plain words. For the gift cards, Sam and Rosa wrote: a new card holds the amount it was bought for; redeeming takes money off the balance; you cannot redeem more than the balance; amounts must be positive; a card can be topped up; and every card has a unique code. The list is a plan, not a contract. Items get added, split and crossed out as the work reveals details.

In the listings that follow, each step shows the tests and the code as they stood at that point. In a real project they would live in two files, tests/test_giftcard.py and src/ridgeline/giftcard.py, and you would rerun pytest after every small change.

Step 1: a new card holds its amount

The first test is deliberately tiny. It describes how a gift card is created and what you can ask it, which forces a decision about the interface before any implementation exists. Sam chose to keep money in whole cents, like the rest of Ridgeline's code, and to call the attribute balance.

Red: the test fails because GiftCard does not exist yet
import unittest

def report(*cases):
    """Run TestCase classes and print one line per test (a small test runner)."""
    loader = unittest.TestLoader()
    tests = [t for case in cases for t in loader.loadTestsFromTestCase(case)]
    result = unittest.TestResult()
    unittest.TestSuite(tests).run(result)
    problems = {}
    for kind, items in (("FAIL", result.failures), ("ERROR", result.errors)):
        for test, tb in items:
            problems.setdefault(test.id().split(" ")[0], []).append((kind, tb.strip().splitlines()[-1]))
    for t in tests:
        found = problems.get(t.id(), [])
        print(f"{t.id().rsplit('.', 1)[-1]:<34} {found[0][0] if found else 'ok'}")
        for kind, line in found:
            print("    " + line)
    print(f"{len(tests)} tests: {len(tests) - len(problems)} passed, {len(problems)} failed")

class TestGiftCard(unittest.TestCase):
    def test_new_card_holds_its_amount(self):
        card = GiftCard(2500)
        self.assertEqual(card.balance, 2500)

report(TestGiftCard)
Output
test_new_card_holds_its_amount     ERROR
    NameError: name 'GiftCard' is not defined
1 tests: 0 passed, 1 failed

The test fails, reported as an ERROR rather than a FAIL, because there is no GiftCard class at all. That counts as red. Now write just enough code to pass. It is tempting to write the whole class at once, but TDD asks for the simplest thing that works, because every line written without a failing test is a line no test checks.

Green: the simplest class that passes
import unittest

def report(*cases):
    """Run TestCase classes and print one line per test (a small test runner)."""
    loader = unittest.TestLoader()
    tests = [t for case in cases for t in loader.loadTestsFromTestCase(case)]
    result = unittest.TestResult()
    unittest.TestSuite(tests).run(result)
    problems = {}
    for kind, items in (("FAIL", result.failures), ("ERROR", result.errors)):
        for test, tb in items:
            problems.setdefault(test.id().split(" ")[0], []).append((kind, tb.strip().splitlines()[-1]))
    for t in tests:
        found = problems.get(t.id(), [])
        print(f"{t.id().rsplit('.', 1)[-1]:<34} {found[0][0] if found else 'ok'}")
        for kind, line in found:
            print("    " + line)
    print(f"{len(tests)} tests: {len(tests) - len(problems)} passed, {len(problems)} failed")
class GiftCard:
    def __init__(self, cents):
        self.balance = cents

class TestGiftCard(unittest.TestCase):
    def test_new_card_holds_its_amount(self):
        card = GiftCard(2500)
        self.assertEqual(card.balance, 2500)

report(TestGiftCard)
Output
test_new_card_holds_its_amount     ok
1 tests: 1 passed, 0 failed

Step 2: redeeming reduces the balance

The next item on the list is redeeming. The new test calls a method that does not exist yet, so it fails. To pass it, add the method. The first test keeps running too: every step reruns the whole suite, so earlier behavior is protected while new behavior is added.

Green again: redeem added, and both earlier tests still pass
import unittest

def report(*cases):
    """Run TestCase classes and print one line per test (a small test runner)."""
    loader = unittest.TestLoader()
    tests = [t for case in cases for t in loader.loadTestsFromTestCase(case)]
    result = unittest.TestResult()
    unittest.TestSuite(tests).run(result)
    problems = {}
    for kind, items in (("FAIL", result.failures), ("ERROR", result.errors)):
        for test, tb in items:
            problems.setdefault(test.id().split(" ")[0], []).append((kind, tb.strip().splitlines()[-1]))
    for t in tests:
        found = problems.get(t.id(), [])
        print(f"{t.id().rsplit('.', 1)[-1]:<34} {found[0][0] if found else 'ok'}")
        for kind, line in found:
            print("    " + line)
    print(f"{len(tests)} tests: {len(tests) - len(problems)} passed, {len(problems)} failed")
class GiftCard:
    def __init__(self, cents):
        self.balance = cents

    def redeem(self, cents):
        self.balance -= cents
        return self.balance

class TestGiftCard(unittest.TestCase):
    def test_new_card_holds_its_amount(self):
        self.assertEqual(GiftCard(2500).balance, 2500)

    def test_redeem_takes_money_off(self):
        card = GiftCard(2500)
        card.redeem(525)
        self.assertEqual(card.balance, 1975)

    def test_redeem_returns_what_is_left(self):
        self.assertEqual(GiftCard(2500).redeem(525), 1975)

report(TestGiftCard)
Output
test_new_card_holds_its_amount     ok
test_redeem_returns_what_is_left   ok
test_redeem_takes_money_off        ok
3 tests: 3 passed, 0 failed

The third test was added because Mei pointed out that the till needs to print the remaining balance on the receipt, so redeem should return it. A test is a convenient place to record that kind of requirement the moment it comes up.

Step 3: you cannot spend more than the balance

Now the important rule. A customer with 19.75 left on a card must not be able to pay for a 22.00 bag with it. The test says that redeeming too much raises ValueError and leaves the balance untouched. Run against the current code, the test fails, because the code happily lets the balance go negative. That red result is valuable: it shows the test really does check the rule.

Red: overspending is not refused yet
import unittest

def report(*cases):
    """Run TestCase classes and print one line per test (a small test runner)."""
    loader = unittest.TestLoader()
    tests = [t for case in cases for t in loader.loadTestsFromTestCase(case)]
    result = unittest.TestResult()
    unittest.TestSuite(tests).run(result)
    problems = {}
    for kind, items in (("FAIL", result.failures), ("ERROR", result.errors)):
        for test, tb in items:
            problems.setdefault(test.id().split(" ")[0], []).append((kind, tb.strip().splitlines()[-1]))
    for t in tests:
        found = problems.get(t.id(), [])
        print(f"{t.id().rsplit('.', 1)[-1]:<34} {found[0][0] if found else 'ok'}")
        for kind, line in found:
            print("    " + line)
    print(f"{len(tests)} tests: {len(tests) - len(problems)} passed, {len(problems)} failed")
class GiftCard:
    def __init__(self, cents):
        self.balance = cents

    def redeem(self, cents):
        self.balance -= cents
        return self.balance

class TestGiftCard(unittest.TestCase):
    def test_cannot_redeem_more_than_balance(self):
        card = GiftCard(1975)
        with self.assertRaises(ValueError):
            card.redeem(2200)
        self.assertEqual(card.balance, 1975)

report(TestGiftCard)
Output
test_cannot_redeem_more_than_balance FAIL
    AssertionError: ValueError not raised
1 tests: 0 passed, 1 failed
Green: the check is added, and a boundary test confirms the whole balance can be spent
import unittest

def report(*cases):
    """Run TestCase classes and print one line per test (a small test runner)."""
    loader = unittest.TestLoader()
    tests = [t for case in cases for t in loader.loadTestsFromTestCase(case)]
    result = unittest.TestResult()
    unittest.TestSuite(tests).run(result)
    problems = {}
    for kind, items in (("FAIL", result.failures), ("ERROR", result.errors)):
        for test, tb in items:
            problems.setdefault(test.id().split(" ")[0], []).append((kind, tb.strip().splitlines()[-1]))
    for t in tests:
        found = problems.get(t.id(), [])
        print(f"{t.id().rsplit('.', 1)[-1]:<34} {found[0][0] if found else 'ok'}")
        for kind, line in found:
            print("    " + line)
    print(f"{len(tests)} tests: {len(tests) - len(problems)} passed, {len(problems)} failed")
class GiftCard:
    def __init__(self, cents):
        self.balance = cents

    def redeem(self, cents):
        if cents > self.balance:
            raise ValueError(f"only {self.balance} cents left on this card")
        self.balance -= cents
        return self.balance

class TestGiftCard(unittest.TestCase):
    def test_new_card_holds_its_amount(self):
        self.assertEqual(GiftCard(2500).balance, 2500)

    def test_redeem_returns_what_is_left(self):
        self.assertEqual(GiftCard(2500).redeem(525), 1975)

    def test_can_spend_the_whole_balance(self):
        self.assertEqual(GiftCard(1975).redeem(1975), 0)

    def test_cannot_redeem_more_than_balance(self):
        card = GiftCard(1975)
        with self.assertRaises(ValueError):
            card.redeem(2200)
        self.assertEqual(card.balance, 1975)

report(TestGiftCard)
Output
test_can_spend_the_whole_balance   ok
test_cannot_redeem_more_than_balance ok
test_new_card_holds_its_amount     ok
test_redeem_returns_what_is_left   ok
4 tests: 4 passed, 0 failed

While making the test pass, Sam wondered whether spending exactly the whole balance would work, since the check uses > rather than >=. Rather than reason about it, Sam wrote a test, test_can_spend_the_whole_balance, and ran it. It passed straight away, which is fine: not every new test has to fail first if it documents a boundary you want protected. In TDD, a question about behavior is answered by writing a test.

Step 4: refactor

With the tests green, it is time to tidy up. The amount checks are going to be needed in three places, creating, redeeming and topping up, so Sam extracted a small helper, renamed the attribute's parameter for clarity, and added a top_up method driven by its own failing test. The tests were run after each small change. Because they stayed green throughout, the refactoring is known not to have broken anything.

After refactoring: a validation helper, top_up, and six green tests
import unittest

def report(*cases):
    """Run TestCase classes and print one line per test (a small test runner)."""
    loader = unittest.TestLoader()
    tests = [t for case in cases for t in loader.loadTestsFromTestCase(case)]
    result = unittest.TestResult()
    unittest.TestSuite(tests).run(result)
    problems = {}
    for kind, items in (("FAIL", result.failures), ("ERROR", result.errors)):
        for test, tb in items:
            problems.setdefault(test.id().split(" ")[0], []).append((kind, tb.strip().splitlines()[-1]))
    for t in tests:
        found = problems.get(t.id(), [])
        print(f"{t.id().rsplit('.', 1)[-1]:<34} {found[0][0] if found else 'ok'}")
        for kind, line in found:
            print("    " + line)
    print(f"{len(tests)} tests: {len(tests) - len(problems)} passed, {len(problems)} failed")
class GiftCard:
    """A Ridgeline gift card holding a balance in cents."""

    def __init__(self, amount_cents):
        self.balance = self._positive(amount_cents)

    @staticmethod
    def _positive(cents):
        if not isinstance(cents, int) or cents <= 0:
            raise ValueError("amounts must be a positive number of cents")
        return cents

    def redeem(self, cents):
        cents = self._positive(cents)
        if cents > self.balance:
            raise ValueError(f"only {self.balance} cents left on this card")
        self.balance -= cents
        return self.balance

    def top_up(self, cents):
        self.balance += self._positive(cents)
        return self.balance

class TestGiftCard(unittest.TestCase):
    def test_new_card_holds_its_amount(self):
        self.assertEqual(GiftCard(2500).balance, 2500)

    def test_redeem_returns_what_is_left(self):
        self.assertEqual(GiftCard(2500).redeem(525), 1975)

    def test_can_spend_the_whole_balance(self):
        self.assertEqual(GiftCard(1975).redeem(1975), 0)

    def test_cannot_redeem_more_than_balance(self):
        card = GiftCard(1975)
        with self.assertRaises(ValueError):
            card.redeem(2200)
        self.assertEqual(card.balance, 1975)

    def test_amounts_must_be_positive(self):
        for bad in (0, -500, 12.5):
            with self.subTest(bad=bad):
                with self.assertRaises(ValueError):
                    GiftCard(2500).redeem(bad)

    def test_top_up_adds_to_balance(self):
        card = GiftCard(500)
        self.assertEqual(card.top_up(2000), 2500)

report(TestGiftCard)
Output
test_amounts_must_be_positive      ok
test_can_spend_the_whole_balance   ok
test_cannot_redeem_more_than_balance ok
test_new_card_holds_its_amount     ok
test_redeem_returns_what_is_left   ok
test_top_up_adds_to_balance        ok
6 tests: 6 passed, 0 failed

Look back at what the process produced. Every line of GiftCard exists because a test demanded it, so every line is tested. The tests read like the test list in executable form. The design is shaped by how the class is used, because each test was written from the user's side first. And at every moment, Sam had a working, fully tested class; if the work had been interrupted at any step, nothing would have been half-finished.

Habits that make TDD work

Take small steps. A test that needs an hour of coding to pass is too big; split the behavior until each test needs only a few lines. Small steps mean that when something goes wrong, the cause is in the last few lines you wrote.

Watch every new test fail before making it pass. A test that passes the first time it runs, for a behavior that does not exist yet, is broken: perhaps it is not being collected, perhaps it checks the wrong thing. Seeing red first is the test of the test.

Write the simplest code that passes, even if it feels too simple, then let the next test force the code to become more general. Some practitioners deliberately return a hard-coded value to pass the first test, then add a second test with a different value that forces a real calculation. This technique is called triangulation, and it guards against code that only works for the one example you thought of.

Never refactor while red. If a test is failing, either make it pass or remove it, get back to green, and only then restructure. Changing behavior and structure at the same time makes it impossible to tell which change broke something.

Worked problem

Triangulating a bill splitter

Friends sharing one card payment want the bill split as evenly as possible in whole cents. Drive split_bill(total_cents, people) with TDD and triangulation.

Show solution
  1. Test 1: split_bill(1000, 2) == [500, 500]. Simplest pass: return [total_cents // people] * people.
  2. Test 2: split_bill(1000, 3) must add up to 1000. It fails: [333, 333, 333] totals 999.
  3. Pass it: compute the remainder with divmod and add one cent to the first remainder shares: [334, 333, 333].
  4. Test 3: people must be at least 1; split_bill(1000, 0) raises ValueError.
  5. Refactor: name the pieces (base, extra), add a docstring, rerun all tests.

Answer: divmod gives base shares and a remainder; the first remainder people pay one extra cent, so the shares always total the bill.

What TDD gives, and where it struggles

Teams that practice TDD report several benefits. Code ends up thoroughly tested by construction. Designs tend to be simpler and more modular, because code that is hard to test is painful to write test-first, which pushes toward small functions and injected dependencies. The tests serve as up-to-date documentation of what the code does. And many developers find the rhythm reassuring: there is always a clear next step, and always a recent green state to return to.

TDD is not a fit for everything. When you are exploring, such as trying out an unfamiliar library or sketching a user interface to see what feels right, you do not yet know what the tests should say; it is common to prototype freely, throw the prototype away, and then build the real thing test-first. Code that mostly arranges a screen layout or calls an external service is awkward to drive with unit tests. TDD also takes practice: the first attempts feel slow. Like any technique, it is a tool to use where it helps, not a rule to follow everywhere.

A related style, behavior-driven development or BDD, writes the tests in a structured, business-readable form: given a card with 19.75 on it, when the customer redeems 22.00, then the payment is refused and the balance stays 19.75. Tools such as behave and pytest-bdd turn sentences like these into executable tests. The given, when, then phrasing is a useful way to name and structure ordinary tests too.

Try it yourself

Pass the gift card tests

The tests for a GiftCard class are written; your job is the green step. Write the class: GiftCard(cents) stores balance; redeem(cents) takes money off and returns the new balance, raising ValueError and leaving the balance unchanged if there is not enough; top_up(cents) adds to the balance and returns it.

class GiftCard:
    def __init__(self, cents):
        self.balance = cents

Try it yourself

Write the next red test

The setup defines a GiftCard class that has balance, redeem and top_up, but it does not check that amounts are positive. The next item on the test list is: redeeming 0 or a negative amount raises ValueError.

Write a unittest.TestCase named TestPositiveAmounts for that behavior. It must FAIL against the current class (red) and PASS against a finished class the checker swaps in (green).

import unittest

class TestPositiveAmounts(unittest.TestCase):
    def test_redeem_works(self):
        self.assertEqual(GiftCard(500).redeem(100), 400)

Try it yourself

Split the bill

Write split_bill(total_cents, people) that returns a list of people shares in whole cents. The shares must add up exactly to total_cents, differ by at most one cent, and list the larger shares first. Raise ValueError if people is less than 1.

def split_bill(total_cents, people):
    return [total_cents // people] * people

print(split_bill(1000, 3))

Common mistakes

  • MistakeWriting a big test that needs an hour of code before it can pass.

    InsteadSplit the behavior into small steps, each needing only a few lines.

  • MistakeSkipping the red step and writing the code first "just this once".

    InsteadRun every new test and watch it fail before writing the code.

  • MistakeWriting far more code than the current test requires.

    InsteadWrite the simplest code that passes; let the next test drive more.

  • MistakeRefactoring while a test is failing.

    InsteadGet back to green first, then restructure in small steps.

  • MistakeKeeping only one example per behavior, so hard-coded answers pass.

    InsteadTriangulate: add a second, different example that forces a real calculation.

  • MistakeInsisting on TDD while still exploring what to build.

    InsteadPrototype freely, throw it away, then build the real code test-first.

Summary and key terms

  • Test-driven development writes a failing test first, then the simplest code to pass, then refactors: red, green, refactor.
  • A test list of expected behaviors guides the work and changes as you learn.
  • Watching each test fail first proves it checks something real.
  • Triangulation adds a second example so hard-coded or special-case code cannot pass.
  • Never refactor while red; change behavior and structure in separate steps.
  • TDD yields thorough tests and testable, modular designs, but suits exploratory work and interface layout less well.
  • BDD expresses tests as given, when, then scenarios in business language.
test-driven development (TDD)
Writing a failing test before the code that makes it pass, in short cycles.
red, green, refactor
The TDD cycle: failing test, passing code, then clean-up with tests still passing.
test list
A short list of behaviors to implement, used to plan TDD work.
triangulation
Adding a second example to force code to generalize instead of hard-coding one answer.
refactoring
Changing the structure of code without changing its behavior.
behavior-driven development (BDD)
Writing tests as given, when, then scenarios in language the business understands.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Why should a new TDD test fail before you write the code?

    Show answer

    The failure proves the test is collected and checks behavior the code does not yet have.

  2. What is the green step's goal?

    Show answer

    The simplest code that makes the failing test pass, and nothing more.

  3. A test passes the first time for behavior that has not been written. What might be wrong?

    Show answer

    It may not be collected, or it may check the wrong thing.

  4. What does triangulation protect against?

    Show answer

    Code that only works for the single example a test used, such as a hard-coded return value.

  5. Two tests are red and you want to rename a method. What should you do first?

    Show answer

    Get the tests green, then rename in a separate step.

  6. Write the given, when, then sentence for refusing to overspend a gift card.

    Show answer

    Given a card with 19.75 on it, when the customer redeems 22.00, then the payment is refused and the balance stays 19.75.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz