Module 3: Contract Testing Consumer And Provider
8. Mini-project: write the `BookingRepository` contract
Description
The time has come to put the seven lessons to work with your own hands. In this mini-project you build, from scratch, the BookingRepository contract as a parametrized test battery, run it against the FakeBookingRepository and the SqliteBookingRepository to certify them —both green—, and then put in the divergent fake from module 2 to see it caught in red. By the end you'll have, made by you, the whole instrument of this module: a shared behavior spec that turns "I hope the fake doesn't lie" into "the fake can't lie without a red test giving it away".
There are no new concepts here; there's synthesis. You're going to use everything: the four clauses as behavior and not shape (lesson 2), driven by the consumer's needs (lesson 3), run with a parametrized fixture against several implementations (lesson 4), catching module 2's divergence (lesson 5), asserting about state (lesson 6). It's the same work the industry automates with Pact (lesson 7), done by hand with pytest and the stdlib's sqlite3. The submission is concrete: the battery, the green output of both providers, and the red output of the fake that lies.
Connection to the module: this lesson is module 3's practical close and its trial by fire. If you can write this battery without looking, run it against two providers, and read the result —including the red of the divergent fake—, you master the contract as a shared battery, which is what the module promised. It's also the ramp to module 4: there we'll take this same contract and look at it from its two sides —the consumer test and the provider test— to catch a breaking change before deploying it. Here you leave it built and working.
The assignment
Write a contract battery for Reservo's BookingRepository that meets these requirements:
- Four behavior clauses, each a test:
- save and read returns the same booking;
getof a missing id raises;- saving the same id twice updates (doesn't duplicate);
find_by_roomreturns only that room's bookings.
- A parametrized fixture that delivers, on different runs, a
FakeBookingRepositoryand aSqliteBookingRepositoryover:memory:. The four clauses must run against both without duplicating code. - The green certification: run the battery and confirm that the eight combinations (4 clauses × 2 providers) pass.
- The red divergence: create a second battery (or change the provider) that uses the
BuggyFakeBookingRepository—the one from module 2, whosegetof a missing id returnsNoneinstead of raising— alongside the real one, and confirm that the battery catches it: exactly the missing-id clause fails for the buggy fake and passes for the real one.
Use Booking.price_cents for money (int cents), English identifiers, and Reservo's anchors (Focus 3 h pro = 6000 cents). Actually run it and save the output.
Step 1: the domain pieces
Have on hand the pieces the battery will use. The model (a booking), the correct fake, the buggy fake, and the real repository. They're the ones you've been seeing all module:
# reservo/models.py — the domain
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Booking:
id: str
room_id: str
member_id: str
start: datetime
end: datetime
status: str # "confirmed" | "cancelled"
price_cents: int # what was charged, in cents
# reservo/doubles.py — the correct fake and the fake that lied
class FakeBookingRepository:
def __init__(self):
self._store = {}
def save(self, booking):
self._store[booking.id] = booking
def get(self, booking_id):
return self._store[booking_id] # RAISES KeyError if it doesn't exist
def find_by_room(self, room_id):
return [b for b in self._store.values() if b.room_id == room_id]
class BuggyFakeBookingRepository:
def __init__(self):
self._store = {}
def save(self, booking):
self._store[booking.id] = booking
def get(self, booking_id):
return self._store.get(booking_id) # RETURNS None if it doesn't exist (M2 bug)
def find_by_room(self, room_id):
return [b for b in self._store.values() if b.room_id == room_id]
The SqliteBookingRepository is the one from the previous lessons: it stores in a table, converts the datetime to ISO text in save and back to datetime in get (to fulfill clause 1), and raises KeyError when the row doesn't exist (to fulfill clause 2). We don't repeat it in full here; it's the reservo/sqlite_repo.py you already know.
Step 2: the contract battery
Write the battery. It's the heart of the assignment: four clauses and a fixture that runs them against both providers.
# tests/test_repository_contract.py
import sqlite3
from datetime import datetime
import pytest
from reservo.doubles import FakeBookingRepository
from reservo.models import Booking
from reservo.sqlite_repo import SqliteBookingRepository
START = datetime(2026, 3, 10, 9)
END = datetime(2026, 3, 10, 12) # Focus 3 h
def a_booking(id="bk-1", room_id="focus", status="confirmed", price_cents=6000):
return Booking(id=id, room_id=room_id, member_id="m-ana",
start=START, end=END, status=status, price_cents=price_cents)
# The parametrized fixture: each clause runs against the fake AND against SQLite.
@pytest.fixture(params=["fake", "sqlite"])
def repo(request):
if request.param == "fake":
return FakeBookingRepository()
return SqliteBookingRepository(sqlite3.connect(":memory:"))
# Clause 1: save-and-read returns the same booking.
def test_save_then_get_returns_the_same_booking(repo):
booking = a_booking()
repo.save(booking)
assert repo.get("bk-1") == booking
# Clause 2: get of a missing id raises.
def test_get_of_a_missing_id_raises(repo):
with pytest.raises(KeyError):
repo.get("does-not-exist")
# Clause 3: save the same id twice updates (does not duplicate).
def test_saving_the_same_id_twice_updates_not_duplicates(repo):
repo.save(a_booking(status="confirmed"))
repo.save(a_booking(status="cancelled")) # same id "bk-1"
assert repo.get("bk-1").status == "cancelled"
assert len(repo.find_by_room("focus")) == 1
# Clause 4: find_by_room returns only that room's bookings.
def test_find_by_room_returns_only_that_rooms_bookings(repo):
repo.save(a_booking(id="bk-1", room_id="focus"))
repo.save(a_booking(id="bk-2", room_id="studio"))
found = repo.find_by_room("focus")
assert [b.id for b in found] == ["bk-1"]
Step 3: certify the fake and the real one (green)
Run the battery. You expect eight greens: the four clauses times the two providers.
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_repository_contract.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 8 items
tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[fake] PASSED [ 12%]
tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[sqlite] PASSED [ 25%]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[fake] PASSED [ 37%]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[sqlite] PASSED [ 50%]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[fake] PASSED [ 62%]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[sqlite] PASSED [ 75%]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[fake] PASSED [ 87%]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[sqlite] PASSED [100%]
============================== 8 passed in 0.03s ==============================
Eight greens: the FakeBookingRepository and the SqliteBookingRepository fulfill the four clauses of the contract. That's the certification —the fake isn't lying about anything the contract covers—, and it's the first half of your submission.
Step 4: catch the divergent fake (red)
Now the part that gives everything its meaning: put in the buggy fake from module 2 and check that the battery gives it away. Reuse the same four clauses —the contract doesn't change—; just change the "fake" provider for the buggy one in the fixture:
# tests/test_contract_catches_divergence.py
import sqlite3
from datetime import datetime
import pytest
from reservo.doubles import BuggyFakeBookingRepository
from reservo.models import Booking
from reservo.sqlite_repo import SqliteBookingRepository
START = datetime(2026, 3, 10, 9)
END = datetime(2026, 3, 10, 12)
def a_booking(id="bk-1", room_id="focus", status="confirmed", price_cents=6000):
return Booking(id=id, room_id=room_id, member_id="m-ana",
start=START, end=END, status=status, price_cents=price_cents)
# The DIVERGENT fake from module 2 alongside the real one.
@pytest.fixture(params=["buggy-fake", "sqlite"])
def repo(request):
if request.param == "buggy-fake":
return BuggyFakeBookingRepository()
return SqliteBookingRepository(sqlite3.connect(":memory:"))
def test_save_then_get_returns_the_same_booking(repo):
booking = a_booking()
repo.save(booking)
assert repo.get("bk-1") == booking
def test_get_of_a_missing_id_raises(repo):
with pytest.raises(KeyError):
repo.get("does-not-exist")
def test_saving_the_same_id_twice_updates_not_duplicates(repo):
repo.save(a_booking(status="confirmed"))
repo.save(a_booking(status="cancelled"))
assert repo.get("bk-1").status == "cancelled"
assert len(repo.find_by_room("focus")) == 1
def test_find_by_room_returns_only_that_rooms_bookings(repo):
repo.save(a_booking(id="bk-1", room_id="focus"))
repo.save(a_booking(id="bk-2", room_id="studio"))
found = repo.find_by_room("focus")
assert [b.id for b in found] == ["bk-1"]
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_contract_catches_divergence.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 8 items
tests/test_contract_catches_divergence.py::test_save_then_get_returns_the_same_booking[buggy-fake] PASSED [ 12%]
tests/test_contract_catches_divergence.py::test_save_then_get_returns_the_same_booking[sqlite] PASSED [ 25%]
tests/test_contract_catches_divergence.py::test_get_of_a_missing_id_raises[buggy-fake] FAILED [ 37%]
tests/test_contract_catches_divergence.py::test_get_of_a_missing_id_raises[sqlite] PASSED [ 50%]
tests/test_contract_catches_divergence.py::test_saving_the_same_id_twice_updates_not_duplicates[buggy-fake] PASSED [ 62%]
tests/test_contract_catches_divergence.py::test_saving_the_same_id_twice_updates_not_duplicates[sqlite] PASSED [ 75%]
tests/test_contract_catches_divergence.py::test_find_by_room_returns_only_that_rooms_bookings[buggy-fake] PASSED [ 87%]
tests/test_contract_catches_divergence.py::test_find_by_room_returns_only_that_rooms_bookings[sqlite] PASSED [100%]
=================================== FAILURES ===================================
_________________ test_get_of_a_missing_id_raises[buggy-fake] __________________
repo = <reservo.doubles.BuggyFakeBookingRepository object at 0x...>
def test_get_of_a_missing_id_raises(repo):
> with pytest.raises(KeyError):
E Failed: DID NOT RAISE KeyError
tests/test_contract_catches_divergence.py:34: Failed
=========================== short test summary info ============================
FAILED tests/test_contract_catches_divergence.py::test_get_of_a_missing_id_raises[buggy-fake] - Failed: DID NOT RAISE KeyError
========================= 1 failed, 7 passed in 0.04s ==========================
There's the second half of your submission, the red that crowns the module. A single case fails —test_get_of_a_missing_id_raises[buggy-fake]— with the unequivocal message DID NOT RAISE KeyError; the same test passes for [sqlite]. The id in brackets points at the culprit (the fake, not the real one), the clause points at the broken behavior (the missing id), and the message points at the cause (it didn't raise). Module 2's divergence, which only appeared in production, is now a red on your machine, with a name, a line, and a reason. That's the contract doing its job.
The submission
Gather and check that you have:
- The contract battery (
test_repository_contract.py): four behavior clauses and a parametrized fixture["fake", "sqlite"]. - The green output:
8 passed, with the ids[fake]and[sqlite]for each clause. Certifies that the fake and the real one fulfill the contract. - The battery that catches the divergence (
test_contract_catches_divergence.py): the same four clauses with theBuggyFakeBookingRepositoryinstead of the correct fake. - The red output:
1 failed, 7 passed, withtest_get_of_a_missing_id_raises[buggy-fake] FAILED — DID NOT RAISE KeyError. Demonstrates that the contract catches the fake that lies. - A three-line diagnosis: which clause failed, against which provider, and why —and what the correct fix would be (align the fake to the behavior the consumer needs: that missing
getraises), without degrading the contract or touching the healthy provider—.
Common mistakes
Turning in only the green. What happens: the battery is run, 8 passed comes out, and the project is considered done. Why it happens: the green feels like "done". How to detect it: if you don't have a red, you didn't demonstrate that the contract is useful —a battery you've only seen pass could be empty of content and pass anyway—. How to fix it: the divergent fake's red is half the assignment, not an extra. A contract is demonstrated both by what it certifies (the green) and by what it catches (the red). Without the red, you didn't prove the battery detects anything.
Duplicating the battery instead of parametrizing. What happens: to test the fake and the real one, two files are written with the same tests copied. Why it happens: it's the first instinct —two providers, two suites—. How to detect it: if adding a clause forces you to edit it in two places, you duplicated. How to fix it: a fixture with params runs the same clauses against both. It's what guarantees they can't diverge —there's a single spec—, and it's explicitly what the assignment asks for (requirement 2).
Closing step 4's red the wrong way. What happens: to "fix" the 1 failed, someone makes the SqliteBookingRepository return None on missing get, or deletes the clause. Why it happens: a red is uncomfortable. How to detect it: if your fix makes the contract tolerate the behavior the consumer doesn't want, you degraded it. How to fix it: step 4's red must stay red —it's the demonstration that the battery catches the divergence—. The buggy fake is wrong on purpose; the real fix (outside this mini-project) would be to correct the fake so it raises, but here the goal is to see it caught, not to silence it.
Exercises
Exercise 1 — Add a fifth clause. The consumer needs find_by_room of a room without bookings to return an empty list, not to raise or return None. Write the clause as a parametrized test and say, without running it, whether the fake and the real one pass it.
See solution
# Clause 5: find_by_room of a room without bookings returns an empty list.
def test_find_by_room_of_empty_room_returns_empty_list(repo):
result = repo.find_by_room("nonexistent-room")
assert result == []
Both pass it. The FakeBookingRepository.find_by_room is [b for b in self._store.values() if b.room_id == room_id]: over an empty dict (or one without that room's bookings) the list comprehension returns []. The SqliteBookingRepository.find_by_room does a SELECT ... WHERE room_id = ? and a fetchall(): without matching rows, fetchall() returns an empty list, so it also returns []. Both fulfill it: the battery would be at 10 passed (5 clauses × 2 providers).
The lesson: this clause covers an edge (the empty room) the contract was silent about. Even though today both providers already fulfill it "by chance" of how they're written, making it a clause turns it into a watched promise: if tomorrow someone writes a provider whose find_by_room returns None for an empty room, the battery would catch it. Each edge you state is a door you close.
Exercise 2 — A second divergent fake. Write a NoUpdateFakeBookingRepository whose save inserts but never updates (if the id already exists, it adds a second copy to an internal list instead of replacing). Put it in the battery alongside the real one. Without running, say which clause catches it and with what id.
See solution
class NoUpdateFakeBookingRepository:
def __init__(self):
self._items = [] # list, not dict: allows duplicates by id
def save(self, booking):
self._items.append(booking) # ALWAYS appends, never replaces
def get(self, booking_id):
for b in reversed(self._items):
if b.id == booking_id:
return b
raise KeyError(booking_id)
def find_by_room(self, room_id):
return [b for b in self._items if b.room_id == room_id]
The clause that catches it is 3: test_saving_the_same_id_twice_updates_not_duplicates. That test saves bk-1 twice (confirmed, then cancelled) and asserts two things: that get("bk-1").status == "cancelled" and that len(repo.find_by_room("focus")) == 1. The NoUpdateFakeBookingRepository would store two copies of bk-1, so find_by_room("focus") would return a list of length 2, and the assertion == 1 would fail. (The first assertion of the test could pass, because get returns the last copy, cancelled; but the second, about the count, gives away the duplicate.)
The red's id would be test_saving_the_same_id_twice_updates_not_duplicates[no-update-fake], while [sqlite] passes —the real one uses ON CONFLICT(id) DO UPDATE, so it updates instead of duplicating—. Each type of divergence lights up its clause: the None on missing get lit up clause 2, the not-updating lights up clause 3. A contract well populated with clauses is a net with a knot for each behavior that matters.
Exercise 3 — The complete diagnosis. You ran step 4 and got 1 failed, 7 passed with test_get_of_a_missing_id_raises[buggy-fake] FAILED. Write the three-line diagnosis the submission asks for (what failed, against which provider, why) and the correct fix, connecting it with the consumer-driven approach.
See solution
Diagnosis:
- What failed: clause 2 of the contract,
test_get_of_a_missing_id_raises—"getof a missing id must raiseKeyError"—. - Against which provider: the
[buggy-fake](theBuggyFakeBookingRepository). The same test passes for[sqlite], so the real one fulfills the clause and serves as the correct reference. - Why: the buggy fake's
getdoesself._store.get(booking_id), which returnsNonewhen the key doesn't exist, instead ofself._store[booking_id], which raisesKeyError.pytest.raises(KeyError)didn't see the expected exception and reportedDID NOT RAISE KeyError.
The correct fix: align the fake to the behavior the consumer needs. BookingService.cancel does booking = self._repo.get(booking_id) and then refund_cents(booking, ...); if get returns None, cancel blows up with an AttributeError later, so the consumer needs missing get to raise to react cleanly. You change self._store.get(booking_id) for self._store[booking_id] in the fake, and the battery goes back to eight greens. What you don't do: degrade the contract (delete the clause, accept None) or touch the SqliteBookingRepository, which already fulfills it —fixing the healthy provider would introduce in production exactly the bug the contract just caught—. The correct behavior is dictated by the consumer; the contract demands it of all; the provider that deviates is the one that's fixed.
Module summary and next step
With this mini-project you closed module 3 having built, with your own hands, the discipline's central instrument: the contract as a shared battery. You went through the seven lessons that lead to it. You started (lesson 1) by taking module 2's problem —the fake that lies— and naming its cure: a behavior spec verified the same against all implementations. You separated (lesson 2) the interface (the shape) from the contract (the behavior), the crack the bug slipped through. You discovered (lesson 3) that the consumer rules: its needs are the clauses. You mastered (lesson 4) the mechanism —the parametrized fixture that runs a battery against the fake and SQLite— and (lesson 5) saw it catch module 2's divergence in red, with the [buggy-fake] id pointing at the culprit. You distinguished (lesson 6) state contracts (asserting about the result) from interaction ones (asserting about the call), and when each goes. And you situated everything (lesson 7) within the industry's panorama with the concept of Pact —the same pattern, automated and networked, with its pact file and its broker—.
What you take from the module, in one sentence: a contract is a consumer-driven behavior spec that, run as a single battery against all implementations, makes it impossible for a double to lie without a red test giving it away.
The next step goes deeper into something we treated here as a single thing: the two sides of the contract. In module 4, Verifying the contract from both sides, we separate the consumer test ("I, BookingService, send this and expect that") from the provider test ("I, the repository, given this return that"), run the same battery against the fake and the real one from each side, and use the contract to catch a breaking change before deploying it —the superpower that in lesson 3 only barely peeked out—. You carry the contract built; module 4 teaches you to look at it from both shores.
Resources
- pytest documentation — Parametrizing fixtures — the reference for the fixture with
paramsthat runs your battery against the fake and SQLite; requirement 2 of the assignment. - pytest documentation —
pytest.raises— how the missing-id clause is written and what theDID NOT RAISEthat catches the divergent fake in step 4 means. sqlite3— DB-API for SQLite (Python documentation) — the real provider your contract certifies, withsqlite3.connect(":memory:")for an ephemeral, clean database per test.testing-backend-applications-guide— where to go when the seam stops being in-process and becomes a real web app with HTTP; the other side of this guide's border.