Module 3: Contract Testing Consumer And Provider

1. Module introduction: from the problem to the cure

Description

Module 2 ended with a diagnosis and no prescription. You saw, with real pytest output, how the FakeBookingRepository diverged from the real SqliteBookingRepository: asking for a booking that doesn't exist made the fake return None while the real one raised an exception. And you saw the part that keeps you up at night: that mismatch passed the unit test —because the unit test only talked to the fake— and blew up in production —where the one on duty is the real one—. The double lied, the green deceived us, and the bug traveled calmly all the way to the user. This module is the cure.

The cure has an old name and a simple idea: a contract. Instead of writing the fake's tests and the real one's tests separately (and praying they match), you write a single behavior spec —what any repository worthy of the name must do— and verify it against all the implementations at once. The same battery of tests runs against the FakeBookingRepository and against the SqliteBookingRepository. If both pass, you have a guarantee no isolated unit test could give you: the fake isn't lying about anything the contract covers, because the contract forces it to behave like the real one. And if the fake diverges —like the one in module 2—, the battery paints it red before the bug leaves your machine.

Connection to the module: this lesson is the map of the cure, not the cure in detail. Here you'll understand what a contract is, why a parametrized battery closes module 2's gap, and how the eight lessons to come are organized. Lessons 2 and 3 lay down the ideas —what a contract is, who defines it (the consumer)—; lesson 4 gives you the concrete mechanism in pytest (the parametrized fixture); lesson 5 collects the reward by catching module 2's divergence in red; lesson 6 distinguishes two ways of writing a clause (state vs interaction); and lesson 7 shows you how the industry automates all this between network services (the concept of Pact). Module 4 will take over to separate the two sides of the contract —the consumer test and the provider test— under a magnifying glass; here the focus is the contract as a shared battery.

Analogy: the outlet standard

Think of the wall outlet. In your country there's a standard: the shape of the prongs, the spacing between them, the voltage, the frequency. That standard is a contract between two parties that don't know each other and never speak: the one who makes the charger (the consumer, the one who uses the current) and the one who installs the outlet (the provider, the one who delivers the current). Neither saw the other's design. And yet your charger works in any wall in the country, because both fulfill the same standard. The charger maker doesn't pray "I hope this wall delivers 120 volts"; they know it does, because the standard guarantees it and there's a lab that certifies that each outlet fulfills it before it's sold.

Remove the standard and you have the world of module 2. The charger maker assumes what the wall is like —they nail their assumption into a fake adapter they build themselves, in their workshop— and test the charger against that adapter. It works wonderfully. On launch day they plug it into a real wall that turned out to be a different voltage, and the charger burns out. The workshop adapter could never warn of the problem, because it was the wrong assumption made object. What was missing was a written standard and a lab that certified that the workshop adapter and the real wall fulfill the same specification. This guide's contract is that standard; the parametrized battery is that lab. You certify the FakeBookingRepository and the SqliteBookingRepository against the same spec, and you no longer pray: you know.

What a contract is, in one sentence

Before seeing code, keep the definition lesson 2 will unpack:

A contract is a spec of behavior —not of shape— that describes what any implementation of an interface promises, and that is verified the same against all of them.

Notice the three loaded words. Behavior, not shape: it's not enough for the fake and the real one to have the same methods (save, get, find_by_room); they have to act the same —save and read returns the same booking, asking for a missing one raises, saving the same id twice updates instead of duplicating—. Any implementation: the contract belongs to neither the fake nor the real one; it belongs to the idea "booking repository", and both must answer to it. And the same against all: a single battery, run N times, one per implementation. That last word —same— is what does the work. If the fake passes a different battery from the real one's, you didn't prove they match; you proved two loose things. The same battery against both is what turns "I hope they match" into "they match or there's a red".

The BookingRepository contract

