Module 4: Verifying The Contract From Both Sides

1. Module introduction: the contract has two sides

Description

In module 3 you built a contract and looked at it as a single thing: a parametrized test battery that runs against the FakeBookingRepository and the real SqliteBookingRepository, and returns a verdict. One battery, two providers, green or red. It was enough to understand what a contract is and why it closes module 1's gap. But a contract, like any agreement between two parties, isn't experienced from a single place: it's experienced from two chairs, and each chair sees one half of the agreement.

On one side is BookingService, the consumer: the one that uses the collaborator. Its question is that of one who depends on another: "I send an operation —save this booking, ask for that one— and I expect a result; am I relying only on what the contract promises, or am I assuming too much?". On the other side is SqliteBookingRepository, the provider: the one that implements the collaborator. Its question is that of one who fulfills: "given that I'm asked to save and then read, do I return the booking the contract promises, with the types it promises, and do I raise when the contract says to raise?". The same battery, two questions. This module opens the contract in half and teaches you to write, read, and run each side separately.

Connection to the module: this lesson is module 4's map. Here you won't write each test yet; you'll understand why separating the two sides isn't an academic subtlety, but what turns the contract into a working tool. Separated, you can verify the consumer without a database and the provider without the service; you can run the same suite against the fake and the real one to guarantee that neither lies; and —the payoff that gives the whole guide its name— you can catch a breaking change before deploying it. The border with module 5 is clear: here we continue with the repository's contract in isolation, seen from its two sides; connecting the real components togetherBookingService and SqliteBookingRepository crossing the seam for real— is module 5.

Analogy: the plug and the outlet

Think of your house's electrical system. There's an invisible agreement between two pieces that were never designed together: the plug of your lamp and the outlet in the wall. The agreement is the country's standard: two flat prongs, so many millimeters of spacing, 120 volts, such a frequency. That standard is the contract. And it has two sides, verified by two manufacturers who don't know each other.

The lamp's manufacturer —the consumer, the one that uses the current— tests their product like this: "if I'm given 120 volts through two prongs spaced this distance, my lamp turns on". They don't test against your specific wall; they test against the standard. And something crucial: if their lamp only works with a detail the standard does not guarantee —say, that the ground prong is always on the left—, their product is fragile, because a perfectly legal outlet could have it on the right. The outlet's manufacturer —the provider, the one that delivers the current— tests theirs on the other side: "I deliver 120 volts through two prongs at this distance, complying with the standard". They don't need anyone's lamp to verify it; a meter and the standard are enough.

Both sides verify the same contract, from opposite chairs, without coordinating. And when it works, any lamp in the country turns on in any wall in the country. When the provider breaks the contract —an outlet that delivers 240 volts "to simplify"—, you don't have to wait for someone to burn their lamp to find out: a meter against the standard catches it instantly. That's exactly what this module does: write the consumer test (the lamp against the standard), the provider test (the outlet against the standard), and use the standard as the meter that catches the provider who breaks the deal before it burns a lamp in production.

Worked example: the two sides, at a glance

Before getting into the detail of each side —which are lessons 2 and 3—, let's see the whole module condensed into two runs. It's the same repository contract from module 3, 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; and find_by_room returns only that room's bookings. The battery is parametrized with a two-value fixture, fake and sqlite, so each clause runs twice —one per provider—.

# tests/test_repository_contract.py — the shared contract battery
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)


# one fixture, two providers: each test runs twice, [fake] and [sqlite]
@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), the battery runs the four clauses against the two providers —eight tests total—:

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 ===============================

Eight greens. Notice the ids in brackets: each clause appears twice, [fake] and [sqlite], and both pass. That's the contract honored by both sides at once. The real provider —SQLite, which serializes the datetime to text and reconstructs it back— returns exactly what the fake returns, because both fulfill the same clause test_save_then_get_returns_the_same_booking. (That got.start == START that in module 1 failed against SQLite now passes: the datetime fix —convert back with fromisoformat— is already applied, and the contract is precisely what guarantees it stays applied. That's the point.)

