Module 3: Contract Testing Consumer And Provider

6. State contracts versus interaction contracts

Description

Up to now all our clauses had the same shape: we called a method and asserted about what it returned or about the observable state it left. "Save and read returns the same booking." "find_by_room returns only that room's." We assert about the result. That's one way to write a contract —the most common for a repository—, but it's not the only one. There's a second form, and this lesson introduces it: contracts that assert not about the result, but about the call —who was called, with what arguments, how many times—.

The names are state contract and interaction contract. A state contract verifies what was left: you save a booking and check that get returns it with price_cents == 6000. You don't care how the provider got there; you care about the observable result. An interaction contract verifies what happened between the pieces: you book and check that BookingService called payments.charge exactly once, with 6000 cents. There's no "result" to inspect —charging doesn't leave a state you can read back like a saved booking—; what you verify is that the conversation between the consumer and the provider happened as the contract demands. Each of Reservo's collaborators calls for one form or the other according to its nature: the repository, which stores state, lends itself to the state contract; the PaymentGateway, which executes an action with effects outside, lends itself to the interaction one.

Connection to the module: this lesson refines the instrument you already master. You know how to write a contract as a parametrized battery (lesson 4) and you know it catches divergences (lesson 5); now you learn that a clause can be written two ways, and to choose the right one according to the collaborator. It's the last conceptual piece before lesson 7, where the concept of Pact brings both styles together in the communication between network services. With state and interaction clear, you have the complete vocabulary to reason about any contract: what behavior (lesson 2), whose (lesson 3), verified how (lesson 4), and now, asserted about what —the result or the call—.

Analogy: the photo of the result versus the recording of the conversation

Imagine you hire a courier to deliver a package and you want to verify they did their job well. You have two ways to check. The first: you go to the recipient's house and look at whether the package is there. If it is, and in good condition, the job was done —you don't care what route the courier took, how many stops they made, whether it was by motorcycle or on foot—. You verified the result: a photo of the final state. The second way is for when you can't go to the house —the package is a verbal message that leaves no physical trace—: then you record the courier and check that they said the right words, to the right recipient, only once. You verified the interaction: the recording of the conversation.

The state contract is the photo of the result; the interaction one is the recording of the conversation. For the repository, you can go to "the house" —the store— and look at whether the booking stayed: state contract. For the payment, there's no house to visit inside your test —charging a card has its effect at the bank, outside, not in a state you can read back—; what you can do is record that BookingService told payments: "charge 6000", once: interaction contract. The choice isn't a matter of taste: it depends on whether the collaborator's effect leaves a state you can inspect (photo) or goes outside and only the conversation remains (recording).

The state contract: asserting about the result

You already wrote many; let's give them a name. A state contract follows the pattern act, then observe the result:

# STATE contract: asserts about the observable RESULT (the saved row).
def test_state_contract_saved_booking_has_the_right_price():
    repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
    service = BookingService(Calendar(), FixedClock(NOW),
                             MockPaymentGateway(), SpyEmailSender(), repo)
    booking = service.book(FOCUS, ANA, START, END)
    assert repo.get(booking.id).price_cents == 6000        # <-- I observe the state

The assertion looks at the world after the action: it asks for the saved booking and checks its price_cents. It says nothing about how book computed the price or in what order it did things; it only verifies that the observable result —the persisted booking— is correct. This style is the natural one when the collaborator stores state you can read back: the repository is the perfect case, because its reason for being is to leave bookings that are later retrieved. The four clauses of the BookingRepository contract from the previous lessons are all state ones: you save and read, save twice and read, filter and read. You always observe the result.

The state contract's virtue is that it's robust against implementation details. Since it only looks at the result, the provider is free to reach it however it wants —a dict, a table, a cache— and the clause still holds for all. That's why it fits so well with the parametrized battery: "the observable result is X" is fulfillable by any implementation, and it's exactly what you want to demand of all equally.

The interaction contract: asserting about the call

