Module 3: Contract Testing Consumer And Provider

2. What a contract is

Description

In lesson 1 we defined a contract in one sentence: a spec of behavior —not of shape— verified the same against all implementations. This lesson takes that sentence apart by its most important word: behavior, as opposed to shape. The distinction seems textbook, but it's exactly the crack module 2's bug slipped through, so it's worth looking at closely until it's obvious.

In Python there are two things we tend to confuse because they usually go together: a component's interface and its contract. The interface is the shape: what methods it has, what they're called, what parameters they receive, what type they return. The FakeBookingRepository and the SqliteBookingRepository have the same interface: both offer save(booking), get(id), and find_by_room(room_id) with the same signatures. The contract is the behavior: what those methods promise will happen when you call them. And there, with the same interface, the two repositories in module 2 diverged: get of a missing id raised in one and returned None in the other. Same shape, different behavior. That's the disease the contract cures, and to cure it you first have to see it clearly: the interface doesn't capture the behavior, and that's why "they have the same methods" was never a guarantee of "they behave the same".

Connection to the module: this lesson is the conceptual foundation of the contract. Once you separate shape from behavior, everything else falls into place: lesson 3 will say who defines the expected behavior (the consumer); lesson 4 will give the mechanism to verify it against several implementations (the parametrized battery); lesson 5 will collect on it by catching module 2's divergence. But none of that makes sense if "contract" still sounds like "the methods the class has". Here we separate them: the contract is the four behavior clauses of the BookingRepository, and we write them as the tests that verify them.

Analogy: the menu says the shape; the recipe says the taste

Walk into two restaurants that offer exactly the same dish on the menu: "Pesto pasta, with basil, pine nuts, and parmesan". The menu —the list of ingredients, the dish's name, the price— is the interface: the shape, what's promised in writing, identical in both places. You order the dish at both. At one it arrives creamy, with fresh pesto and toasted pine nuts; at the other it arrives watery, with wilted basil and cold. Same menu, different behavior. What differs isn't in the ingredient list: it's in how each kitchen executes the dish —the real recipe, what actually happens in the pan—.

The menu is the interface; the executed recipe is the contract. Two repositories can advertise the same "dish" —get(id) -> Booking— and serve different things: one raises when there's no booking, the other brings you an empty plate (None) and lets you believe there was something. If you choose a restaurant only by the menu, you get surprises; if you choose by what actually reaches the table —the behavior—, you know what you're eating. A contract is writing the expected recipe with such precision that you can send the same dish to both kitchens and check that both execute it the same. The identical menu was never enough; you have to taste it.

The interface: the shape

Let's start with what the fake and the real one do share. The BookingRepository's interface is this set of signatures —the method names and the shapes they move—:

# The INTERFACE of the BookingRepository (the shape, not the behavior)
class BookingRepository:
    def save(self, booking) -> None: ...
    def get(self, booking_id) -> "Booking": ...
    def find_by_room(self, room_id) -> list: ...

Both the FakeBookingRepository (an in-memory dict) and the SqliteBookingRepository (a real table) fulfill this interface to the letter. Both have save, get, and find_by_room with those parameters. In Python, fulfilling the interface is all that's needed for the code to run: BookingService calls self._repo.get(id) without asking what class the repo is, and it works with either. That flexibility is a virtue —it's what lets you substitute the real one with a fake in the tests—, but it carries hidden the danger of module 2: since Python only requires that the methods exist, not that they behave the same, two implementations can pass for the same interface while differing in what they do. The shape isn't the behavior.

The contract: the behavior

The contract is what the interface doesn't say: what happens when you call each method, case by case. For the BookingRepository, they're the four clauses you already met in lesson 1, now written as what they really are —executable tests—:

# The CONTRACT of the BookingRepository (the behavior), as tests
# 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"]

Each test is a clause, and each clause is a behavior promise the interface can't express. The signature get(id) -> Booking says nothing about what happens when the id doesn't exist; clause 2 fixes it: it raises. save's signature doesn't say what happens when repeating an id; clause 3 fixes it: it updates, doesn't duplicate. There's the whole difference. The interface describes the channel (what I send, what I get back); the contract describes the conduct (what happens in each situation that matters to me). The contract is a superset of the interface: it includes it —the methods must exist to be able to test them— and adds the essential thing it was silent about.

Worked example: the same shape, two behaviors

Let's see it with module 2's exact case, isolated to the clause that diverged. Here we don't parametrize yet —that's lesson 4—; we just put the correct fake and the buggy fake face to face to see that the identical interface doesn't prevent opposite behaviors.

# tests/test_interface_is_not_contract.py
from reservo.doubles import BuggyFakeBookingRepository, FakeBookingRepository


