Object contract tests: check the promise, not the fields
Turn one library borrowing promise into success, rejection, boundary and invariant checks, and see why green runs mislead and tests avoid private fields. Behavioural contracts and testing · Object-oriented programming.
A library desk has one rule: a member may hold two loans. One afternoon a tidy-up lets a third loan through, and every check stays green. Next week another tidy-up changes nothing a patron can see, yet a check turns red. What should a check look at?
A contract is a promise a caller can rely on
Think of the desk rule as a promise. A caller hands over a request, and the promise says what comes back, what changes and what always stays true. In code, that promise is the contract of one public operation. Here it is for borrow on a Member:
- When the member holds fewer than two loans and the book is available,
borrowreturnstrue, records one new loan and marks the book as out. - Otherwise it returns
falseand changes nothing. activeLoans()is always between 0 and 2.
Refusal here is an ordinary false result, the first kind of refusal from exception contracts. Only public things appear in the promise: the returned value, activeLoans() and the book's isAvailable(). We call these the public observations. Anything internal, such as how the member counts loans inside, is not part of the promise. That split comes from encapsulation.
One test asks one question
A contract test has three moves. Arrange: put a valid object into a known starting state. Act: call exactly one operation. Compare: set what a caller can see against what the contract promised. One comparison is called an assertion.
Arrange, act, compare
Read the steps as text
- Mira has no loans and three books are available. The contract is written before any call is made.
- StandardMember.borrow(Mira, Dune) returns true. Compared 3 rows: 3 pass. Success is checked on the result, the loan count and the book.
- StandardMember.borrow(Mira, Ravi) returns true. Compared 1 row: 1 pass. The second request is still inside the limit, so it is accepted.
- StandardMember.borrow(Mira, Atlas) returns false. Every judged row passes. The third request is the boundary and a rejection, so state is checked too.
- Every assertion ran against the promise and every one passed.
Watch where each expected value comes from. It is read off the contract, before the actual value arrives, and never copied from the code under test. A test that copies the code's behavior can only agree with it.
The small helper check below throws an error when a condition is false. The last section explains why we write it ourselves.
class Book {
private boolean available = true;
boolean isAvailable() { return available; }
void markOut() { available = false; }
}
class Member {
int count = 0; // internal detail; package-private here, so a test in
// the same package could read it: the temptation
boolean borrow(Book b) {
if (count >= 2 || !b.isAvailable()) return false;
count++;
b.markOut();
return true;
}
int activeLoans() { return count; }
}
class Main {
static void check(boolean ok, String what) {
if (!ok) throw new AssertionError(what);
}
public static void main(String[] args) {
// arrange, act, compare
Member mira = new Member();
Book dune = new Book();
check(mira.borrow(dune), "returns true");
check(mira.activeLoans() == 1, "one loan");
check(!dune.isAvailable(), "Dune is out");
}
}class Book:
def __init__(self):
self._available = True
def is_available(self):
return self._available
def mark_out(self):
self._available = False
class Member:
def __init__(self):
self._count = 0 # private detail: how loans are counted
def borrow(self, book):
if self._count >= 2 or not book.is_available():
return False
self._count += 1
book.mark_out()
return True
def active_loans(self):
return self._count
def check(ok, what):
if not ok:
raise AssertionError(what)
# arrange, act, compare
mira = Member()
dune = Book()
check(mira.borrow(dune) is True, "returns true")
check(mira.active_loans() == 1, "one loan")
check(not dune.is_available(), "Dune is out")Python spells the observations active_loans() and is_available(); the contract is the same. All three checks pass, and the member holds exactly one loan.
Four questions every contract answers
A short contract still hides several checks. Four questions find them.
- Success: does a valid request give the promised result and change?
- Rejection: when the contract says no, does the call return
falseand leave everything unchanged? The book may be out already, or the limit may be reached. - Boundary: where does the outcome flip? Here the second loan is accepted and the third is refused, so the third request is both the boundary and a rejection.
- Invariant: after every call, is
activeLoans()still between 0 and 2?
A boundary is the point where the contract's outcome changes, not an extreme of the number type. Testing the largest integer tells you nothing about a rule that flips at two.
First, a rejection away from the boundary: the book is already out, and the member holds nothing. The check reads the state as well as the returned value.
// inside main
Member mira = new Member();
Book dune = new Book();
dune.markOut();
check(!mira.borrow(dune), "unavailable book refused");
check(mira.activeLoans() == 0, "no loan recorded");mira = Member()
dune = Book()
dune.mark_out()
check(mira.borrow(dune) is False, "unavailable book refused")
check(mira.active_loans() == 0, "no loan recorded")Now the boundary pair that matters. The third request is a rejection at the boundary, so it gets the rejection checks too.
// inside main
Member mira = new Member();
Book dune = new Book(), ravi = new Book(), atlas = new Book();
mira.borrow(dune);
check(mira.borrow(ravi), "second loan accepted");
boolean third = mira.borrow(atlas);
check(!third, "third loan refused");
check(mira.activeLoans() == 2, "still two loans");
check(atlas.isAvailable(), "Atlas untouched");mira = Member()
dune, ravi, atlas = Book(), Book(), Book()
mira.borrow(dune)
check(mira.borrow(ravi) is True, "second loan accepted")
third = mira.borrow(atlas)
check(third is False, "third loan refused")
check(mira.active_loans() == 2, "still two loans")
check(atlas.is_available(), "Atlas untouched")A green run proves only what it asked
A test run can show a defect only through an assertion that exists. Suppose a clerk changes the limit check from count >= 2 to count > 2. A suite that checks only one loan, or only a refusal because the book is already out, still passes the limit bug.
Two defects, two assertions
Read the steps as text
- Two defective members face the same checks. One lets a third loan through, and one counts the loan before it refuses.
- OffByOneMember.borrow(Mira, Dune) returns true. OffByOneMember.borrow(Mira, Ravi) returns true. OffByOneMember.borrow(Mira, Atlas) returns true. Failing: boundary, rejection, invariant. Success stays green; the third request fails.
- CountFirstMember.borrow(Mira, Atlas) returns false. Failing: rejection, invariant. The refusal value is right, but the loan count moved, so the state row fails.
- A check on the returned value alone would have passed the second defect.
In the scene, the first defective version accepts a third loan. The success rows stay green, and the rows for the third request turn red: boundary, rejection and invariant.
The second defect is quieter. It counts the loan first and checks the limit afterwards, so the third request returns the correct false. A test that reads only the returned value passes. Reading activeLoans() shows 3, and the book is still available, so the state half of the rejection clause fails.
boolean borrow(Book b) {
count++; // changes first
if (count > 2 || !b.isAvailable()) return false;
b.markOut();
return true;
}def borrow(self, book):
self._count += 1 # changes first
if self._count > 2 or not book.is_available():
return False
book.mark_out()
return TrueFields are not the contract
Now change the inside of Member and keep every promise. The count becomes a list of loans. Nothing a patron or a caller can see differs.
The inside changes, the promise does not
Read the steps as text
- Mira borrows Dune. Then the member's loan count becomes a list, and the same checks run again.
- StandardMember.borrow(Mira, Dune) returns true. Compared 2 rows: 2 pass. Both the public row and the private read pass while the count exists.
- The inside changes from count to list; no public method changes. Only the private representation changed.
- Failing: representation. The public row still passes, and the private read fails on correct behavior.
- The behavior was correct, and only the test that read the field broke.
Every check on public observations still passes. A test that reaches into the field fails, even though the new code is correct.
// version 2: the count becomes a list
private final java.util.List<Book> loans = new java.util.ArrayList<>();
// fragile test line: check(mira.count == 2, "two loans"); // no field named count
// stable test line: check(mira.activeLoans() == 2, "two loans");# version 2: the count becomes a list
self._loans = []
# fragile test line: check(mira._count == 2, "two loans") # AttributeError
# stable test line: check(mira.active_loans() == 2, "two loans")In Java, the old line no longer compiles. In Python it raises AttributeError. Either way the test measured the representation, which the contract never promised. A test of a deliberately exposed helper is a different question, and we leave it out here.
Two facts about the check itself
Java has an assert statement, but it is disabled unless the program runs with -ea. When disabled, the condition is not evaluated at all. Python's assert is removed when the interpreter runs with -O. A suite built on either can report success without checking anything, so the examples here use a check that throws or raises on every run.
assert mira.activeLoans() == 99 : "wrong count"; // silent without -ea
// java Main -> no error
// java -ea Main -> AssertionError: wrong countassert mira.active_loans() == 99, "wrong count" # removed by -O
# python3 main.py -> AssertionError: wrong count
# python3 -O main.py -> no errorRecap
- A contract test arranges a valid object, calls one operation and compares public observations with what the contract promised.
- Four questions find the checks: success, rejection with unchanged state, the boundary where the outcome flips, and the invariant.
- A green run proves only the assertions that ran, so a refusal check reads state as well as the returned value.
- Test through public observations, so a correct change to the inside keeps the tests green.
Go deeper
A contract can say that a refusal raises an exception instead of returning false. Then the test asserts the named exception and the unchanged state. Expecting an exception where the contract promises false is a mismatch between test and contract, and the test is the one to change.
This lesson does not cover test frameworks, test doubles, parameterized tests or coverage numbers. They organize and run assertions; they do not change which assertions follow from a contract.
Before this, the subtype behavior contracts lesson asked whether a substitute keeps a caller's promise. Next, object interaction tests follows a sequence of calls and the effects they must produce. Practise promise-checking in Stack Fundamentals, where every push and pop must leave the structure valid.