Now the other style. Sometimes there's no state to observe, or the one that matters isn't the result but that the conversation happened correctly. Charging is the canonical example: when BookingService.book charges, the real effect —moving money— happens outside, in the gateway, and inside your test there's no "charge booking" to read back. What you can verify is that book talked to the gateway correctly: that it called charge once, with the correct amount. For that we use a double that records the calls —a mock— and assert about what was recorded:

class MockPaymentGateway:
    """Records the calls: used to verify the INTERACTION, not the state."""

    def __init__(self):
        self.calls = []

    def charge(self, amount_cents):
        self.calls.append(amount_cents)                    # <-- notes the call
        return Receipt(id="rcpt-1", ok=True, amount_cents=amount_cents)


# INTERACTION contract: asserts about the CALL (to whom, with what, how many times).
def test_interaction_contract_charge_is_called_once_with_the_price():
    repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
    payments = MockPaymentGateway()
    service = BookingService(Calendar(), FixedClock(NOW),
                             payments, SpyEmailSender(), repo)
    service.book(FOCUS, ANA, START, END)
    assert payments.calls == [6000]        # a single call, for 6000 cents

The assertion doesn't look at any returned result; it looks at the list of calls the mock was recording. payments.calls == [6000] says three things at once: that charge was called (the list isn't empty), that it was called once (the list has one element, not two —important: we don't charge twice—), and that it was called with the correct amount (6000 cents, the price of Focus 3 h pro). That's an interaction contract: it verifies the protocol of the conversation between the consumer and the provider.

This style is the natural one when the collaborator executes an action with an effect outside —charging, sending an email, publishing a message— and what you care about protecting is that it's invoked correctly: the right number of times, with the right data. Charging twice is a serious bug no state contract of the gateway would catch (there's no state to read); an interaction contract that asserts calls == [6000] catches it instantly, because [6000, 6000] isn't equal to [6000].

Worked example: the two styles, side by side

Let's run the two tests together —one state one on the repository, one interaction one on the gateway— to see that both are legitimate clauses, each on its own terrain:

# tests/test_state_vs_interaction.py  (excerpt; imports and constants above)

# STATE: the repository saves a booking with the correct price.
def test_state_contract_saved_booking_has_the_right_price():
    repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
    service = BookingService(Calendar(), FixedClock(NOW),
                             MockPaymentGateway(), SpyEmailSender(), repo)
    booking = service.book(FOCUS, ANA, START, END)
    assert repo.get(booking.id).price_cents == 6000


# INTERACTION: charge is called once, with 6000.
def test_interaction_contract_charge_is_called_once_with_the_price():
    repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
    payments = MockPaymentGateway()
    service = BookingService(Calendar(), FixedClock(NOW),
                             payments, SpyEmailSender(), repo)
    service.book(FOCUS, ANA, START, END)
    assert payments.calls == [6000]

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

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

tests/test_state_vs_interaction.py::test_state_contract_saved_booking_has_the_right_price PASSED [ 50%]
tests/test_state_vs_interaction.py::test_interaction_contract_charge_is_called_once_with_the_price PASSED [100%]

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

Two greens, two styles. The first asserts about the result (the saved booking has the correct price); the second asserts about the call (charge was invoked once with 6000). The same book produced both truths —it saved correctly and charged correctly—, but we verified them in different ways because the collaborators are of a different nature. The repository leaves a state we read; the gateway executes an action whose correctness lives in how it was called. Choosing the right style for each is what makes each clause both faithful (it tests what matters) and robust (it doesn't break because of details that don't matter).