Now the module's other moment. Suppose the team that maintains the SqliteBookingRepository decides to "simplify" get: instead of raising when the id doesn't exist, have it return None. A one-line change, apparently harmless. We run exactly the same battery, with the provider already changed:

python3 -m pytest tests/test_repository_contract_after_change.py -v
tests/..._after_change.py::test_save_then_get_returns_the_same_booking[fake] PASSED [ 12%]
tests/..._after_change.py::test_save_then_get_returns_the_same_booking[sqlite] PASSED [ 25%]
tests/..._after_change.py::test_get_of_a_missing_id_raises[fake] PASSED [ 37%]
tests/..._after_change.py::test_get_of_a_missing_id_raises[sqlite] FAILED [ 50%]
tests/..._after_change.py::test_saving_the_same_id_twice_updates_not_duplicates[fake] PASSED [ 62%]
tests/..._after_change.py::test_saving_the_same_id_twice_updates_not_duplicates[sqlite] PASSED [ 75%]
tests/..._after_change.py::test_find_by_room_returns_only_that_rooms_bookings[fake] PASSED [ 87%]
tests/..._after_change.py::test_find_by_room_returns_only_that_rooms_bookings[sqlite] PASSED [100%]

=================================== FAILURES ===================================
___________________ test_get_of_a_missing_id_raises[sqlite] ____________________
...
>       with pytest.raises(KeyError):
E       Failed: DID NOT RAISE KeyError
========================= 1 failed, 7 passed in 0.02s ==========================

There's the whole module in two runs. The first: the contract green on both sides, [fake] and [sqlite]. The second: a one-line change in the provider, and the contract goes red exactly where the change broke a promise —test_get_of_a_missing_id_raises[sqlite]— while the [fake], which didn't change, stays green. And this happened when running the tests, before deploying. Without the contract, the change would have reached production, and the first signal would have been that BookingService.cancel, which relies on get raising for a missing id, one day receives None and breaks with a message that mentions neither the repository nor the change. The contract turns that disaster into a local red test of two hundredths of a second.

The two sides, named precisely

It's worth fixing the vocabulary before the lessons use it nonstop, because "consumer" and "provider" aren't company roles or test types: they're positions relative to a seam.

  • The consumer is the component that calls through the seam. At the repository seam, the consumer is BookingService: it's the one that invokes save, get, find_by_room. It depends on the other side's behavior; it doesn't implement it.
  • The provider is the component that responds from the other side of the seam. At the repository seam, there are two interchangeable providers: the FakeBookingRepository and the SqliteBookingRepository. They implement the behavior; they don't call it.
  • The contract is the agreement about that behavior, written once, that both sides must respect: what each method receives, what it returns, what it raises.

The same component can be the consumer of one seam and the provider of another. BookingService is the consumer facing the repository, but it could be the provider facing a web layer that calls it. The word doesn't describe what the component is, but from which side of which seam you're looking at it. That's why the module insists on "the consumer side" and "the provider side": they're points of view on the same seam, not different boxes.

And from here comes the question lesson 7 will answer: if the contract is an agreement between two, who defines it? The answer —the consumer— is what makes these contracts called consumer-driven, and it's what the concept of Pact automates between services. For now it's enough that you see the shape: two sides, two questions, one agreement.

Why separating the two sides gives you power

You might ask: if the battery already runs against both and gives a verdict, why distinguish "the consumer test" from "the provider test"? The answer is that the distinction buys you three concrete capabilities a single verdict doesn't give.

You verify each side on its own, with what that side needs. The provider test needs the provider —the fake or SQLite— and nothing more: it doesn't start BookingService, doesn't build a Calendar, doesn't simulate a payment. The consumer test needs the consumer —BookingService— against any provider that honors the contract, and since the fake honors the contract, the consumer is tested without a database, fast and deterministic. Separating the sides lets you test each with that side's minimal setup.

