Module 4: Finding Properties The Patterns
2. Where properties come from
Description
In the previous lesson you got to know the catalog of five patterns. A catalog is useful, but only if you know how to consult it in the face of a concrete function. This lesson gives you the exact technique: five questions you ask any function so its properties come to light. They are not just any five questions —each one points directly to one of the five patterns—, so answering them is, literally, going through the catalog without having to remember it by heart.
The problem this lesson solves is the most common and most frustrating of property-based: the blank mind. You sit down in front of a function, you know all the mechanics of Hypothesis, and nothing occurs to you to assert. The reason is that you are waiting for the property to "occur to you," like an inspiration, when it is actually manufactured with a method. The method is interrogating the function. The five questions —what does NOT change?, is there a simpler way to compute the same?, what happens if I do it twice?, what happens if I undo it?, how does the output change if I move the input?— are a questionnaire that, applied with discipline to any pure function, almost always uncovers at least one property and often several. By the end, instead of waiting for inspiration, you are going to interrogate the function.
Connection with the module: this is the technique lesson, the bridge between the map (lesson 1) and the five pattern lessons that follow. Here we don't go deep into any pattern —that is lessons 3 to 7—; here we learn to trigger each one with its question. Think of this lesson as the module's interactive index: each question you ask is going to leave you at the door of one of the following lessons. When in lesson 6 we see the metamorphic in depth, you will already have triggered it here with the question "how does the output change if I move the input?".
An analogy: the doctor who doesn't guess, asks
Think of a good doctor in front of a patient with a diffuse pain. The bad doctor tries to guess the diagnosis: they look at the patient, have a hunch and marry it. The good doctor doesn't guess; they interrogate. They have a battery of standard questions they always ask, in order, because they know each one rules out or confirms a family of causes: where exactly does it hurt?, since when?, what makes it worse?, what relieves it?, has it happened before? No question is brilliant on its own; the brilliance is in having the questionnaire and applying it without skipping steps. By the end of the questions, the diagnosis almost drew itself.
Finding properties works the same. The beginner tries to guess the property —looks at the function, waits for a hunch, and if it doesn't come, gets frustrated. Whoever has the craft interrogates the function with a fixed questionnaire. Each of the five questions rules out or confirms a pattern, and by the end of the interrogation you have one or several properties on the table, not because you were brilliant, but because you asked the correct questions in order. The skill is not in the inspiration; it is in having the questionnaire and not skipping it. This lesson is that questionnaire.
The five questions, one per pattern
Here are the five questions. Each one we are going to apply to a Reservo function to see what it uncovers. Read them first straight through, because together they form the complete interrogation.
- What does NOT change, no matter what happens with the input? → triggers the invariant.
- Is there a simpler, slower or more obvious way to compute the same? → triggers the oracle.
- What happens if I apply the operation twice? → triggers the idempotence.
- What happens if I undo the operation? → triggers the round-trip.
- If I move the input in a known direction, how must the output move? → triggers the metamorphic.
That is the whole method. Five questions, five patterns. Nothing more is needed to start in the face of almost any pure function. Now let's see what each one uncovers.
Question 1: What does NOT change? (invariant)
You take the function refund_cents and ask yourself: no matter what happens with the price paid and with the cancellation date, what of the output is always true? The answer jumps out: the refund is never negative (charging the one who cancels doesn't exist) and never exceeds what they paid (you can't give back more than what came in). There you have, without guessing anything, the invariant 0 <= refund <= paid. The question "what doesn't change?" handed it to you.
This question almost always bears fruit because almost every output lives in some range or satisfies some fixed condition. A price? Never negative. A refund? Between zero and what was paid. A sorted list? Same length as the input. A percentage? Between 0 and 100. When you don't know where to start, always start with this one: it has the highest hit rate.
Question 2: Is there a simpler way to compute the same? (oracle)
You take overlaps, which decides whether two ranges step on each other with a_start < b_end and b_start < a_end. You ask yourself: is there another way to compute exactly the same, maybe slower or more obvious? Yes: two ranges overlap if the last of the two starts falls before the first of the two ends, that is max(a_start, b_start) < min(a_end, b_end). It is a different formula, equally correct, and now you have a property: the two must always give the same result. The question "is there another way?" gave you an oracle to compare against.
This question applies when a second implementation exists: an obvious alternative formula, an old version you want to replace, a standard-library function that does the same, or a slow but bulletproof implementation. If you have it, the property writes itself: "the fast one matches the slow one for every input."
Question 3: What happens if I do it twice? (idempotence)
You take an operation like trimming an amount to the valid range —clamp(cents, paid) = min(max(cents, 0), paid)— and ask yourself: what happens if I apply it twice in a row? If I trim a value to the range [0, paid] and trim the result again, nothing changes: it was already within the range. There you have the property clamp(clamp(x)) == clamp(x). The question "and if I do it twice?" uncovered the idempotence.
This question applies to operations that "settle" a state or normalize a value: trim, round, sort, mark as cancelled. If applying the operation a second time shouldn't change the result, you have an idempotence. Beware: not all operations are idempotent —adding 1 twice is not the same as adding 1 once—, and the question also serves to discover that an operation is NOT idempotent, which is valuable information.
Question 4: What happens if I undo it? (round-trip)
You take book and ask yourself: is there an operation that undoes this? Yes, cancel. So the property is a there-and-back: if I book a room and then cancel that booking, the calendar should end up as if I had never booked —without that confirmed booking. The question "what happens if I undo it?" gave you the round-trip.
This question applies to any pair of inverse operations: book/cancel, encode/decode, save/read, serialize/deserialize, compress/decompress. When an operation has an "undo," the round-trip is almost always a very strong property with very little code, because it catches any information that is lost or corrupted on the there-and-back trip.
Question 5: If I move the input, how does the output move? (metamorphic)
You take price_cents and ask yourself: I don't know the exact price for each room and each hour, but if I move the input in a known way, how must the output move? Two answers jump out. If I change the member from basic to pro (same room, same hours), the price can't go up —the discount never makes it more expensive—: price_cents(pro) <= price_cents(basic). And if I increase the hours, the price can't go down: more hours ⇒ price ≥. The question "how does the output move if I move the input?" gave you two metamorphics, and neither needs to know a single exact value.
This is the most powerful question when the exact value is hard to compute by hand but the direction of the change is obvious. You don't know how much exactly a 17-hour booking costs in a 3300-cent room with a pro discount —you would have to do the math—, but you know it costs more than a 16-hour one and less than what a basic would pay. That certainty about the direction is a property, even if the value is unknown to you.
Worked example: interrogating refund_cents completely
Let's apply the complete interrogation to a single function, refund_cents, to see how a simple method uncovers several properties where before you saw one. We ask it the five questions:
- What doesn't change? The refund is always in
[0, paid]. → invariant. - Is there another way to compute it? Not a simpler one worth it here. → no obvious oracle.
- And if I do it twice?
refund_centsdoesn't mutate anything nor "re-apply"; it doesn't fit. → no idempotence. - And if I undo it? There is no inverse of "compute the refund." → no round-trip.
- How does the output move if I move the input? If I cancel earlier (more lead time), the refund can't be less. → monotonicity metamorphic.
Two of the five questions bore fruit: an invariant and a metamorphic. The other three were cleanly ruled out, and ruling out is also progress —it tells you that for this function those molds don't apply, and you stop wasting time on them. Let's run the metamorphic that the fifth question uncovered, because it is the least obvious and the most instructive. It asserts that cancelling with more lead time never refunds less:
# test_monotonic.py — the metamorphic that question 5 uncovered
from datetime import datetime, timedelta
from hypothesis import given, strategies as st
from reservo import Booking, refund_cents
START = datetime(2026, 3, 10, 12, 0)
BK = Booking("bk-1", "r-focus", "m-1", START,
START + timedelta(hours=2), "confirmed", 6000)
@given(paid=st.integers(min_value=0, max_value=1_000_000),
t1=st.integers(min_value=0, max_value=200),
delta=st.integers(min_value=0, max_value=200))
def test_earlier_cancellation_never_refunds_less(paid, t1, delta):
# now_early is 'delta' hours BEFORE now_late -> more lead time
now_late = START - timedelta(hours=t1)
now_early = now_late - timedelta(hours=delta)
assert refund_cents(BK, paid, now_early) >= refund_cents(BK, paid, now_late)
Note the structure: we generate any instant now_late, and build now_early by subtracting delta more hours from it, so that now_early is always earlier than or equal to now_late. We don't fix the dates by hand; we let Hypothesis move t1 and delta across the whole space, and assert the relationship. No concrete refund value appears: only the order early >= late.
What to expect. You save the file, run python3 -m pytest test_monotonic.py -v, and see the green dot —a hundred pairs of instants tested, and in all of them the earlier refund was greater than or equal to the later:
$ python3 -m pytest test_monotonic.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/m4work
plugins: hypothesis-6.161.2
collected 1 item
test_monotonic.py::test_earlier_cancellation_never_refunds_less PASSED [100%]
============================== 1 passed in 0.14s ===============================
Green. A hundred pairs of dates, and in none of them did cancelling earlier refund less than cancelling later. And what matters for this lesson is not the result —it is where the property came from: it didn't occur to me by inspiration, the fifth question of the questionnaire uncovered it. That is the method I want you to take with you. Don't wait for the hunch; interrogate the function.
Deep dive: ruling out is also finding
There is a misunderstanding worth clearing up. When you apply the five questions and three of them don't bear fruit —as in refund_cents—, it is easy to feel that you "failed" in three fifths of the attempt. It is not so. Ruling out a pattern is a result, not a failure.
Knowing that refund_cents has no round-trip (there is no way to "undo a refund computation") nor idempotence (it doesn't mutate anything that can be re-applied) is precise information that saves you time: you are not going to force an artificial property from those molds, you are not going to write a confusing test that actually proves nothing. The questionnaire doesn't promise that the five questions bear fruit in every function; it promises that going through them all leaves you with the certainty of having explored the complete space of patterns, instead of marrying the first one that occurred to you.
Compare two ways of working. The first: you look at refund_cents, the range invariant occurs to you, you write it and move on. You found a property, but you never knew there was a metamorphic waiting. The second: you apply the five questions, rule out three with confidence and keep two properties —the invariant and the monotonicity. The second function ends up much better covered, and not because you were smarter, but because you didn't skip the interrogation. The discipline of going through the five questions, even the ones you know are going to fail, is what separates a superficial property-based suite from a thorough one.
A practical note on order. The five questions don't have to be asked in a fixed order, but it is worth starting with "what doesn't change?" (invariant) because it has the highest hit rate: very rare is the pure function without some range or sign invariant. After that, let the function's shape guide you: if you see a pair of inverse operations, jump to question 4; if you see an operation that normalizes, to 3; if the exact value is hard but the direction obvious, to 5. Over time you will stop going through them consciously and do almost all of them at a glance, just as the experienced doctor does half the interrogation with a single look.
Common mistakes
Waiting for inspiration instead of interrogating. What happens: someone stares at the function waiting for the property to "appear," and since it doesn't appear, concludes that properties "aren't their thing." Why it happens: we believe finding properties is a mystical talent, not a method. How to detect it: if your strategy in the face of a new function is "think until something occurs to me," you are guessing, not interrogating. How to fix it: keep the five questions written next to you and apply them one by one. The property doesn't come by inspiration; the correct question uncovers it.
Giving up after the first question that bears fruit. What happens: question 1 uncovers the invariant, the person writes it and considers the function done, without asking the other four. Why it happens: finding one property feels like having solved the problem. How to detect it: if you never find yourself ruling out patterns, you are not going through the whole questionnaire. How to fix it: always ask the five questions, even after finding the first property. refund_cents would have ended up half-covered if you stop at the invariant and don't get to the monotonicity metamorphic.
Forcing a pattern that doesn't apply. What happens: someone insists on finding a round-trip for a function that has no inverse, and ends up writing a convoluted test that doesn't prove what they think. Why it happens: if a pattern worked wonderfully in another function, one wants to apply it in all of them. How to detect it: if your property needs three pirouettes to "fit" the pattern, the pattern probably doesn't apply. How to fix it: accept the ruling-out. That a question doesn't bear fruit in a function is normal and expected; no function has all five patterns. Force nothing; go through the five and keep only the ones that fit naturally.
Exercises
Exercise 1
Apply the complete five-question questionnaire to the function price_cents(room, member, hours). For each question, say whether it bears fruit and, if it does, state the property (without programming it).
View solution
- What doesn't change? (invariant): the price is never negative,
price_cents >= 0. Bears fruit. - Is there another way to compute it? (oracle): you could compute the pro discount with another formula (for example,
subtotal * 80 // 100instead ofsubtotal - subtotal * 20 // 100) and compare. Bears fruit if you have that second formula at hand; it is a weak but legitimate oracle. - And if I do it twice? (idempotence):
price_centsdoesn't mutate nor re-apply over its output; it doesn't fit. Ruled out. - And if I undo it? (round-trip): there is no inverse of "compute a price." Ruled out.
- How does the output move if I move the input? (metamorphic): two properties. Changing
basictoprodoesn't raise the price (pro <= basic); increasing the hours doesn't lower the price (more hours ⇒ price ≥). Bears fruit, and double.
Result of the interrogation: an invariant, a possible oracle and two metamorphics. Three of the five questions uncovered something. As with refund_cents, a single function hides several properties, and the questionnaire brings them all out.
Exercise 2
A teammate says: "I asked question 1 to is_available and no invariant occurred to me, so this function has no properties." Is that conclusion correct? Apply at least one of the other four questions and show that they are wrong.
View solution
They are wrong: giving up after a question that didn't bear fruit is precisely the "giving up after the first question" error. is_available returns a boolean, so the range invariant doesn't say much (it is always True or False), but that doesn't mean the function has no properties. It is enough to keep following the questionnaire.
- Question 2 (oracle): is there another way to compute availability? Yes: instead of going through the bookings with
overlaps, you can mark each minute (or each hour) that some confirmed booking occupies and check whether the requested range touches any. It is a slower and more obvious computation, a perfect oracle. The property:is_availablematches that occupancy version for every calendar and every range. You will see it run in lesson 5 and in the mini-project.
With a single additional question, is_available went from "has no properties" to having a solid oracle. The lesson: when a question doesn't bear fruit, don't conclude anything about the function; ask the other four.
Exercise 3
Choose a function you use often outside Reservo —sorted(list), str.upper(), json.dumps/json.loads, abs(x), whatever you want— and apply the five-question questionnaire to it. State (without programming) all the properties you uncover, indicating which question triggered each one.
View solution
An example with sorted(list):
- What doesn't change? (invariant): the returned list has the same length as the input one; and it is sorted (each element ≤ the next). Two invariants.
- Is there another way to compute it? (oracle): a slow and naive version (a hand-written bubble sort, or repeated
min()) must give the same result assorted. A classic oracle. - And if I do it twice? (idempotence):
sorted(sorted(list)) == sorted(list); sorting something already sorted doesn't change it. Idempotence. - And if I undo it? (round-trip): sorting is not reversible (you lose the original order), so there is no direct round-trip. Ruled out —unless you compare with the input as a multiset, which is more of a permutation invariant.
- How does the output move if I move the input? (metamorphic): adding an element to the list leaves the output with that element inserted in its place and everything else the same; sorting the concatenated list
a + bgives a permutation of sortingaandbseparately and merging. Metamorphics.
With json.dumps/json.loads the star pattern is the round-trip: json.loads(json.dumps(obj)) == obj for every serializable obj —question 4 uncovers it immediately. With abs(x) the invariant abs(x) >= 0 (question 1) and the idempotence abs(abs(x)) == abs(x) (question 3). Note that each function lights up different questions: the questionnaire is the same, but each function answers in its own way.
Summary and next step
In this lesson you swapped "waiting for a property to occur to me" for "interrogating the function with a questionnaire." The five questions —what doesn't change? (invariant), is there another way to compute it? (oracle), and if I do it twice? (idempotence), and if I undo it? (round-trip), how does the output move if I move the input? (metamorphic)— are the method that uncovers properties without depending on inspiration. Each question points to a pattern, so answering them is going through the catalog.
You applied it to refund_cents: two questions bore fruit (range invariant and monotonicity metamorphic) and three were cleanly ruled out. You ran the monotonicity metamorphic, the least obvious, and saw that it came from the method, not from a hunch. And you understood that ruling out a pattern is a result, not a failure: the value of the questionnaire is in going through it whole, even the questions you know are going to fail, because that way you make sure you don't leave hidden properties.
Before moving on you should be able to: recite the five questions and the pattern each triggers; apply the questionnaire to a new function and state the properties it uncovers; and explain why ruling out three of five questions is not failing.
With the method in hand, we now enter each pattern separately and in depth. We start with the one with the highest hit rate, the one question 1 triggers: the invariant. In lesson 3 you will see it run over refund_cents and price_cents, with its green version and with the falsifying example Hypothesis reports when we put in the uncapped-bonus bug. Let's continue.
Resources
- What you can generate and how — Hypothesis — the catalog of strategies. When interrogating a function, the question "what valid inputs does it have?" is answered with these strategies; having them in mind helps to formulate the properties.
- Metamorphic testing (reference article, Wikipedia) — the foundation of question 5: how to test when you don't know the exact value of the output but do know how it must change. We develop it in lesson 6.
- Stateless Properties — PropEr Testing (Fred Hébert) — the classic chapter that organizes the strategies for finding properties: modeling (oracle), generalizing example-based tests, invariants and symmetric properties (round-trip). Almost the same catalog as this lesson, in another language. Highly recommended reading to reinforce the method.