Reservo's repository exposes three methods, and its contract is four behavior clauses. You'll see them over and over in the module, so get to know them now:

  1. Save and read returns the same booking. If you do save(booking) and then get(booking.id), you get back a booking equal to the one you saved —same fields, same types—.
  2. get of a missing id raises. Asking for a booking that doesn't exist raises an exception (KeyError), it doesn't return None or an empty booking. (This is exactly the clause the module 2 fake violated.)
  3. Saving the same id twice updates, doesn't duplicate. A second save with the same id replaces the previous booking; it doesn't create a second row.
  4. find_by_room returns only that room's bookings. It filters by room_id and doesn't drag in bookings from other rooms.

These four sentences are the contract. They don't depend on how the repository is implemented —an in-memory dict, a SQLite table, a file, a remote service—; they describe what the consumer needs to be able to rely on. And that's the module's key: it's BookingService's (the consumer's) needs that dictate the clauses. We'll come back to this in lesson 3.

First, the fix module 1 left pending

Before putting the SqliteBookingRepository to answer to the contract, we have to settle a debt from module 1. There, the integration with the real repository exposed a bug we left red on purpose: get read the start column —a TEXT— and returned the value as-is, as a str, without reconstructing the datetime; the fake, on the other hand, returned the datetime intact. That was the type divergence module 1 showed and didn't fix. To be able to contract-test —so that this module's green outputs tell the truth and aren't a mirage— we fix it now, where the bug always should have been fixed: in the provider. get reconstructs the datetime when reading, with datetime.fromisoformat(row[3]):

# reservo/sqlite_repo.py — get() with the datetime fix applied
from datetime import datetime
# ...
    def get(self, booking_id):
        row = self._conn.execute(
            "SELECT id, room_id, member_id, start, end, status, price_cents "
            "FROM bookings WHERE id = ?",
            (booking_id,),
        ).fetchone()
        if row is None:
            raise KeyError(booking_id)               # missing id -> raises
        return Booking(
            id=row[0], room_id=row[1], member_id=row[2],
            start=datetime.fromisoformat(row[3]),    # text -> datetime back
            end=datetime.fromisoformat(row[4]),
            status=row[5], price_cents=row[6],
        )

With that get, the SqliteBookingRepository is now a faithful provider: it reconstructs the types when reading and raises KeyError on the missing id. From here on, Reservo's real repository carries this fix —except when a module reverts it on purpose to exhibit the bug again, as module 5 will with the full flow—. It's what makes the eight greens below true against the repository you built, and not an optimistic output. (Module 5 will use that deliberate rollback to show something this module's contract doesn't reach: that a contract with a gap might not catch this bug, and that the full-flow integration does.)

Worked example: the cure at a glance

Let's see the cure working —a module preview, you don't need to write it now—. The contract battery is four tests, one per clause. The trick is the parametrized fixture: instead of receiving a fixed repository, each test receives a repo fixture that pytest fills twice —once with the fake, once with SQLite—. That way, the four clauses run against both implementations without duplicating a single line of test.

# 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 SAME fixture delivers, on each run, a fake and a SqliteBookingRepository.
@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"]

Four tests, two implementations: pytest will run eight cases.

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

Read the ids in brackets: [fake] and [sqlite]. Each clause appears twice, one per implementation, and all eight pass. That's a certification: the fake and the real one fulfill the four clauses of the contract, so —within what the contract covers— the fake isn't lying. The assumption that in module 2 nobody verified ("my fake behaves like the real one") is here a verified fact, backed by eight greens.

And what if the fake did lie? It's the question that gives everything its meaning. If instead of the correct FakeBookingRepository we put in the BuggyFakeBookingRepository from module 2 —the one that returns None on get of a missing id—, the same battery gives it away:

tests/..._divergence.py::test_get_of_a_missing_id_raises[buggy-fake] FAILED [ 37%]
tests/..._divergence.py::test_get_of_a_missing_id_raises[sqlite]     PASSED [ 50%]
...
E       Failed: DID NOT RAISE KeyError
========================= 1 failed, 7 passed in 0.04s ==========================

