Project Goals
3 min- Drive a small domain model entirely from tests.
- Use a fixture for the catalogue.
- Cover happy paths AND business-rule errors (double-loan).
- Refactor under green at the end of the build.
Warm-Up · The Requirements
5 minA Catalogue should: - start empty - add a book (title) and find it - track whether a book is available - borrow a book → marks it unavailable - return a book → marks it available - reject borrowing an already-borrowed book - reject borrowing/returning an unknown book
You won't write the Catalogue class first. You'll write a test for "starts empty", make it pass, then a test for "add a book", make it pass — letting the class's shape emerge from what the tests demand.
Build It Test-First
14 min🔴🟢 Step 1 — starts empty
# test_catalogue.py import pytest from catalogue import Catalogue @pytest.fixture def cat(): return Catalogue() def test_starts_empty(cat): assert len(cat) == 0
# catalogue.py class Catalogue: def __init__(self): self._books = {} # title -> available? def __len__(self): return len(self._books)
🔴🟢 Step 2 — add & find
def test_add_book(cat): cat.add("Dune") assert cat.has("Dune") assert len(cat) == 1
def add(self, title): self._books[title] = True # True = available def has(self, title): return title in self._books
🔴🟢 Step 3 — availability + borrow
def test_borrow_makes_unavailable(cat): cat.add("Dune") cat.borrow("Dune") assert not cat.is_available("Dune")
def is_available(self, title): return self._books[title] def borrow(self, title): self._books[title] = False
🔴🟢 Step 4 — business rules (errors)
def test_double_borrow_raises(cat): cat.add("Dune"); cat.borrow("Dune") with pytest.raises(ValueError, match="already borrowed"): cat.borrow("Dune") def test_borrow_unknown_raises(cat): with pytest.raises(KeyError): cat.borrow("Ghost")
def borrow(self, title): if title not in self._books: raise KeyError(title) if not self._books[title]: raise ValueError("already borrowed") self._books[title] = False
The Part-1 Artifacts
12 min# catalogue.py — emerged from the tests class Catalogue: def __init__(self): self._books = {} # title -> available (bool) def __len__(self): return len(self._books) def add(self, title): self._books[title] = True def has(self, title): return title in self._books def is_available(self, title): self._check(title) return self._books[title] def borrow(self, title): self._check(title) if not self._books[title]: raise ValueError("already borrowed") self._books[title] = False def return_book(self, title): self._check(title) self._books[title] = True def _check(self, title): # refactored: shared guard if title not in self._books: raise KeyError(title)
# test_catalogue.py — the full Part-1 suite import pytest from catalogue import Catalogue @pytest.fixture def cat(): return Catalogue() def test_starts_empty(cat): assert len(cat) == 0 def test_add(cat): cat.add("Dune"); assert cat.has("Dune") def test_new_book_available(cat): cat.add("Dune"); assert cat.is_available("Dune") def test_borrow(cat): cat.add("Dune"); cat.borrow("Dune"); assert not cat.is_available("Dune") def test_return(cat): cat.add("Dune"); cat.borrow("Dune"); cat.return_book("Dune") assert cat.is_available("Dune") def test_double_borrow_raises(cat): cat.add("Dune"); cat.borrow("Dune") with pytest.raises(ValueError, match="already borrowed"): cat.borrow("Dune") def test_unknown_raises(cat): with pytest.raises(KeyError): cat.borrow("Ghost")
$ pytest test_catalogue.py -v 7 passed $ pytest --cov=catalogue catalogue.py ... 100% ← coverage came for free
Read the diff
The _check helper is a refactor done at the end, under green — it removed the duplicated "is it a known title?" guard from three methods. The class was never designed up front; it grew to satisfy seven tests, and hit 100% coverage by construction. Part 2 adds search, filter, and sort the same way.
Build Your Part 1
13 minBuild the catalogue yourself, test-first, committing after each green. Don't peek at the finished class until you've tried each step.
TDD a new rule: a catalogue can hold multiple copies of a title, and borrow only fails when ALL copies are out. Tests first.
TDD: returning a book that's already available should raise (it was never borrowed). Write the test, watch it fail, fix.
Mini-Challenge · Members & Limits
8 minTDD a borrow limit: a member can hold at most 3 books at once; a 4th borrow raises. You'll need to track who borrowed what. Drive the whole feature from failing tests.
Recap
3 minYou built a domain class entirely test-first: each behaviour (add, borrow, return) and each business rule (double-borrow, unknown title) arrived as a failing test, then minimal code. The shared _check guard was a green-bar refactor. Coverage hit 100% naturally. Part 2 extends it with search/filter/sort under the same discipline.
Homework
4 minComplete the catalogue Part 1 test-first, including the multi-copy rule and the member borrow-limit. Commit after each green. Run coverage — you should be at or near 100%. Keep this; Part 2 builds on it.
Use the finished catalogue.py + suite as the base, then TDD the multi-copy and borrow-limit features on top — tests before code, every time.