Object interaction tests: check what a history leaves behind
Test a library hold desk with a short sequence of public calls, read the effects its recorder collects, and tell required effects from incidental calls. Behavioural contracts and testing · Object-oriented programming.
Mira cancels her hold on Dune. Ravi then tries to reserve it and is refused, although nobody holds it. Reserve passed its test. Cancel passed its test. What did neither test look at?
A desk, a recorder and a history
A test usually checks one call on one object. Here the object under test is a library hold desk, HoldDesk. It has two operations. reserve(member, book) places a hold on a free book for a member. cancel(member, book) removes a hold, but only when that member is the one holding it. Each operation answers True when it did its job and False when it refused. In this lesson a refusal is a False result, never an exception. Java spells them true and false; prose and tables here write True and False.
The desk does not work alone. When something visible happens, it tells another object, a notifier, which would send a message to a member. An object that the object under test talks to is called a collaborator. A test cannot read a real message, so it hands the desk a collaborator that only writes down what it is told. That is a recorder: a plain class holding a list.
interface Notifier {
void send(String kind, String member, String book);
}
class RecordingNotifier implements Notifier {
final List<String> events = new ArrayList<>();
public void send(String kind, String member, String book) {
events.add(kind + " " + member + " " + book);
}
}class RecordingNotifier:
def __init__(self):
self.events = []
def send(self, kind, member, book):
self.events.append((kind, member, book))Java needs the small Notifier interface, from abstract classes and interfaces, so the desk accepts either notifier. Python needs no declaration: any object with send works. The desk, with the same logic in both languages:
class HoldDesk {
private final Map<String, String> holder = new HashMap<>();
private final Notifier notifier;
HoldDesk(Notifier notifier) { this.notifier = notifier; }
boolean reserve(String member, String book) {
if (holder.get(book) != null) return false;
holder.put(book, member);
notifier.send("hold-placed", member, book);
return true;
}
boolean cancel(String member, String book) {
if (!member.equals(holder.get(book))) return false;
holder.remove(book);
notifier.send("hold-cancelled", member, book);
return true;
}
}class HoldDesk:
def __init__(self, notifier):
self.holder = {} # book -> member, or None when free
self.notifier = notifier
def reserve(self, member, book):
if self.holder.get(book) is not None:
return False
self.holder[book] = member
self.notifier.send("hold-placed", member, book)
return True
def cancel(self, member, book):
if self.holder.get(book) != member:
return False
self.holder[book] = None
self.notifier.send("hold-cancelled", member, book)
return TrueA first test makes one call and checks two things: what the call returned, and what the recorder now holds. The helper check(ok, what) is the one defined in the contract tests lesson; it raises an error naming what when ok is false. The Java snippets also assume import java.util.*;.
RecordingNotifier recorder = new RecordingNotifier();
HoldDesk desk = new HoldDesk(recorder);
check(desk.reserve("Mira", "Dune"), "outcome");
check(recorder.events.equals(List.of("hold-placed Mira Dune")), "effects");recorder = RecordingNotifier()
desk = HoldDesk(recorder)
check(desk.reserve("Mira", "Dune"), "outcome")
check(recorder.events == [("hold-placed", "Mira", "Dune")], "effects")One call is a single point. A history is a sequence of public calls on the same objects, where each call starts from what the earlier ones left behind. Consider four calls on a fresh desk.
| Call | Outcome | Recorder after the call |
|---|---|---|
1. reserve(Mira, Dune) | True | placed Mira |
2. reserve(Ravi, Dune) | False | placed Mira (unchanged) |
3. cancel(Mira, Dune) | True | placed Mira, cancelled Mira |
4. reserve(Ravi, Dune) | True | placed Mira, cancelled Mira, placed Ravi |
Four calls, one history
Read the steps as text
- A fresh desk and an empty recorder. Four public calls follow, and the contract says what each one must return and record.
- Desk.reserve(Mira, Dune) returns True. Compared 2 rows: 2 pass. A free book is reserved, so the recorder gains one hold-placed.
- Desk.reserve(Ravi, Dune) returns False. Compared 2 rows: 2 pass. The book is held, so the answer is False and nothing is recorded.
- Desk.cancel(Mira, Dune) returns True. Compared 2 rows: 2 pass. The holder cancels, which frees the book and records one cancellation.
- Desk.reserve(Ravi, Dune) returns True. Every judged row passes. Ravi now succeeds, and the recorder holds placed, cancelled, placed.
- Four outcomes and three recorded effects all matched the contract.
The contract as plain rules
The contract is the list of promises a caller may rely on, written without mentioning fields. The desk keeps its own record of holds and shares no state with borrowing. For this desk:
- A reserve on a free book returns
True, and the recorder gains onehold-placedfor that member and book. - A reserve on a held book returns
False, and the recorder gains nothing. - A cancel by the member who holds the book returns
True, frees the book, and the recorder gains onehold-cancelled. - A cancel by anyone else returns
False, and the recorder gains nothing.
A required effect is something the contract demands that another object can see: which kind of message, for which member and book, how many, and in what order. Order and content count. Java's List.equals and Python's list == compare element by element in order. "Placed Mira, cancelled Mira, placed Ravi" is not the same promise as the same entries shuffled, and a bare count of three would accept both.
Bugs that only a history can see
The history becomes a test by making the four calls on one desk, collecting the outcomes, and comparing both the outcomes and the recorder with what the contract requires.
List<Boolean> outcomes = List.of(
desk.reserve("Mira", "Dune"), desk.reserve("Ravi", "Dune"),
desk.cancel("Mira", "Dune"), desk.reserve("Ravi", "Dune"));
check(outcomes.equals(List.of(true, false, true, true)), "outcomes");
check(recorder.events.equals(List.of("hold-placed Mira Dune",
"hold-cancelled Mira Dune", "hold-placed Ravi Dune")), "effects");outcomes = [
desk.reserve("Mira", "Dune"), desk.reserve("Ravi", "Dune"),
desk.cancel("Mira", "Dune"), desk.reserve("Ravi", "Dune"),
]
check(outcomes == [True, False, True, True], "outcomes")
check(recorder.events == [("hold-placed", "Mira", "Dune"),
("hold-cancelled", "Mira", "Dune"), ("hold-placed", "Ravi", "Dune")], "effects")The first defect is stale state. This cancel records the cancellation and answers True, but forgets to free the book.
boolean cancel(String member, String book) {
if (!member.equals(holder.get(book))) return false;
// holder.remove(book) is missing
notifier.send("hold-cancelled", member, book);
return true;
}def cancel(self, member, book):
if self.holder.get(book) != member:
return False
# self.holder[book] = None is missing
self.notifier.send("hold-cancelled", member, book)
return TrueTest cancel alone and it passes, and so does reserve. Only the fourth call of the history, Ravi's reserve after Mira's cancel, comes back False with no new effect, because the desk still believes Mira holds the book. The history test above catches it: the contract says the fourth outcome is True, the buggy desk gives False, so the outcomes check fails.
The cancel that forgot to free the book
Read the steps as text
- A desk whose cancel records the message and answers True but never frees the book. Each single call still looks right.
- StaleDesk.reserve(Mira, Dune) returns True. Compared 2 rows: 2 pass. Mira reserves Dune and the desk behaves correctly.
- StaleDesk.cancel(Mira, Dune) returns True. Compared 2 rows: 2 pass. This call alone passes, with True and one cancellation recorded.
- StaleDesk.reserve(Ravi, Dune) returns False. Failing: outcome, effects. Ravi is refused with no effect, though the contract says True and a hold.
- Only the history exposed the book still marked as held.
The second defect is a duplicate effect: cancel sends the hold-cancelled message twice. Every outcome is correct, so an outcome-only test passes. The recorder shows four entries where the contract allows three.
The cancel that records twice
Read the steps as text
- A desk whose cancel sends its message two times. Every answer it gives is correct.
- TwiceDesk.reserve(Mira, Dune) returns True. Compared 2 rows: 2 pass. The reserve is correct, with one hold-placed.
- TwiceDesk.cancel(Mira, Dune) returns True. Failing: effects. The cancel returns True, yet the recorder gains two cancellations.
- An outcome-only test would have passed this desk.
Assert the effect, not the call count
A test should depend on what the contract promises, not on how the desk reaches it. Suppose a developer refactors cancel so that it first calls a private helper to write an audit line. Every outcome is unchanged, and the recorder still receives the same messages in the same order.
def cancel(self, member, book):
self._audit("cancel asked", member, book) # extra helper, not in the contract
if self.holder.get(book) != member:
return False
self.holder[book] = None
self.notifier.send("hold-cancelled", member, book)
return TrueSuppose an earlier test also checked that "the audit helper was never called". It fails after this harmless refactor, although every outcome and recorded effect is identical. It would still pass a desk that keeps the old call pattern but never frees the book. Assert the outcomes and the recorded effects. Do not assert private fields, helper-call counts, or the exact internal method that produced an effect. Code outside the class cannot call a Java private helper directly. Python's single leading underscore is only a naming convention, so there the habit has to come from the test author.
Two correct desks, two internal tallies
Read the steps as text
- Two desks cancel the same hold. One also calls a private audit helper first, which the contract never mentions.
- PlainDesk.cancel(Mira, Dune) returns True. Every judged row passes. The plain desk gives True and one cancellation.
- AuditedDesk.cancel(Mira, Dune) returns True. Failing: internal. Same outcomes, same effects. Only the helper tally differs.
- The tally differs while outcomes and effects agree, so the effects are the contract.
The recorder is a plain class, not a framework feature, and nothing here requires a mocking library.
Recap
- A history is a sequence of public calls, and a check compares each call's outcome and the recorder's contents with the contract.
- A required effect names its kind, member, book, count and order, and a refusal requires no effect.
- Stale state and duplicate effects can pass every single-call check. Ravi was refused because the cancel left the book marked held, and only a history that cancels and then reserves again shows it.
- Assert outcomes and recorded effects, never helper-call counts or private fields.
Go deeper
Liskov and Wing (1994) specify a type with pre- and postconditions, an invariant, and a history constraint on how an object's state may change over time, and they require a subtype's histories, as seen through the supertype's operations, to satisfy the supertype's constraint. This lesson's recorder test is a much smaller, practical check and is not their notion. Meyer's design by contract (1992) treats a violated precondition as the caller's fault, so the supplier owes nothing. This desk differs: it is total, because it defines its refusal as a False result with a postcondition of no state change and no effect.
This lesson uses the plain phrase "interaction test" operationally: a sequence of public calls whose recorded collaborator effects are compared with the contract. Two lists are equal only when they have the same length and the same elements in the same positions.
The desk's rules are the same kind of promise as a subtype's behaviour contract. Practise reading state that depends on earlier calls in Stack Fundamentals, where a push or pop only makes sense after the calls before it.