# Same call, same interface: get of an id that doesn't exist.
def test_correct_fake_raises_on_missing():
    repo = FakeBookingRepository()
    # Correct behavior: raises.
    try:
        repo.get("ghost")
        raised = False
    except KeyError:
        raised = True
    assert raised is True


def test_buggy_fake_returns_none_on_missing():
    repo = BuggyFakeBookingRepository()
    # Divergent behavior: does NOT raise, returns None.
    result = repo.get("ghost")
    assert result is None

The two repositories have the same interface —get(id)—, and yet we make two opposite assertions about the same call: one raises, the other returns None. And both tests pass, because each describes the real behavior of its repository. That's the living proof that the interface isn't the contract: here the shape is identical and the behavior is contradictory.

What to expect. On my machine (Python 3.14.0, pytest 9.1.1):

python3 -m pytest tests/test_interface_is_not_contract.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items

tests/test_interface_is_not_contract.py::test_correct_fake_raises_on_missing PASSED [ 50%]
tests/test_interface_is_not_contract.py::test_buggy_fake_returns_none_on_missing PASSED [100%]

============================== 2 passed in 0.01s ==============================

The two greens tell the whole story. test_correct_fake_raises_on_missing passes because the correct fake raises; test_buggy_fake_returns_none_on_missing passes because the buggy fake returns None. Both fulfill the same interface; neither is "broken" at the syntax level. The difference —raise versus return None— is pure behavior, and that's why only a contract (a clause that says which of the two is correct and demands it of both) can settle who's lying. The interface passes both as good; the contract picks one and forces the other to align or go red. That is, exactly, lesson 5.

Why the distinction matters so much

You might ask: if Python lets me substitute one repo for another just by sharing the interface, why obsess over behavior? Because the substitution is the source of your confidence and also of your risk. You substitute the real one with the fake in the unit tests to gain speed; that substitution is only honest if the fake behaves like the real one in everything that matters to you. The interface guarantees that the substitution compiles and runs; the contract guarantees that the substitution doesn't change the result. Without a contract, every substitution is a gamble: "I hope these two behave the same". Module 2's bug was that lost gamble.

There's a classic principle behind this, the Liskov substitution principle, which in plain terms says: if a component promises an interface, any implementation of that interface must be usable in its place without behavior surprises. The interface is the promise of shape; the contract is the promise of "no surprises". When you write the contract as a battery and run it against all the implementations, you're enforcing that principle mechanically: no implementation can slip in with the correct shape and the wrong behavior, because the battery actually tests it.

And there's a practical detail that rounds out the idea: the contract is as big as its clauses. get's interface is fixed (one signature); get's contract is as detailed as you decide to write it. If you only write the happy-case clause (save and read), your contract is silent about the missing case —and there the fake and the real one can diverge without anyone noticing—. Writing a good contract is, above all, deciding which behaviors are part of the agreement: the happy cases, yes, but also the edges that bite —the missing id, the repeated id, the filter that mustn't drag in extras—. Each clause you add closes a door a divergence could escape through.

Common mistakes

Treating type hints as if they were the contract. What happens: someone annotates def get(self, booking_id: str) -> Booking and feels they've already specified the behavior. Why it happens: type hints seem precise and give a false sense of rigor. How to detect it: the hint -> Booking says nothing about what happens when there's no booking —does it raise? return None? an empty Booking?—; if your "spec" doesn't answer that question, it's shape, not behavior. How to fix it: hints document the interface (useful), but the contract is the tests that fix the conduct in each case, including the edges. A -> Booking and a test_get_of_a_missing_id_raises don't compete; the first is the menu, the second the recipe.

Writing only the happy case and believing the contract is complete. What happens: the battery has the clause "save and read returns the same booking" and nothing else, and victory is declared. Why it happens: the happy case is the one that first comes to mind and the most satisfying to see green. How to detect it: ask about the edges —missing id, repeated id, empty list— and check whether any clause covers them. If not, the contract is silent exactly where implementations tend to diverge. How to fix it: module 2's bug lived on an edge (the missing id), not on the happy case. A serious contract covers the edges that can do harm; they, not the happy case, are what catch the divergences.

Confusing "the code runs" with "the behavior is correct". What happens: the fake is substituted with the real one, the app starts without import or attribute errors, and it's concluded that the substitution is healthy. Why it happens: in Python, sharing the interface is enough for it not to blow up on startup. How to detect it: starting up only proves that the shape fits; it says nothing about the behavior in each case. How to fix it: the only proof that the substitution is healthy is running the contract —the behavior battery— against both implementations and seeing them match. "Runs" is the interface; "behaves the same" is the contract.

Exercises