You locate the blame when something fails. If the [sqlite] provider test is red but the consumer test is green, you know the problem lives in SQLite's implementation, not in how BookingService uses it. If it were the reverse, you'd know the consumer assumes too much. A single verdict tells you "something doesn't fulfill the contract"; the two sides tell you who.

You catch changes in the right place, before the deploy. A change in the provider —like the get that returns None— reddens the [sqlite] side without touching the [fake], because the fake didn't change. A change in the consumer's assumptions —like assuming an unpromised order— reddens a consumer test without touching any provider. Each kind of error has its side, and seeing it on its side tells you what to review. That correspondence —provider error → provider red, consumer assumption → consumer red— is the one lessons 5 and 6 exploit in depth.

The module's map

LessonTopicThe idea in one sentence
1The contract has two sides (this one)Consumer and provider: two chairs, two questions, one same contract
2The consumer side"I send X and expect Y": the consumer relies only on what's promised
3The provider side"Given X, I return Y": the provider fulfills each clause, isolated
4The same battery against bothOne battery, two providers: that "two" is the whole guarantee
5Catching a provider breaking changeThe provider breaks a promise → red in [sqlite] before the deploy
6Catching a consumer over-assumptionThe consumer relies on the unpromised → consumer red
7Who owns the contract: consumer-drivenThe consumer defines, the provider fulfills; the concept of Pact
8Mini-projectVerify both sides and catch a breaking change you introduce

Lessons 2 and 3 give you the two chairs separately; lesson 4 shows why running both with the same battery is the guarantee; lessons 5 and 6 are the two payoffs —catching the provider error and the consumer assumption—; lesson 7 answers who rules; and lesson 8 weaves it all into a deliverable.

Common mistakes

Believing "consumer" and "provider" are component types, not positions. What happens: someone searches the code for "the Consumer class" and doesn't find it, and gets confused. Why it happens: the names sound like fixed categories. How to detect it: if you can't say "consumer of what seam", you're using the word as an absolute label. How to fix it: always anchor the role to a seam. BookingService is the consumer of the repository seam; SqliteBookingRepository is the provider of that same seam. Change the seam and the roles change.

Thinking the consumer test needs the real provider. What happens: someone sets up a SQLite database to test BookingService, believing that "testing the consumer" requires the real provider. Why it happens: the consumer test is confused with an integration test. How to detect it: if your consumer test opens a database connection, you crossed into integration (module 5) unintentionally. How to fix it: the consumer test runs against any provider that honors the contract, and the fake honors the contract —that's why the contract exists—. Test the consumer against the fake: fast, without a database, and still faithful, because the contract guarantees the fake doesn't lie.

Assuming a contract green today will stay green on its own. What happens: the team sees the eight greens, breathes easy, and stops running the battery on every provider change. Why it happens: a green feels like a permanent achievement. How to detect it: if the contract doesn't run on every change of the SqliteBookingRepository, a breaking change can slip in without anyone seeing it until production. How to fix it: the contract's value isn't the one-time green, but that it runs again every time the provider changes. Lesson 5's breaking change is only caught because the battery was run after the change. A contract that isn't run is a contract that doesn't protect.

Exercises

Exercise 1 — Name the two sides. Reservo has another seam besides the repository's: the payment one, where BookingService calls a PaymentGateway (with StubPaymentGateway and a real implementation). For that seam, say who the consumer is, who the providers are, and write in one sentence a contract clause both sides should respect.

See solution
  • Consumer: BookingService, because it's the one that calls charge and refund through the payment seam. It depends on the gateway's behavior; it doesn't implement it.
  • Providers: the PaymentGateway implementations —the StubPaymentGateway (double) and the real gateway that would talk to the actual payment provider—. Both must respond the same to the same call.
  • A contract clause: "charge(amount_cents) returns a Receipt with ok=True if the charge was approved and ok=False if it was declined; it never returns None or raises for a normal decline". Both sides respect it: the consumer relies on reading receipt.ok (and not on, say, a decline raising an exception), and any provider promises to return that Receipt with the correct verdict.

