Contracts Propagate Requirements

CrossHair and Such

30 Jul 2021

Contracts and property-based testing have a lot of overlap: they both can be used to check arbitrary behaviors of your code. But contracts propagate, and that has big implications.

I was very happy to share a EuroPython talk with Marko Ristin-Kaufmann and Lauren De bruyn today. We introduced the concept of code contracts and two tools that can be used to check contracts, icontract-hypothesis and CrossHair.

One obvious difference between contracts and property-based tests: only contracts can be run in staging/production.

But I emphasized another important difference:

Unlike proprety-based tests, contract requirements propagate through your codebase.

I’ll explain with the example I used in my talk. Imagine we’re building an online shopping site, and need a function to compute the total price for an order:

class LineItem:
    item_id: str
    quantity: int

def compute_total(items: List[LineItem], prices: Dict[str, float]) -> float:
    total = 0.0
    for item in items:
        total += prices[item.item_id] * item.quantity
    return total

One might imagine that we want every order total to be greater than zero. It’s easy enough to make a postcondition for that (in the icontract syntax):

@ensure(lambda result: result > 0)
def compute_total(items: List[LineItem], prices: Dict[str, float]) -> float:
    ...

CrossHair quickly points out several ways to break this postcondition.

We might dutifully handle each of these cases. There are 4 of them!:

# There is at least one item:
@require(lambda items: len(items) > 0)

# Each quantity is at least one:
@require(lambda items: all(i.quantity > 0 for i in items))

# Every item has a price:
@require(lambda items, prices: all(i.item_id in prices for i in items))

# Every price is greater than zero:
@require(lambda prices: all(p > 0 for p in prices.values()))

After we add all these preconditions, CrossHair is satisfied.

This is an awful lot of work, and we didn’t find a single bug in our function. Was it worth it?

If this were a property-based test, probably not.

But as a contract, these new preconditions propagate to callers. And, via more contracts, to the callers of callers. This kind of propagation is very similar to what happens when you change a type: the change ripples throughout the system, causing you to notice all the other places that need to be changed.

Zooming out to a wider scale, each of the preconditions imply requirements for other parts of the system. Let’s look at each of these preconditions again, this time while “zoomed out”:

 

@require(lambda items: len(items) > 0)

You cannot check out if your cart is empty!

 

@require(lambda items: all(i.quantity > 0 for i in items))

Setting a quantity to zero should remove the entire line item!

 

@require(lambda items, prices: all(i.item_id in prices for i in items))

You can’t add something to a cart that doesn’t have a price!

 

@require(lambda prices: all(p > 0 for p in prices.values()))

When parsing prices from a feed or table, verify the prices are nonzero!

 

All of these requirements are important and could be easy to forget. Contracts, along with tooling like icontract-hypothesis and CrossHair, help us discover them.

Sometimes, we don’t want to propagate requirements, and that’s fine too. This comes back to a point that Marko made today - all these testing approaches (unit tests, property-based tests, contract) are complementary.

UPDATE 2021-10-12: The talk recording has been released! You can jump to the CrossHair part here!