Exercise 1 — Separate the menu from the recipe. Classify each sentence as part of the interface (shape) or the contract (behavior) of the BookingRepository: (a) "find_by_room returns a list"; (b) "find_by_room of a room without bookings returns an empty list, doesn't raise"; (c) "save accepts a Booking and returns no value"; (d) "after save, the booking is visible to find_by_room of its room".

See solution
  • (a) Interface. The return type (list) is shape. It doesn't say what it contains or what happens at the edges.
  • (b) Contract. It describes what happens in a concrete case (room without bookings): returns empty instead of raising. It's behavior, and moreover a valuable edge —the kind of case where implementations tend to diverge—.
  • (c) Interface. The types save receives and returns: pure shape.
  • (d) Contract. It relates two operations (save and then find_by_room) and fixes what should be observed: behavior. It's a consistency clause between methods.

The pattern: if the sentence talks about types and names, it's interface; if it talks about what happens when I call, especially at the edges, it's contract.

Exercise 2 — The missing clause. The example contract has four clauses. Imagine Reservo starts depending on find_by_room returning the bookings ordered by start. The SqliteBookingRepository returns them in the order they're in the table; the FakeBookingRepository in the dict's insertion order. Does the current contract protect against a divergence in the order? If not, write the clause that would be missing.

See solution

The current contract doesn't protect the order. Clause 4 only verifies which bookings find_by_room returns (the requested room's, not other rooms'), not in what order. Since no clause mentions the order, it's a behavior outside the agreement: the fake and the real one could return the same bookings in different orders and the four clauses would still be green. It's exactly the kind of silence a divergence slips through —just like the missing id in module 2—.

The clause that would be missing, written as a parametrized test:

# Clause 5: find_by_room returns the bookings ordered by start.
def test_find_by_room_returns_bookings_ordered_by_start(repo):
    late = Booking(id="bk-late", room_id="focus", member_id="m-ana",
                   start=datetime(2026, 3, 10, 15), end=datetime(2026, 3, 10, 16),
                   status="confirmed", price_cents=2500)
    early = Booking(id="bk-early", room_id="focus", member_id="m-ana",
                    start=datetime(2026, 3, 10, 9), end=datetime(2026, 3, 10, 10),
                    status="confirmed", price_cents=2500)
    repo.save(late)      # the later one is saved first
    repo.save(early)
    found = repo.find_by_room("focus")
    assert [b.id for b in found] == ["bk-early", "bk-late"]   # ordered by start

Adding it and running the battery, any implementation that doesn't order by start would go red. This reinforces the lesson: the contract covers what it states. If the order matters to the consumer, it has to be a clause; if not, it's left out on purpose. Deciding that —what enters the agreement— is the work of designing a contract.

Exercise 3 — Same interface, behavior that bites. A colleague writes a third repository, CachingBookingRepository, with the same interface (save, get, find_by_room), that keeps an in-memory copy of the last thing it read to go faster. Because of a bug, after a save that updates a booking, get keeps returning the old version from the cache. Which contract clause would catch it, and what does this tell you about why the interface isn't enough?

See solution

The clause that catches it is 3: "saving the same id twice updates (doesn't duplicate)". The test saves a booking with status="confirmed", saves it again with status="cancelled", and then asserts that get returns status == "cancelled". The buggy CachingBookingRepository would return the old version ("confirmed") from its stale cache, so that assertion would fail red —exactly where it should—.

What this reveals is the heart of the lesson: the CachingBookingRepository has the perfect interface. Correct names, correct signatures, starts without a single error. And yet it behaves badly in a concrete situation (read after update). No review of the shape could catch that bug, because the shape is impeccable. Only the contract —a clause that tests the behavior of update-and-read— reveals it. Each new implementation of an interface is a new opportunity to diverge; the contract is what keeps them all honest without you having to review the behavior by hand one by one.

Summary and next step

In this lesson you separated two things that used to come stuck together: the interface (the shape —names, signatures, types—) and the contract (the behavior —what those methods promise will happen—). With the menu and the recipe you saw that two kitchens can advertise the same dish and serve different tastes; with the correct fake and the buggy one you saw, in green, that two repositories with the same interface can behave oppositely to the same call. And you understood why this matters so much: the substitution of a double for the real one is honest only if they share the behavior, sharing the shape isn't enough —and the interface, on its own, never guaranteed it—.

Before moving on you should be able to: distinguish an interface sentence from a contract one; state the four behavior clauses of the BookingRepository and recognize that they cover both happy cases and edges; and explain why "they have the same methods" never implied "they behave the same", with module 2's bug as proof.

You now know what a contract is (behavior, not shape). What's missing is the question of who decides that behavior: does the one who implements the repository define it, or the one who uses it? In lesson 3 you'll see that the consumer rules —BookingService, the one that uses the repo— because it's its needs that turn into clauses. That idea, that of consumer-driven contracts, is the one that gives the whole discipline its name.

Resources