The essence: the role is fixed by the seam. BookingService was the provider... no, it was the consumer of the repository, and here it's the consumer of the payment too —it happens to be the consumer of both seams—, but that's a coincidence of this case. What doesn't change is the rule: consumer = the one who calls, provider = the one who responds, contract = what both respect.

Exercise 2 — Predict which side goes red. For each change, without running anything, say whether it would redden a provider [sqlite] test, a consumer test, or neither: (a) SqliteBookingRepository.save stops doing commit, so a later get doesn't find the row; (b) BookingService starts assuming find_by_room returns the bookings ordered by price; (c) you rename an internal variable of SqliteBookingRepository.get without changing its behavior.

See solution
  • (a) Provider [sqlite] red. Without commit, save-and-read stops returning the booking, so test_save_then_get_returns_the_same_booking[sqlite] fails. It's a contract promise (save-and-read returns the same booking) that the implementation broke: red on the real provider's side, with [fake] intact because the fake didn't change.
  • (b) Consumer red. find_by_room's contract promises no order; if BookingService assumes one, it relies on something unpromised. That's caught by a consumer test that runs it against a provider with a different legal order (lesson 6), not a provider test —because the provider broke nothing, the consumer assumed too much—.
  • (c) Neither red. Renaming an internal variable without changing the behavior touches no contract promise. The contract asserts about the observable behavior (what's received, what's returned, what's raised), not about the internal names. That a harmless refactor does not redden the contract is a virtue, not an oversight: a contract that breaks with every rename would be over-specified.

The moral: each kind of error has its side. A promise broken by the implementation → provider red. An over-assumption by the caller → consumer red. A change that doesn't touch the behavior → no red. Knowing how to predict the side is knowing how to read the contract.

Exercise 3 — The value of "before". Explain, in your own words, why the phrase "the contract caught the breaking change before deploying it" is the heart of the module, and what would have happened without the contract when get started returning None.

See solution

The key word is before. Without a contract, a change in the provider (making get return None instead of raising) is syntactically valid —get still exists, still returns something— so it compiles, passes any test that doesn't exercise the missing-id case, and gets deployed. The first signal of the problem arrives after, in production, when some path that relied on get raising —for example BookingService.cancel, which calls get and expects a KeyError for a nonexistent id— receives None, carries on as if the booking existed, and fails further down with an error that mentions neither the repository nor the change (an AttributeError about a None, maybe, three layers away). The cost is high: a production incident, an uphill trace to the cause, and affected users.

With the contract, that same change reddens test_get_of_a_missing_id_raises[sqlite] the moment you run the battery, on your machine, before merging anything. The error presents itself with a first and last name —the exact clause that broke, in the exact provider— and in two hundredths of a second. The contract doesn't prevent people from changing the provider; it prevents an incompatible change from reaching production unseen. It moves the discovery of the bug from the most expensive place (production, after) to the cheapest (your suite, before). That —running the risk to the left, toward the "before"— is what makes the contract a tool and not decoration.

Summary and next step

In this lesson you opened the contract in half and saw that it always has two sides: the consumer's (BookingService, the one that uses the seam and asks "am I relying only on what's promised?") and the provider's (SqliteBookingRepository, the one that implements it and asks "do I fulfill what's promised?"). With the plug and the outlet you understood that both verify the same standard from opposite chairs, without coordinating, and that the standard is also the meter that catches the one who breaks the deal. And you saw the whole module condensed: the battery green on both sides —the eight [fake]/[sqlite]—, and a one-line change in the provider reddening the [sqlite] side on the exact clause, before deploying.

Before moving on you should be able to: define consumer and provider as positions relative to a seam, not as component types; name the three capabilities separating the two sides gives (verify each on its own, locate the blame, catch the change on its side); and explain why "catching the breaking change before the deploy" is the guide's central payoff.

What comes next is sitting in the first chair. Lesson 2 writes the consumer side: what a test that looks at the seam from BookingService looks like —"I send X and expect Y"— and, above all, how that test verifies that the consumer relies only on what the contract promises, without assuming too much. Then, lesson 3 switches to the provider's chair.

Resources