There's the cure in action, summarized: the same test, test_get_of_a_missing_id_raises, passes for [sqlite] and fails for [buggy-fake] with a message that leaves no doubt —DID NOT RAISE KeyError—. The contract found exactly module 2's bug, on your machine, before deploying. Lesson 5 builds this red step by step; for now keep the shape: one battery, two providers, and the one that diverges can't hide.

The module's map: the eight lessons

Each lesson leans on the previous one. The journey takes you from "I know the double can lie" to "I have a contract that doesn't let it lie, and I know how the industry does it at scale".

LessonTopicThe idea in one sentence
1From the problem to the cure (this one)The contract: a shared behavior spec, verified the same against all implementations
2What a contract isThe interface is the shape; the contract is the behavior — and that's why two implementations with the same shape can diverge
3Consumer-driven contractsThe consumer (BookingService) defines what it expects; the provider promises to fulfill it
4The parametrized batteryA fixture with params=["fake", "sqlite"] runs each clause against both implementations
5The contract catches module 2's divergenceThe fake that returns None instead of raising shows up red; the real one stays green
6State vs interactionAsserting on the result (the repository) versus asserting on the call (the gateway)
7The concept of PactThe industrial, networked version: pact file, broker, provider verification
8Mini-projectWrite the repository's contract and run it against the fake and SQLite; catch the divergent fake

Where this module ends (the border)

It's worth marking two limits from the start, because there are topics that look like they belong here and belong to the next module or another guide.

The two sides of the contract in detail are module 4. Here we treat the contract as a shared battery: a suite that runs against several implementations. But a contract has two faces worth looking at separately —the consumer test ("I, BookingService, send this and expect that") and the provider test ("I, the repository, given this return that")—, and with them a breaking change is caught before deploying. That breakdown, and that broken-change case, are module 4. In this module, contract = a battery both fulfill.

In-depth integration with real resources is module 5 onward. We use the real SqliteBookingRepository as one of the implementations the contract certifies, but we don't yet get into the tricks of really integrating: transactions, rollback, temporary files, a stdlib http.server, isolation between tests that touch real state. That's modules 5, 6, and 7. Here SQLite is "the real piece the contract must cover", not "the resource we learn to handle".

Pact isn't installed. In lesson 7 we explain the concept of Pact —consumer-driven contracts between network services— as the industrial version of what you build here by hand. But it's a reference concept: we don't install the tool or depend on anything outside the stdlib. Reservo remains pytest, sqlite3, and http.server, everything you already have.

Common mistakes

Thinking the contract is "more fake tests and more real tests". What happens: someone understands "verify both" as "I write the fake's suite, copy and paste, adapt it to the real one". Why it happens: it's the natural reflex —two implementations, two suites—. How to detect it: if you have two test files that should assert the same thing but separately, nothing guarantees they keep saying the same when one changes. How to fix it: the contract is one battery that runs against both. Copy-and-paste produces two specs that drift; parametrizing produces a single spec, impossible to desynchronize. That uniqueness is exactly what gives the guarantee.

Believing "same interface" already implies "same behavior". What happens: someone sees that the fake and the real one have the methods save, get, find_by_room with the same signatures and concludes they're interchangeable. Why it happens: Python doesn't require more; if the names match, the code runs. How to detect it: module 2's bug is the proof —same interface, different behavior (None versus exception)—. How to fix it: the interface is a necessary but not sufficient condition; the contract tests the behavior, which is what the interface doesn't capture. Lesson 2 lives off this distinction.

Wanting to "fix" the divergence by making the fake imitate the real one's defect. What happens: to make the battery green, someone has the fake reproduce some undesirable quirk of the real one instead of deciding what the correct behavior is. Why it happens: it seems that "aligning" the fake to the real one closes the red. How to detect it: if your contract now demands a behavior you wouldn't want in production, you baked the bug into the spec. How to fix it: the contract describes the correct behavior (for example, "get of a missing one raises"), and both providers must fulfill it. If the real one doesn't fulfill it, the real one is fixed; if the fake doesn't fulfill it, the fake is fixed. The contract is the referee, not the mold of the defect.

Exercises

