Module 4: Verifying The Contract From Both Sides
8. Mini-project: verify the contract from both sides
Description
This is the module's practical close. The previous seven lessons gave you the contract seen from its two chairs —the consumer side, the provider side—, the guarantee of running a single battery against both, the two ways to break it (the provider breaking change, the consumer over-assumption), and the governance question (who owns it). Now it's your turn to weave it into a single deliverable that demonstrates the guide's central payoff with your own hands: verify the BookingRepository contract from both sides —green against the Fake and against the real Sqlite— and then introduce an incompatible change in the SqliteBookingRepository yourself to see it caught in red, before any deploy.
What is really evaluated here isn't that you get the green or the red —both are easy to produce— but that you can explain why the contract is the net that caught the change. Anyone can paste a battery and break a line. The deliverable that matters is the diagnosis: naming which clause broke, on which side ([sqlite], not [fake]), why that asymmetry points at the real provider as the culprit, what would have happened in production without the battery, and what the correct way out is (revert or renegotiate the contract, never loosen the test). A student who turns in the red without the diagnosis has shown they know how to break code; one who turns in the complete diagnosis has shown they understood why a consumer-driven contract, run before the deploy, turns a production incident into a local red test of two hundredths of a second.
Connection to the module: this lesson is module 4's practical exam and its close. It gathers lesson 4's battery (green against both), lesson 5's technique (catching a provider breaking change), and lesson 7's frame (the consumer owns the contract), and presents them as a project with deliverables and a reference solution. After the statement, it summarizes the module and points to module 5, where we'll leave the isolated repository contract to test the real components together —real integration—.
Analogy: the fire drill
Think of an office building that installs a fire sprinkler system. Installing it isn't enough: you have to run a drill to test that it really works. The drill has two parts. First, you verify that everything is in order with the system at rest: the sensors green, the water pressure correct, the exits clear. Second —and this is what turns an installation into a guarantee— you trigger a controlled smoke signal, on purpose, to check that the sprinklers activate. Nobody trusts a fire system that was never tested with real smoke; the drill is what proves the alarm sounds when it should.
Your mini-project is that drill. The first part is the battery green against both providers: the system at rest, all in order. The second part —the one that proves the contract is a living alarm and not decoration— is triggering the "smoke" yourself: introducing a breaking change in the SqliteBookingRepository and checking that the battery goes red, on the exact clause, pointing at the real provider. A contract you've only seen green is like a sprinkler you never tested with smoke: you don't know if it would trip. The drill —the deliberate breaking change— is what gives you the certainty that, the day a colleague breaks a promise by accident, the contract will catch it before the deploy. Turning in the complete drill, not just the system at rest, is what separates "I installed a contract" from "I know my contract protects".
The project: formal statement
Your task is to demonstrate, from start to finish and with real pytest output, that the BookingRepository contract verifies both sides and catches a provider breaking change before the deploy. Specifically:
Part 1 — Verify the contract from both sides. Write (or reuse) the BookingRepository contract battery with its four clauses —save-and-read returns the same booking; get of a missing id raises; saving the same id twice updates without duplicating; find_by_room returns only that room's bookings— parametrized with a params=["fake", "sqlite"] fixture. Run it and show the eight greens: the four clauses honored by the FakeBookingRepository and by the real SqliteBookingRepository.
Part 2 — Introduce a breaking change and catch it. Modify the SqliteBookingRepository yourself to break one of the contract's promises. Run the same battery again —without touching the tests— and show the red: the exact clause on the [sqlite] side, with the [fake] intact.
Deliverables
- The contract battery, parametrized against the fake and the real one, with its four clauses.
- The real output of part 1: the eight greens (
[fake]and[sqlite]), proof that both sides honor the contract. - The breaking change: the exact diff of what you changed in the
SqliteBookingRepository, and which clause you expect it to break. - The real output of part 2: the red that catches it, showing the clause, the
[sqlite]side, and the[fake]that stays green. - The diagnosis (the deliverable that weighs most), answering: (a) which clause broke and why only the
[sqlite]side? (b) what would have happened in production without the battery? (c) what's the correct way out of the red, and why isn't "loosening the test" it?
Worked example, part 1: the contract green on both sides
Here's the complete battery —lesson 4's— and its green run. The eight greens are the proof that the fake and the real one honor the same contract.
# tests/test_repository_contract.py — the contract battery, four clauses, two providers
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)
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)
@pytest.fixture(params=["fake", "sqlite"])
def repo(request):
if request.param == "fake":
return FakeBookingRepository()
return SqliteBookingRepository(sqlite3.connect(":memory:"))
def test_save_then_get_returns_the_same_booking(repo):
repo.save(a_booking())
got = repo.get("bk-1")
assert got.id == "bk-1"
assert got.room_id == "focus"
assert got.start == START # datetime, not str
assert got.price_cents == 6000
assert got.status == "confirmed"
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"))
got = repo.get("bk-1")
assert got.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"))
ids = {b.id for b in repo.find_by_room("focus")}
assert ids == {"bk-1"}
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.01s ===============================
With this you have deliverables 1 and 2: the battery and the eight greens. Both sides honor the four clauses. The system at rest, in order. Now the drill.
Worked example, part 2: the breaking change and its red
We introduce the smoke on purpose. The breaking change I choose is a classic —module 1's bug, reintroduced—: making SqliteBookingRepository.get stop converting the datetime back, returning it as the raw text the database stores. The diff, in the get method (inside _row_to_booking):
# BEFORE (honors the contract): reconstructs the datetime from the ISO text
return Booking(
id=row[0], room_id=row[1], member_id=row[2],
start=datetime.fromisoformat(row[3]), # ISO text -> datetime back
end=datetime.fromisoformat(row[4]),
status=row[5], price_cents=row[6],
)
# AFTER (BREAKING CHANGE): returns the raw text, without reconstructing
return Booking(
id=row[0], room_id=row[1], member_id=row[2],
start=row[3], # <-- forgets to convert to datetime; comes back as str
end=row[4],
status=row[5], price_cents=row[6],
)
One line (well, two: start and end). It's exactly the kind of "simplification" someone would make without thinking —"after all, it's the same data"—. We run the same battery, without touching a single test:
python3 -m pytest tests/test_repository_contract.py -v
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] FAILED [ 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%]
=================================== FAILURES ===================================
_____________ test_save_then_get_returns_the_same_booking[sqlite] ______________
repo = <reservo.sqlite_repo.SqliteBookingRepository object at 0x101e69160>
def test_save_then_get_returns_the_same_booking(repo):
repo.save(a_booking())
got = repo.get("bk-1")
assert got.id == "bk-1"
assert got.room_id == "focus"
> assert got.start == START # datetime, not str
E AssertionError: assert '2026-03-10T09:00:00' == datetime.datetime(2026, 3, 10, 9, 0)
E + where '2026-03-10T09:00:00' = Booking(id='bk-1', ..., start='2026-03-10T09:00:00', ...).start
tests/test_repository_contract.py:34: AssertionError
=========================== short test summary info ============================
FAILED tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[sqlite]
========================= 1 failed, 7 passed in 0.02s ==========================
1 failed, 7 passed. The drill worked: the alarm sounded. The clause test_save_then_get_returns_the_same_booking went red only on the [sqlite] side —'2026-03-10T09:00:00' (a str) is not equal to datetime.datetime(2026, 3, 10, 9, 0)—, while the [fake] of the same clause stays green and the other three clauses too. With this you have deliverables 3 and 4: the breaking change diff and the red that catches it, on the exact clause, before any deploy. What's missing is the one that weighs: the diagnosis.
Reference solution
See the complete diagnosis (deliverable 5)
(a) Which clause broke and why only the [sqlite] side? test_save_then_get_returns_the_same_booking broke —clause 1: "save-and-read returns the same booking"—, on the assertion assert got.start == START. It broke only in [sqlite] because the change was made in the SqliteBookingRepository: by returning start=row[3] without datetime.fromisoformat(...), the real provider returns the start as the str '2026-03-10T09:00:00', which isn't equal to the original datetime. The FakeBookingRepository didn't change —it still stores the whole object in a dict and returns it with its datetime intact—, so its side of the clause passes. That asymmetry ([fake] green, [sqlite] red, same clause) is the diagnosis: it points unambiguously at the real implementation as the one that deviated from the contract, not at the contract or the fake. Notice also the assertions that survive: got.id, got.room_id, got.price_cents, got.status pass even in [sqlite], because those fields (text and integers) have a native type in SQLite and cross the seam without changing; only the datetime, which has to be serialized, breaks.
(b) What would have happened in production without the battery? The change would have reached production unseen, because it compiles and all of BookingService's unit tests (which use the fake, which didn't change) stay green. The bug would live hidden until some code consumed the start expecting a datetime —a screen that does booking.start.strftime("%H:%M"), a computation that subtracts dates— and blew up with an AttributeError: 'str' object has no attribute 'strftime' or a TypeError when operating a str as if it were a date. The symptom would appear far from the repository's get and late (in the screen, in the report), and whoever debugged it would lose time looking in the wrong place. The battery turns that distant incident into a local red that names the clause, the provider, and the exact field, at the moment of running the tests.
(c) What's the correct way out of the red, and why isn't "loosening the test" it? The correct way out is to fix the provider: put the conversion datetime.fromisoformat(row[3]) back so SQLite's get fulfills the contract again. The contract is the agreement, defined by what the consumer needs (a start that is a datetime, to be able to format and operate on it); the red says the provider violated it, so you fix the provider. "Loosening the test" —changing the assertion to assert isinstance(got.start, str) or deleting it so it passes— would be inverting the relationship: letting the defective implementation redefine the agreement, and turning off the only alarm that detected the bug. The datetime-as-str would go back to production, now with the blessing of a green suite. The only legitimate way to change the assertion would be if the team deliberately decided that start is now text —and then you'd have to renegotiate the contract with all the consumers and adapt each one that treated it as datetime, at the same time—. Quietly loosening the test to cover the red is never the way out: it reintroduces the bug and silences the net.
Common mistakes
Turning in the red without the diagnosis. What happens: someone pastes the battery, breaks a line, shows the 1 failed, 7 passed, and considers the project done. Why it happens: the red "feels" like the deliverable, because it's the visible part. How to detect it: if you can't answer the diagnosis's three questions —which clause and why only [sqlite], what would happen in production, what's the correct way out—, you're missing the central deliverable. How to fix it: the mini-project evaluates the understanding, not the red; the red is the evidence, the diagnosis is the thesis. Turn in the drill's report, not just the photo of the alarm sounding.
Breaking the fake instead of the real provider. What happens: someone, to get the red, modifies the FakeBookingRepository instead of the SqliteBookingRepository. Why it happens: "breaking a promise" is confused with "breaking anything". How to detect it: if your red appears on the [fake] side and the [sqlite] stays green, you broke the double, not the real provider —which is the opposite of the module's scenario—. How to fix it: the provider breaking change lives in the real implementation, the one that's deployed. Break the SqliteBookingRepository, so the red falls on [sqlite] with the [fake] intact: that's the asymmetry that proves the contract catches the real provider deviating before the deploy.
Changing the battery between part 1 and part 2. What happens: someone, unintentionally, edits an assertion when introducing the breaking change, and can no longer claim that "the same battery" caught the change. Why it happens: everything is touched at once. How to detect it: if the test file differs between the green run and the red one, the experiment isn't controlled —the red could be due to your edit of the test, not the provider's change—. How to fix it: the power of the demonstration is that the battery is identical in both parts; the only thing that changes is the provider's implementation. Run the same battery, without touching it, before and after the change. An experiment with a single variable —the provider— is the one that proves something.
Exercises
Exercise 1 — Choose another breaking change. Instead of the datetime-as-str, introduce a different breaking change in the SqliteBookingRepository that breaks clause 3 (saving the same id twice updates without duplicating). Describe the change, predict the red, and say why the [fake] would stay green.
See solution
The change: in save, replace the INSERT ... ON CONFLICT(id) DO UPDATE SET ... with a bare INSERT (without the upsert clause). With that, the second save of the same id no longer updates: it tries to insert a second row with the same id, which is PRIMARY KEY, and SQLite rejects it.
The predicted red: test_saving_the_same_id_twice_updates_not_duplicates[sqlite] would go red. The second repo.save(a_booking(status="cancelled")) would raise sqlite3.IntegrityError: UNIQUE constraint failed: bookings.id on the save line, before even reaching the assertions. (It's a red by exception, not by a failed assertion: the test blows up when saving, not when comparing.) The promise "saving the same id twice updates, doesn't duplicate" broke because the mechanism that fulfilled it —the upsert— disappeared.
Why the [fake] would stay green: the FakeBookingRepository.save does self._store[booking.id] = booking —an assignment to a dict by key—, which by nature overwrites the previous value instead of duplicating or failing. The second save simply replaces the entry, the get returns the cancelled version, and find_by_room still has a single booking. The fake didn't change and its way of "saving by key" fulfills the clause effortlessly. Again: [fake] green + [sqlite] red = the real provider broke a promise the fake still fulfills.
Exercise 2 — The consumer-side breaking change. The mini-project broke the contract from the provider. Design the mirror version: a consumer over-assumption that the repository's battery (as it is) would not catch, and explain what kind of test would catch it.
See solution
The over-assumption: that a consumer —say, a function next_booking(repo, room_id)— does repo.find_by_room(room_id)[0] assuming the list comes ordered by start. The contract doesn't promise order in find_by_room; it only promises the set of that room's bookings.
Why the repository's battery wouldn't catch it: the battery verifies the provider —that find_by_room returns that room's correct bookings— and its clause 4 compares as a set (ids == {"bk-1"}), without asserting anything about the order. Neither the fake nor SQLite fails that clause for not ordering, because the clause doesn't ask for order. The over-assumption doesn't live in the provider (which fulfills the contract) but in the consumer (which relies on something unpromised), and the repository's battery doesn't exercise any consumer.
What test would catch it: a consumer test with lesson 6's technique —the adversarial-but-legal provider—: run next_booking against a repository fed out of start order (save the 11 o'clock one before the 9 o'clock one), so that find_by_room returns [11am, 9am] and [0] gives the wrong booking. That consumer test fails and gives away the order assumption. The moral of the exercise: the provider side and the consumer side catch different errors, and you need both —the provider's battery doesn't see the consumer's over-assumptions, just as the consumer test doesn't see the real provider's breaking changes—.
Exercise 3 — Explain it to your tech lead. Your lead asks: "we already have 300 green unit tests of BookingService. Why add this 8-test contract battery?". Write the three- or four-sentence answer you'd give, leaning on what you demonstrated in the mini-project.
See solution
A possible answer:
"Our 300 green unit tests prove that BookingService orchestrates well against the FakeBookingRepository —the double we use at the repository seam—, but they say nothing about whether the real SqliteBookingRepository, the one that runs in production, behaves like the fake. This battery of 8 runs the same four promises against the fake and against the real SQLite, so it guarantees the fake isn't lying: if the real one diverges on any promise, a test goes red. I just demonstrated it: a one-line change in SQLite's get —returning the datetime as text, which compiles and leaves the 300 unit tests green— makes the battery go red instantly on the [sqlite] side, pointing to the exact clause, before deploying. Without the battery, that bug reaches production and blows up far and late, in the screen that formats the date; with the battery, it's a local red of two hundredths of a second that says exactly what broke and where."
The essence of the answer: don't pit the contract against the unit tests (the 300 are still necessary: they're the pyramid's fast base), but explain what the contract covers that they can't cover —the real provider's fidelity against the fake— and demonstrate it with the mini-project's concrete case: a breaking change the 300 let through and the battery of 8 catches before the deploy. Eight tests that keep honest the double the other 300 hang from are the suite's best cost-benefit ratio.
Summary and module close
With this mini-project turned in, you close module 4. You demonstrated with your own hands the guide's central payoff: you verified the BookingRepository contract from both sides —eight greens, the fake and the real SQLite honoring the four clauses— and then, as in a fire drill, you triggered the smoke on purpose: you introduced a breaking change in the SqliteBookingRepository (the datetime that comes back as str) and saw the battery go red on the exact clause, on the [sqlite] side, with the [fake] intact, before any deploy. And you were able to explain it: why the asymmetry points at the real provider, what would have happened in production without the battery, and why the way out is to fix the provider —never loosen the test—.
You went through the whole module: the contract has two sides (lesson 1); the consumer side, "I send X and expect Y", against a provider that honors the contract (lesson 2); the provider side, "given X, I return Y", answering for itself alone (lesson 3); the same battery against both, the transitivity guarantee that the fake doesn't lie (lesson 4); catching a provider breaking change before the deploy (lesson 5); catching a consumer over-assumption with the adversarial-but-legal provider (lesson 6); and who owns the contract —the consumer, consumer-driven, the concept of Pact— (lesson 7). You come out knowing how to write, read, and run a contract from its two chairs, and use it as the net that catches incompatible changes before they reach production.
Where the guide goes next. Up to here you worked the repository's contract in isolation: you verified that the fake and the real one match on what the contract promises, and that the consumer relies only on what's promised. But a contract green on both sides guarantees that the pieces fulfill their agreement, not that they actually work together when connected. That's the next step. Module 5 leaves the isolated contract and tests the real components together —BookingService and SqliteBookingRepository connected, crossing the seam for real, with a book→get integration test—: what to keep real and what to double in an integration, and how to verify that the complete assembly works, not just that each piece honors its contract. The contract guaranteed the fake doesn't lie; integration verifies that the building, with its real pieces, holds up.
Resources
- pytest documentation — How to invoke pytest (
-v, selecting tests) — the reference for running the battery with-vand producing your own "What to expect" with the eight greens and the breaking change's red, as in the worked example. sqlite3— Adapter and converter recipes (Python documentation) — the section that explains why SQLite stores thedatetimeas text and how it's reconstructed; the root of the mini-project's breaking change and of its fix withdatetime.fromisoformat.- docs.pact.io — How Pact works — the conceptual reference for consumer-driven contract testing between services; useful for seeing the mini-project (hand-built battery, both-sides verification, catching the change before the deploy) as the in-process version of what Pact automates over the network.
- Module 1 of this guide — Mini-project: fake green, real red — where the
datetime-as-strthat this mini-project's breaking change reintroduces first appeared; useful for seeing the complete arc, from the problem (module 1) to the net that catches it (module 4).