Which to choose (and each one's risk)

The practical rule is direct: if the collaborator leaves an observable state the consumer uses, state contract; if it executes an action with an effect outside and what matters is that it's invoked correctly, interaction contract. Repository → state. Payment gateway, email sending, publishing to a queue → interaction. Many collaborators admit both, and sometimes you want both: for the gateway you could verify by interaction that charge was called once with 6000 (protocol) and by state that the booking ended up confirmed only if the charge succeeded (result).

Each style has its risk, and it's worth knowing them. The state contract is robust but sometimes blind to what left no trace: if book charged twice but the final state (the saved booking) looked the same, a state contract of the repository wouldn't notice —the double charge left no state to read—. There you need interaction. The interaction contract, on the other hand, is more fragile against the implementation: it asserts about how the consumer talks to the provider, so it breaks if you change the conversation even if the result is still correct. If tomorrow book charges in two calls of 3000 instead of one of 6000 —same money, identical result—, calls == [6000] would go red even though nothing is actually wrong. That's why the common wisdom is to prefer state when you can, and use interaction only when the result isn't enough —charges that must not be duplicated, emails that must not be omitted, actions without inspectable state—. Assert about the what (the result) whenever you can; reserve the how (the call) for when the what doesn't tell the whole story.

This distinction, by the way, connects with the doubles you already know from the sister guide: a stub or a fake serve you for state contracts (they give values/state that you later observe); a spy or a mock serve you for interaction contracts (they record the calls that you later assert). The type of double and the type of contract go hand in hand: you choose the double according to what the clause needs to observe.

Common mistakes

Using interaction where state was enough (fragile contract). What happens: to verify that book saves correctly, someone asserts "repo.save was called once with such an object" instead of "the saved booking has such a price". Why it happens: the mock is at hand and asserting about the call seems more direct. How to detect it: if your test would break when changing how the consumer saves (two saves instead of one, a slightly different object) even though the final result is correct, you over-specified with interaction. How to fix it: for a collaborator with observable state (the repository), assert about the result (get returns the correct thing). It's more robust: it survives the implementation changes that don't change the result.

Using state where interaction was needed (invisible bug). What happens: the payment is verified only by looking at whether the booking ended up confirmed, without checking how many times charge was called. Why it happens: looking at the final state is the default reflex. How to detect it: if a double charge would leave the same final state (a confirmed booking) as a single charge, your state contract wouldn't catch it —the bug is invisible to the result—. How to fix it: for actions that must not be duplicated or omitted, add an interaction contract that asserts the number and arguments of the calls (calls == [6000]). It's the only way to protect the protocol when the result doesn't give the error away.

Asserting about internal calls that don't cross the seam. What happens: an interaction contract asserts about private methods of the consumer or about internal steps that aren't part of the deal with the provider. Why it happens: the mock can record anything, and it's tempting to verify too much. How to detect it: if your interaction assertion breaks when refactoring the consumer without changing anything the provider observes, you're asserting about internal implementation, not about the seam's contract. How to fix it: an interaction contract asserts only about the conversation between consumer and provider —the calls that cross the seam (charge, send)—, not about the consumer's internal steps. The contract lives at the seam; what's inside is the consumer's business.

Exercises

Exercise 1 — State or interaction. For each clause, say whether it's state or interaction: (a) "after cancel, the saved booking has status == 'cancelled'"; (b) "cancel calls emails.send once, with the member's id"; (c) "find_by_room('focus') returns two bookings"; (d) "book of an occupied room does not call payments.charge at all".

See solution
  • (a) State. It asserts about the observable result: what status ended up on the saved booking. It's verified by reading the state with get.
  • (b) Interaction. It asserts about the call to the email collaborator: to whom (emails.send), with what (the member's id), how many times (once). Sending an email leaves no inspectable state in your test; only the conversation remains.
  • (c) State. It asserts about the result of find_by_room: how many bookings it returns. The return value is observed.
  • (d) Interaction. It asserts about a call that didn't happen: if the room is occupied, book must reject before charging, so payments.charge must not be called. Verifying "it wasn't called" is an interaction assertion (calls == []) —and a very valuable one: it protects against charging for a booking that wasn't made—.

The pattern: if the clause looks at what was left, it's state; if it looks at who was called and how (including "not called"), it's interaction.

Exercise 2 — The invisible double charge. A bug makes BookingService.book call payments.charge twice with 6000 (charges double), but the booking is saved only once, correct. You have a state contract of the repository (the booking ended up with price_cents == 6000) and everything passes green. Explain why the state contract doesn't catch the double charge and write the clause that would.

See solution

The state contract doesn't catch it because the double charge leaves no trace in the state that contract observes. The saved booking has price_cents == 6000 —correct— regardless of whether charge was called once or twice: the second charge moves money outside (in the gateway/bank), it doesn't change the persisted booking. The state contract looks at "the saved booking" and there everything is fine; the bug lives in the conversation with the gateway, a place the state contract doesn't look at.

The clause that catches it is an interaction one, with a mock that records the calls:

def test_book_charges_exactly_once():
    payments = MockPaymentGateway()
    service = BookingService(Calendar(), FixedClock(NOW),
                             payments, SpyEmailSender(),
                             SqliteBookingRepository(sqlite3.connect(":memory:")))
    service.book(FOCUS, ANA, START, END)
    assert payments.calls == [6000]        # a SINGLE call, of 6000

With the bug, payments.calls would be [6000, 6000], and the assertion == [6000] would fail —catching the double charge—. This is the exact reason for the interaction contract's existence: to protect properties of the protocol (how many times, with what) that the final result doesn't give away. Charging once is as important as charging the correct amount, and only interaction verifies it.

Exercise 3 — The overly fragile test. A colleague, to "be exhaustive", verifies that book saves the booking with this interaction contract: assert repo_mock.save_calls == [booking] —asserting that save was called once with that exact object—. It works today. Later someone optimizes book to save in two steps (a provisional save and a final one with the confirmed status), without changing the booking that ends up at the end. The test breaks. Who's right, and what contract should have been written?

See solution

Whoever optimized book is right, and the interaction test was over-specified. The behavior that really matters to the repository's consumer is the result: that the booking ends up saved, a single one, with the correct data. How book reaches that result —in one save or two— is book's internal business, not part of the repository seam's contract. By asserting save_calls == [booking], the colleague turned an implementation detail (the number of saves) into a clause, and that's why a legitimate optimization —same final result— broke the test without anything actually being wrong. That's a fragile contract: it breaks because of changes that don't change the observable.

The contract that should have been written is a state one: it doesn't matter how many times save was called; what matters is that at the end get(booking.id) returns the correct booking, a single one, with status == "confirmed" and price_cents == 6000. That clause survives the optimization because it only looks at the result. The lesson: for the repository —a collaborator with observable state— prefer state; reserve interaction for what state can't tell (the previous exercise's double charge). Interaction where it's needed, not where it's superfluous.

Summary and next step

In this lesson you learned that a contract clause can be written two ways, and to choose the right one. The state contract asserts about the observable result —the saved booking has price_cents == 6000— and is the natural one for collaborators that leave state the consumer reads back, like the repository; it's robust against implementation details. The interaction contract asserts about the callcharge was invoked once, with 6000— and is the natural one for collaborators that execute an action with an effect outside, like the payment gateway; it catches protocol bugs (the double charge) that no state gives away, in exchange for being more fragile against implementation changes. With the photo of the result versus the recording of the conversation you fixed the image, and with two greens side by side you saw both styles verify the same book in different ways. The rule you take away: prefer state when you can, use interaction when the result isn't enough.

Before moving on you should be able to: classify a clause as state or interaction; explain why the state contract doesn't catch a double charge and when interaction is needed; and recognize an over-specified (fragile) interaction contract that should have been a state one.

With this you have the complete vocabulary of the hand-built contract: what behavior, whose, verified with what battery, asserted about the result or the call. Lesson 7 takes the module's last step: showing how the industry takes all this —consumer-driven, state, and interaction contracts— to the communication between services over the network, with a tool called Pact. You'll see the concept (the pact file, the broker, the provider verification) as the automated, networked version of the battery you built here by hand —without installing anything—.

Resources