Exercise 1 — Interface or contract. For each claim about the BookingRepository, say whether it describes the interface (the shape) or the contract (the behavior): (a) "get receives a str and returns a Booking"; (b) "get of an id that doesn't exist raises KeyError"; (c) "save returns nothing"; (d) "saving the same id twice leaves a single booking".

See solution
  • (a) Interface. It talks about the shape: what type get receives and what type it returns. It's the method's signature. Two implementations could fulfill this signature and still behave differently.
  • (b) Contract. It talks about the behavior in a concrete case (missing id): what it does, not what shape it has. It's exactly clause 2, the one the module 2 fake violated.
  • (c) Interface. It describes save's signature (returns no value). It's shape.
  • (d) Contract. It describes what happens when repeating a save with the same id (updates, doesn't duplicate): it's clause 3, observable behavior.

The rule you're sharpening: the interface says what it's called and what types it moves; the contract says how it behaves. Module 2's bug lived exactly where the interface is silent and the contract speaks.

Exercise 2 — Why one battery and not two. A colleague proposes: "let's write test_fake_repo.py with the fake's tests and test_sqlite_repo.py with the real one's; after all, they test the same thing". Explain why a single parametrized battery is superior to two twin suites for this module's goal.

See solution

Two twin suites solve the problem today and reopen it tomorrow. At the moment of writing them maybe they assert the same thing, but they're two independent texts: when someone adds a clause to the contract, or changes an assertion, they have to remember to touch both. As soon as one changes and the other doesn't, you're back in the world of module 2 —the fake and the real one tested against different expectations, free to diverge without anyone noticing—. The contract's goal isn't "test the fake" or "test the real one"; it's guarantee they behave the same, and only a single source of truth guarantees that.

A parametrized battery is that single source. You write the clause once and pytest runs it against both implementations. It's impossible for you to "forget to update the real one's", because there aren't two: there's one, run twice. The spec's uniqueness is the guarantee; the duplication destroys it.

Exercise 3 — What the green guarantees (and what it doesn't). The example battery passes green for [fake] and [sqlite]. A colleague concludes: "done, the fake and the real one are identical". Correct the claim precisely: what exactly does the green guarantee, and what's left out?

See solution

The green guarantees something strong but bounded: the fake and the real one behave the same in everything the contract covers —the four clauses: save-and-read, missing-get-raises, save-updates, find_by_room-filters—. About those four behaviors, there's no more praying: they're verified against both implementations.

What the green does not guarantee is that they're identical in everything. It only covers what you wrote as a clause. If there's a behavior that matters and isn't in the battery —say, how find_by_room orders, or what happens with a negative price_cents, or concurrency—, the contract is silent about it, and there the fake and the real one could still diverge without any red giving it away. A contract's guarantee is as broad as its clauses: it covers what it states, and nothing more. That's why writing the contract is deciding, carefully, which behaviors are part of the agreement. An empty contract always passes and protects nothing; a well-thought-out contract covers exactly the seams that can do harm.

Summary and next step

In this lesson you took the leap from the problem to the cure. Module 2 showed you the disease —the double that lies and deceives the unit test—; here you met the remedy: the contract, a shared behavior spec that is verified the same against all implementations. With the outlet standard you understood the idea —a specification that two parties who don't speak fulfill equally, certified by a lab—; with the four clauses of the BookingRepository you saw what a concrete contract is made of; and with the parametrized battery running [fake] and [sqlite] you saw the cure working: eight greens that certify the fake isn't lying, and a red (DID NOT RAISE KeyError) that catches the fake that does.

Before moving on you should be able to: define a contract as a spec of behavior (not shape), verified the same against all implementations; state the four clauses of the repository's contract; and explain why a single parametrized battery —not two twin suites— is what guarantees the fake and the real one don't diverge.

What comes next is sharpening the first of those ideas to the edge. In lesson 2 we're going to separate clearly the interface (the shape: names and signatures) from the contract (the behavior: what they really promise), because in the crack between those two things is where module 2's bug lived. Understanding that distinction is what lets you write contracts that cover exactly what matters.

Resources