Module 4: Verifying The Contract From Both Sides
2. The consumer side
Description
Let's start with the first chair: the consumer's, the component that uses the collaborator. At the repository seam, the consumer is BookingService, and its point of view boils down to a sentence you'll repeat all module: "I send X and expect to receive Y". I, BookingService, save a booking and expect to be able to read it back. I ask for a booking by an id that doesn't exist and expect to be told so by raising, not by silently returning None. The consumer test is the verification of those expectations: it checks that the consumer works correctly when the other side fulfills the contract.
And it brings a sharper question, which is what this lesson really teaches. The consumer test doesn't just verify that BookingService does its job; it verifies that it does so relying only on what the contract promises, and nothing more. A consumer can work by accident thanks to a detail its current provider has but the contract never guaranteed —an order, a type, a side effect—. That consumer is fragile: the day you connect it to another equally valid provider, it breaks. The well-made consumer test exercises the consumer against a provider that promises exactly the contract, not an ounce more, so that if the consumer assumes too much, it shows. Writing the consumer against the contract, not against a concrete implementation, is the discipline this lesson installs.
Connection to the module: lesson 1 showed you that the contract has two sides; this one sits in the first. Here you'll see how a consumer test is written and run, why it runs against the fake (which honors the contract) and not against SQLite, and what "relying only on what's promised" means. Lesson 3 will switch to the provider's chair —the mirror question, "given X, I return Y"—. And lesson 6 will take the edge of this one —"without assuming too much"— and turn it into its own payoff: catching a consumer over-assumption. For now, learn to see the seam from the caller's chair.
Analogy: the order in the kitchen
Think of a waiter taking orders at a restaurant. The waiter is the consumer: they use the kitchen without cooking. Their job, on their side, is to send clear orders and rely on what the agreement with the kitchen promises: "if I hand in an order with the dish and the table, the kitchen returns that dish ready, or explicitly warns me if the ingredient ran out". That agreement is the contract. A good waiter relies only on it: they send the order, wait for the dish or the warning, and act accordingly.
Now imagine a waiter who, without realizing it, relies on something the agreement does not promise. In their usual kitchen, the cook happens to be left-handed and leaves the ready dishes at the left end of the counter, so the waiter got used to looking only to the left. It works —for years— because their cook always puts the dishes there. But the agreement never said "the dishes come out on the left"; that's a detail of the current cook, not of the contract. The day a right-handed cook comes in who leaves the dishes on the right, the waiter stands staring at an empty left counter, swearing the kitchen failed —when the kitchen fulfilled the agreement to the letter, and it was they who relied on an unpromised detail—.
The well-made consumer test is rehearsing the waiter against a kitchen that fulfills the agreement and only the agreement: it delivers the dishes, but not always on the same side, not always in the same order, without any gift the contract doesn't require. If the waiter works against that "strict" kitchen, they'll work against any legal cook. If they relied on the left, the rehearsal gives them away before service. Verifying the consumer against the contract —not against the convenience of their usual provider— is what makes it robust.
Worked example: the consumer against a provider that honors the contract
Here's the consumer test. Notice the deliberate choice: the provider is the FakeBookingRepository, not SQLite. Why the fake? Because the fake honors the contract —that's its reason for existing since module 3—, so it faithfully represents "any provider that fulfills what's promised". Testing the consumer against the fake is testing it against the contract made object, without dragging in a database. Everything else in the seam —the payment, the email, the clock— is also doubled, because it's not what we're testing: the focus is how BookingService uses the repository.
# tests/test_consumer_bookingservice.py — the consumer against a provider that honors the contract
from datetime import datetime
from reservo.calendar import Calendar
from reservo.doubles import (FakeBookingRepository, FixedClock,
SpyEmailSender, StubPaymentGateway)
from reservo.models import Member, Room
from reservo.services import BookingService
FOCUS = Room(id="focus", name="Focus", capacity=4, hourly_cents=2500)
ANA = Member(id="m-ana", name="Ana", tier="pro")
START = datetime(2026, 3, 10, 9)
END = datetime(2026, 3, 10, 12) # Focus 3 h
CLOCK = datetime(2026, 3, 1, 9)
def make_service(repo):
return BookingService(
Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo,
)
# the consumer sends X (a Focus 3 h booking) and expects Y (it's saved and retrievable)
def test_book_persists_a_retrievable_booking():
repo = FakeBookingRepository() # a provider that honors the contract
service = make_service(repo)
booking = service.book(FOCUS, ANA, START, END)
saved = repo.get(booking.id) # the consumer only uses save/get from the contract
assert saved.status == "confirmed"
assert saved.price_cents == 6000
# the consumer expects get of a missing id to raise (clause 2 of the contract)
def test_cancel_of_a_missing_booking_propagates_the_contract_error():
repo = FakeBookingRepository()
service = make_service(repo)
import pytest
with pytest.raises(KeyError): # the consumer relies on get raising
service.cancel("does-not-exist")
Read them as claims about the consumer, not about the repository. The first test says: "when BookingService.book runs against a provider that fulfills the contract, the booking is saved and can be retrieved with the correct status and price". The second says something subtler and more important: "BookingService.cancel relies on clause 2 of the contract —that get of a missing id raises—; if you ask it to cancel a booking that doesn't exist, that KeyError from the provider propagates". That second test documents a dependency of the consumer on a concrete promise of the contract: cancel trusts that get raises. Keep that thread: it's exactly the promise that lesson 5's breaking change will break.
What to expect. On my machine (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_consumer_bookingservice.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items
tests/test_consumer_bookingservice.py::test_book_persists_a_retrievable_booking PASSED [ 50%]
tests/test_consumer_bookingservice.py::test_cancel_of_a_missing_booking_propagates_the_contract_error PASSED [100%]
============================== 2 passed in 0.01s ==============================
Two greens, without a database in sight. The consumer is verified —it does its job when the provider fulfills the contract— and it's documented which promises it relies on: that save/get save and retrieve, and that get raises for a missing id. It ran in hundredths of a second because the provider is the fake, and the fake is faithful because the contract keeps it honest. That's the whole module's deal: since the fake honors the contract, testing the consumer against the fake is as valid as testing it against the real one, but a thousand times faster.
What a consumer test verifies —and what it doesn't—
A consumer test has a precise scope, and confusing it with something else is this lesson's most common mistake. Let's fix its limits.
It verifies the consumer's logic, taking the provider's contract as fulfilled. The consumer test assumes the provider fulfills what's promised —that's why it uses the fake, which fulfills it— and on that basis checks that the consumer orchestrates well: that book saves a confirmed booking with the correct price, that cancel relies on get raising. It doesn't verify that SQLite serializes a datetime correctly; that's the provider test's job (lesson 3). The split is clean: the consumer assumes the contract fulfilled and tests its own; the provider tests that it fulfills it.
It's not an integration test. There's no real piece crossing a boundary seam: the repository is an in-memory fake, the payment a stub, the email a spy. Everything runs in-process, without disk or network. If your "consumer test" opens sqlite3.connect(...), you're no longer testing the consumer against the contract: you're testing BookingService + SQLite together, which is integration —module 5, not this—. The mark of a consumer test is that the provider is doubled by something that honors the contract.
It doesn't test the real provider. A green consumer test says nothing about whether SqliteBookingRepository fulfills the contract. You could have the consumer impeccable and a broken SQLite, and this test would still be green, because it never touches SQLite. That's why the contract needs its two sides: the consumer test covers "the consumer uses the contract well", and only the provider test covers "the real provider fulfills the contract". Neither replaces the other.
The Pact idea: the consumer is tested against a stand-in that promises the contract
What you just did by hand has a name in the industry, and understanding it gives you the complete frame. In consumer-driven contract testing —the idea Pact automates between services—, the consumer test never runs against the real provider. It runs against a stand-in: a double of the provider programmed to respond exactly according to the contract, and that also records the consumer's expectations to produce the contract itself.
In the world of services over HTTP, that stand-in is a fake server Pact spins up: the consumer sends it its real requests, the stand-in responds according to what was agreed, and Pact notes "the consumer expects that, given this request, the provider responds like this". The result is a pact file: a JSON with the consumer's expectations, that the real provider will later use to verify itself (that's lesson 3 and 7). The conceptual key: the consumer is tested against a promise, not against an implementation.
In our in-process version, the FakeBookingRepository is that stand-in. It fulfills the contract and nothing more, so testing BookingService against it is testing it against "a provider that promises the contract". We don't generate a JSON pact file —no need, we're in a single process—, but the shape is identical: consumer against a provider-that-honors-the-contract, verifying that the consumer relies only on what's promised. When at work you see Pact spin up a mock server for the consumer test, you'll recognize it as this same pattern, taken to the network.
Common mistakes
Testing the consumer against the real provider "to be safer". What happens: someone changes the fake for a SqliteBookingRepository in the consumer test, thinking that testing against the real thing gives more confidence. Why it happens: "more real" sounds like "a better test". How to detect it: the test became slower, opened a database connection, and now can fail for something that isn't the consumer (the schema, the serialization). How to fix it: separate the questions. "Does the consumer use the contract well?" is answered against the fake, fast. "Does the real provider fulfill the contract?" is answered with the provider test (lesson 3). Mixing them in a single test takes away your ability to know which side failed, and leads you to integration unintentionally.
Relying on an unpromised detail of the provider and not noticing. What happens: BookingService works because the fake returns the bookings in a certain order, or because get returns the same object you saved (identity, not just equality), and no one realizes the contract doesn't promise that. Why it happens: the current provider has that detail, so the consumer works and the test passes. How to detect it: ask yourself about each thing the consumer relies on: "is this in a contract clause, or is it a gift of this provider?". How to fix it: if it's an unpromised gift, either the consumer stops depending on it, or it's added as an explicit contract clause. Lesson 6 is entirely about catching this error; for now, it's enough to suspect it.
Verifying the provider inside the consumer test. What happens: in the book test, someone adds assert isinstance(saved.start, datetime) to "make sure it was saved correctly". Why it happens: it seems reasonable to verify the type of what was left. How to detect it: that assertion talks about the provider (how it saves and returns the start), not the consumer (how it uses the repository). Against the fake it will always pass, saying nothing about the real one. How to fix it: the promises about what the provider returns —types, fields, errors— live in the contract battery, which runs against both providers (lesson 4). The consumer test stays on its own: that the consumer orchestrates well given the fulfilled contract.
Exercises
Exercise 1 — cancel's hidden expectation. The test test_cancel_of_a_missing_booking_propagates_the_contract_error verifies that cancel of a missing id raises KeyError. Explain what contract promise that test is exercising from the consumer side, and why documenting it matters for what's coming in lesson 5.
See solution
The test exercises clause 2 of the contract: "get of a missing id raises". From the consumer side, what's verified is that BookingService.cancel relies on that promise: cancel starts by calling self._repo.get(booking_id), and trusts that, if the id doesn't exist, that get raises —which makes cancel fail immediately with a clear KeyError, instead of continuing as if the booking existed—. The test documents that dependency: "the consumer needs get to raise".
Why it matters for lesson 5: the breaking change we'll see is precisely that the provider SqliteBookingRepository.get stops raising and returns None. Since this consumer test recorded that cancel depends on get raising, you have the complete map of the damage: when the provider breaks that promise, it isn't an abstract detail —it's exactly the promise cancel hangs from—. The consumer test explains why the provider's breaking change is dangerous: there's a real consumer relying on the promise that broke.
Exercise 2 — Why the fake and not SQLite? A colleague proposes rewriting the two consumer tests using SqliteBookingRepository(sqlite3.connect(":memory:")) instead of the FakeBookingRepository, "to test against something real". Give two concrete reasons why the fake is the correct choice for a consumer test, and say what kind of test the real provider would want.
See solution
Two reasons why the fake is correct here:
- The consumer test asks about the consumer, not the provider. Its goal is "does
BookingServiceuse the contract well?". The fake honors the contract, so it faithfully represents "any provider that fulfills what's promised" —exactly what the consumer must assume—. Changing it for SQLite mixes two questions (does the consumer use it well? does the real provider fulfill it?) into a single test, and if it fails, you no longer know which of the two failed. - Speed and determinism. The fake runs in memory, without a connection, without a schema, without
commit. Consumer tests tend to be many (one for each path ofbook,cancel, etc.); tying them all to SQLite makes them slow and fragile against a problem that isn't the consumer's. The pyramid (sister guide) wants exactly this: many fast consumer tests against the fake.
What test the real provider would want: one on the provider side (lesson 3), which exercises SqliteBookingRepository against the contract directly —"save-and-read returns the same booking", "missing get raises"—, without BookingService in between. That's the right place to touch SQLite: test that the provider fulfills, not that the consumer uses.
Exercise 3 — A new consumer over the same seam. Reservo adds a function monthly_report(repo, room_id) that uses repo.find_by_room(room_id) to count how many confirmed bookings a room has. It's a new consumer of the repository seam. Write a consumer test for it (against the fake) and say which contract clause it relies on —and which it should not rely on—.
See solution
A possible consumer test, against the fake:
def test_monthly_report_counts_confirmed_bookings():
repo = FakeBookingRepository()
repo.save(Booking(id="bk-1", room_id="focus", member_id="m-ana",
start=START, end=END, status="confirmed", price_cents=6000))
repo.save(Booking(id="bk-2", room_id="focus", member_id="m-ivan",
start=START, end=END, status="cancelled", price_cents=0))
count = monthly_report(repo, "focus")
assert count == 1 # only the confirmed one
Which clause it relies on: clause 4 of the contract —"find_by_room returns only that room's bookings"—. monthly_report trusts that what it receives is exactly focus's bookings (none from another room slipped in, none of focus's missing), and over that set it filters by status. That's a legitimate promise of the contract, so relying on it is correct.
Which it should not rely on: any order of the list. The contract doesn't promise that find_by_room returns the bookings ordered (by date, by price, by id). If monthly_report did something like "take the first of the list" or "assume they come by date", it would be assuming too much —the error lesson 6 catches—. Since here it only counts (an operation that doesn't depend on order), the consumer is clean: it uses exactly what clause 4 promises, no more, no less. Counting is safe; ordering-by-position wouldn't be.
Summary and next step
In this lesson you sat in the consumer's chair and learned to look at the seam from BookingService: "I send X and expect to receive Y". You wrote a consumer test that runs against the FakeBookingRepository —because the fake honors the contract and acts as a faithful stand-in, without a database— and saw that it does two things at once: it verifies that the consumer orchestrates well given the fulfilled contract, and it documents which promises it relies on (that save/get save and retrieve, that get raises for a missing id). With the waiter and the kitchen you understood the lesson's edge: a robust consumer relies only on what's promised, and a well-made consumer test exercises it against a provider that promises the contract and nothing more, so any over-assumption shows. And you saw that this is, in miniature and in-process, the same Pact idea of the consumer test against a stand-in.
Before moving on you should be able to: write a consumer test against the fake; explain why the fake and not SQLite is the correct choice for that test; distinguish a consumer test from an integration test; and name the difference between relying on a contract clause and relying on an unpromised gift of the current provider.
What comes next is switching chairs. Lesson 3 sits in the provider's: the mirror question, "given X, I return Y", where SqliteBookingRepository verifies that it fulfills each clause of the contract, isolated from the consumer. You'll see the same battery we took as fulfilled here, now from the side that has to fulfill it —and why that side does touch real SQLite—.
Resources
- docs.pact.io — Consumer testing — the official description of what a consumer test is in the consumer-driven model: run the consumer against a stand-in that responds according to the contract and records its expectations. The industrial frame of what this lesson does by hand with the fake.
- pytest documentation — How to write and run tests — the reference for the assertions with
assertandpytest.raiseswe use to verify both the result (saved.status) and the expected error (KeyError) of the consumer. test-doubles-and-test-data-guide— the sister guide where you built theFakeBookingRepositorythat acts here as the provider's stand-in; useful to remember that a fake is an implementation that really works, and that's why it can honor a contract.- Module 3 of this guide — Contract testing: consumer and provider — where the four-clause contract the consumer of this lesson relies on was defined.