Module 4: Finding Properties The Patterns
6. The metamorphic pattern
Description
The three previous patterns needed something known to compare against: the invariant compares the output against a fixed band, the round-trip against the starting point, the oracle against another implementation. But what do you do when you don't have any of the three? When the exact value is hard to compute by hand, there is no inverse and you don't have a reliable second implementation. That is the terrain of the metamorphic pattern, and it is at once the most subtle and the one that finds the most logic bugs.
The idea is this: even if you don't know how much the output is worth for a given input, often you know with certainty how the output should change if you move the input in a controlled way. You don't know how much exactly a 17-hour booking with a pro discount costs in a 3300-cent room —you would have to do the math—, but you know, without a doubt, that it costs more than a 16-hour one and less than what a basic member would pay for the same. Those certainties about the direction of the change are properties, and they don't need a single exact value. The pattern triggers question 5 of the questionnaire: "if I move the input this way, how must the output move?". By the end of this lesson you will know how to formulate metamorphic relationships, write them with Hypothesis over price_cents and refund_cents, and understand why they find bugs the other patterns let through.
Connection with the module: this is the fourth pattern lesson, and it closes the group of those that "compare the output against something." The metamorphic already appeared twice: in lesson 1, when you saw pro <= basic among the five patterns, and in lesson 2, when question 5 uncovered the monotonicity of refund_cents (cancelling earlier never refunds less). Here we develop it in depth. It is also the canonical property of price_cents according to the guide's design, so it is the natural home of this function. The next lesson, idempotence, closes the catalog with the most specific pattern.
An analogy: the scale you don't know but trust
Imagine you step on an old digital scale, the kind you don't know whether it is well calibrated. It reads 71.4 kg. Is it correct? You have no idea —maybe it is miscalibrated and you actually weigh 70 or 73. You can't verify the absolute value without a reference weight.
But there is something you can verify without knowing your real weight. If you step on it holding a backpack of books, the scale must read more than without the backpack. You don't know how much more exactly (it depends on the books), but you know the direction: adding weight can never make the scale read less. If you step on with the backpack and it reads less than without it, the scale is broken, and you know it with absolute certainty, even though you never knew your true weight. In the same way: if two people weigh themselves together, the scale must read the sum of their individual weights, more or less. Another relationship you can check without a reference weight.
That is metamorphic testing. You don't verify the output value (your weight, which you don't know); you verify the relationship between outputs when you change the input in a known way (adding the backpack must raise the reading). The word "metamorphic" comes from metamorphosis: you transform the input in a controlled way and assert how the output transforms. It is the tool for testing functions whose exact value you can't predict but whose behavior under changes you do know. And it turns out almost every business function —prices, refunds, rankings, recommendations— has obvious metamorphic relationships, even if its exact value is a tangle.
Worked example: price_cents and its two metamorphics
price_cents(room, member, hours) computes the price of a booking: hourly_cents * hours, minus 20% if the member is pro. Its exact value depends on three things and, for a room with an odd price and many hours with a discount, you don't know it by heart. But it has two metamorphic relationships you know with total certainty.
First: changing from basic to pro never raises the price. For the same room and the same hours, a pro member pays less than or equal to a basic —the discount never makes it more expensive. It doesn't matter what the exact price is; pro <= basic always.
Second: more hours never lowers the price. For the same room and the same member, increasing the hours can't reduce the price —each extra hour costs zero or more, never negative. Again, without knowing the value: more hours ⇒ price ≥.
# test_metamorphic.py — the two metamorphics of price_cents
from hypothesis import given, strategies as st
from reservo import Room, Member, price_cents
rooms = st.builds(
Room,
id=st.just("r-focus"), name=st.just("Focus"),
capacity=st.integers(min_value=1, max_value=20),
hourly_cents=st.integers(min_value=0, max_value=1_000_000),
)
BASIC = Member(id="m-1", name="Ana", tier="basic")
PRO = Member(id="m-2", name="Beto", tier="pro")
@given(room=rooms, hours=st.integers(min_value=0, max_value=24))
def test_pro_never_pays_more_than_basic(room, hours):
# Metamorphic relationship: changing basic -> pro never RAISES the price.
assert price_cents(room, PRO, hours) <= price_cents(room, BASIC, hours)
@given(
room=rooms,
hours=st.integers(min_value=0, max_value=24),
extra=st.integers(min_value=0, max_value=24),
member=st.sampled_from([BASIC, PRO]),
)
def test_more_hours_never_lowers_the_price(room, hours, extra, member):
# Metamorphic relationship: adding hours never LOWERS the price.
assert price_cents(room, member, hours + extra) >= price_cents(room, member, hours)
Pause on the structure, because it is different from the previous patterns. In a metamorphic, you call the function twice —once with the original input, once with the transformed input— and compare the two outputs. In the first property, the two calls share room and hours but change the member (PRO vs BASIC); we assert pro <= basic. In the second, the two calls share room and member but one has hours and the other hours + extra (with extra >= 0); we assert that the one with more hours is greater than or equal. Neither of the two properties mentions an output value: only the relationship between two outputs.
Note also a trick of the second: instead of generating two independent hour counts and comparing them, we generate hours and an extra >= 0, and compare hours + extra against hours. That way we guarantee by construction that the second input has more or equal hours than the first, without having to filter the cases where one is greater. It is the same idea we used in lesson 2 for the monotonicity of refund_cents (building now_early by subtracting from now_late): manufacture the relationship in the input instead of discarding the cases that don't satisfy it.
What to expect. You save the file, run python3 -m pytest test_metamorphic.py -v, and see two green dots —two hundred cases in total, and in none of them did the pro pay too much or the extra hours lower the price:
$ python3 -m pytest test_metamorphic.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- /private/tmp/m4work/.venv/bin/python
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /private/tmp/m4work
plugins: hypothesis-6.161.2
collecting ... collected 2 items
test_metamorphic.py::test_pro_never_pays_more_than_basic PASSED [ 50%]
test_metamorphic.py::test_more_hours_never_lowers_the_price PASSED [100%]
============================== 2 passed in 0.17s ===============================
Two metamorphics green. And the remarkable thing: we didn't write a single == 6000 or == 7500. We proved that the discount behaves well —it never makes it more expensive— and that the price scales with the hours —it never decreases—, all without knowing a single exact price. That is the magic of the pattern: it tests the logic of the function (how it responds to changes) instead of its values (how much it gives for each input).
The metamorphic catching a bug: the premium surcharge
The acid test. Imagine someone, by mistake, turns the "pro discount" into a "premium surcharge": instead of subtracting from the pro, they add 100 cents as if the pro tier were a luxury service paid separately. It is a plausible sign bug —confusing discount with surcharge.
def price_cents_buggy(room, member, hours):
subtotal = room.hourly_cents * hours
if member.tier == "pro":
subtotal += 100 # BUG: 'premium charge' for pro, instead of discount
return subtotal
We run the first metamorphic (pro <= basic) against this version. Now the pro pays more than the basic, so the relationship should break.
What to expect. Red, with the falsifying example reduced to the simplest imaginable case:
$ python3 -m pytest test_metamorphic_buggy.py
=================================== FAILURES ===================================
_____________________ test_pro_never_pays_more_than_basic ______________________
room = Room(id='r-focus', name='Focus', capacity=1, hourly_cents=0), hours = 0
@given(room=rooms, hours=st.integers(min_value=0, max_value=24))
def test_pro_never_pays_more_than_basic(room, hours):
> assert price_cents_buggy(room, PRO, hours) <= price_cents_buggy(room, BASIC, hours)
E AssertionError: assert 100 <= 0
E + where 100 = price_cents_buggy(Room(id='r-focus', name='Focus', capacity=1, hourly_cents=0), Member(id='m-2', name='Beto', tier='pro'), 0)
E + and 0 = price_cents_buggy(Room(id='r-focus', name='Focus', capacity=1, hourly_cents=0), Member(id='m-1', name='Ana', tier='basic'), 0)
E Failing test case: test_pro_never_pays_more_than_basic(
E room=Room(id='r-focus', name='Focus', capacity=1, hourly_cents=0),
E hours=0,
E )
Read it. Hypothesis reduced the failure to the cleanest possible case: a room with a price per hour of zero and zero hours. With that input, a basic pays 0 (nothing per hour, no hours) and a pro pays 100 (the 0 subtotal plus the erroneous premium surcharge). The relationship pro <= basic becomes 100 <= 0, which is false. The minimal case exposes the bug cruelly: even when there is nothing to charge (free room, zero hours), the pro pays 100 too much. That makes it evident that the problem is not in the subtotal computation or the hours, but in that += 100 that should never have existed.
Note something important: the sign invariant (price >= 0) we saw in lesson 3 would not have caught this bug. price_cents_buggy never returns a negative —adding 100 to something non-negative gives something non-negative—, so the invariant >= 0 would pass green with the broken version. The metamorphic does catch it, because it doesn't look at the sign of an isolated output, it looks at the relationship between the pro's output and the basic's. This is the central argument of the pattern: the metamorphic finds business-logic bugs that range invariants let through, because it tests how the outputs relate to each other, not just where each one falls.
Deep dive: why the metamorphic is so powerful (and how you come up with them)
Metamorphic testing has an interesting history and an underlying reason for its power. It was born precisely to test programs where an oracle doesn't exist —scientific programs, search engines, compilers— because nobody knows the exact correct output. How do you test a search engine if you don't know which "should" be the ten best results for a query? You can't verify the value, but you can verify a relationship: if you add a more specific word to the query, the number of results shouldn't increase. That is a metamorphic relationship, and with it you test the search engine without knowing the "correct" answer. The technical name of those transformations —changing the input in a controlled way and asserting how the output changes— is metamorphic relationships.
The reason for its power is that it attacks the logic directly. A range invariant says "the output falls in [0, X]," which is a weak restriction: many broken functions satisfy it (remember the one that always returns 0). A metamorphic relationship says "if the pro should cost less, then pro <= basic," which encodes a specific business rule. Breaking that rule requires a bug in that exact logic, not a generic overflow. That is why the metamorphic is so good at catching domain-logic errors: each metamorphic relationship is a business rule translated to a comparison between two calls.
How do you come up with metamorphic relationships? There are a handful of "transformation molds" that appear over and over, and it is worth having them at hand:
- Monotonicity: if I increase an input, the output goes up (or down, or doesn't change direction). More hours ⇒ price ≥; cancelling earlier ⇒ refund ≥; more specific query ⇒ fewer results.
- Order between variants: one version of the input always produces an output ordered with respect to another. Pro ≤ basic; premium ≥ standard; with coupon ≤ without coupon.
- Scale: if I multiply the input by something, the output multiplies predictably. Double the hours ⇒ (with a linear price) double the price, or at least ≥.
- Permutation / symmetry: reordering the input doesn't change the output, or changes it in a known way.
overlaps(a, b) == overlaps(b, a)(the order of the two ranges doesn't matter); sorting a list twice gives the same. - Combination: the output of the parts relates to the output of the whole. The price of A plus that of B relates to the price of A and B together.
When you look for a metamorphic, go through these molds asking yourself: is this function monotonic in some input? are there ordered variants (tiers, plans)? does it scale? is it symmetric in some argument? Almost always, at least one of the molds applies and hands you a property without you having to know a single exact value.
A note on overlaps and symmetry, which is a metamorphic we didn't see in the oracle. overlaps(a_start, a_end, b_start, b_end) == overlaps(b_start, b_end, a_start, a_end): swapping the two ranges doesn't change whether they overlap (overlapping is a symmetric relationship). That is a permutation metamorphic, and it complements the oracle of lesson 5: the oracle proves that overlaps gives the correct value; the symmetry proves that it treats the two ranges equally. Two patterns, two angles, the same function better covered.
Common mistakes
Putting an exact value in a metamorphic. What happens: someone tries to assert price_cents(room, PRO, hours) == price_cents(room, BASIC, hours) * 80 // 100 instead of <=. Why it happens: the habit of computing the exact value. How to detect it: if your metamorphic reproduces the function's internal formula (the * 80 // 100), it is no longer a metamorphic, it is a fragile oracle that breaks if you change the implementation. How to fix it: a metamorphic asserts the direction or the order (<=, >=), not the exact value of the relationship. pro <= basic survives any change of the discount percentage; pro == basic * 80 // 100 doesn't.
Comparing outputs of inputs that aren't related in a controlled way. What happens: someone generates two independent hour counts and asserts that the price of one is less than that of the other, and the property fails because sometimes the first is greater. Why it happens: forgetting to build the relationship in the input. How to detect it: if your metamorphic fails "randomly" depending on which input came out greater, you didn't control the transformation. How to fix it: manufacture the relationship in the input —generate hours and extra >= 0, compare hours + extra against hours— instead of generating two loose values and hoping they come out in the correct order.
Believing a metamorphic replaces invariants and examples. What happens: someone finds the metamorphic pro <= basic, writes it, and considers price_cents completely tested. Why it happens: the metamorphic feels very powerful. How to detect it: the metamorphic pro <= basic is satisfied by a function that always returns 0 for everyone (0 <= 0), even though it is broken. How to fix it: combine. The metamorphic proves the relational logic; the sign invariant proves the range; an anchor example (price_cents(Focus, pro, 3) == 6000) pins a known value. The three together corner price_cents; no single one suffices. It is, again, the module's underlying lesson.
Exercises
Exercise 1
Reproduce this lesson's test_metamorphic.py and confirm the two greens. Then introduce the price_cents_buggy (the += 100 premium surcharge one) and run only the first metamorphic (pro <= basic) against it. Without executing: what do you expect the minimal case to be? Then run it and confirm that Hypothesis reduces to hourly_cents=0, hours=0 with 100 <= 0.
View solution
Against the correct price_cents, two greens: pro never pays more than basic, and more hours never lowers the price.
Against price_cents_buggy, red. The minimal case is hourly_cents=0, hours=0: with a free room and zero hours, the basic pays 0 and the pro pays 100 (the subtotal of 0 plus the erroneous premium surcharge), and 100 <= 0 is false. Hypothesis reduces to those values because they are the simplest that expose the problem: the += 100 bug doesn't depend on the price per hour or the hours, so the minimal case puts them at zero. The key observation of the exercise: the sign invariant (price >= 0) would not have caught this bug —100 is non-negative—; only the metamorphic, which compares pro against basic, detects it.
Exercise 2
refund_cents also has a metamorphic, which you already saw in lesson 2: cancelling earlier never refunds less. State another metamorphic of refund_cents, this time about the amount paid instead of the time. Hint: if two members cancel with the same lead time but one paid more than the other, what relationship do their refunds have?
View solution
The metamorphic about the amount: for the same booking and the same now (same lead time), if one member paid more than another, their refund is greater than or equal. Formally, if paid_a >= paid_b, then refund_cents(booking, paid_a, now) >= refund_cents(booking, paid_b, now). It makes sense: the refund is a percentage of what was paid (100%, 50% or 0%), and a fixed percentage of a larger amount is greater than or equal to the same percentage of a smaller amount.
To write it with Hypothesis, you manufacture the relationship in the input as always: you generate paid_b and an extra >= 0, and compare refund_cents(booking, paid_b + extra, now) against refund_cents(booking, paid_b, now), asserting >=. It is the monotonicity in the amount metamorphic, sibling of the monotonicity in time one of lesson 2. That refund_cents has two metamorphics (one in time, one in money) besides its range invariant is, once again, the proof that a function hides several patterns.
Exercise 3
Choose a function you use outside Reservo and state two metamorphic relationships using the deep-dive molds (monotonicity, order between variants, scale, permutation/symmetry, combination). Suggestions: len(list), max(list), a search function that returns results, sorted.
View solution
An example with max(list) (the maximum of a non-empty list):
- Monotonicity (adding): adding an element to the list never lowers the maximum.
max(list + [x]) >= max(list). Monotonicity mold: growing the input doesn't reduce the output. - Permutation: reordering the list doesn't change the maximum.
max(list) == max(shuffled_list). Symmetry mold: the order of the input doesn't matter. - Combination: the maximum of two lists together is the greater of the two maximums.
max(a + b) == max(max(a), max(b)). Combination mold: the output of the whole relates to that of the parts.
An example with a search (a function search(query) that returns a list of results):
- Monotonicity (specificity): making the query more specific (adding a word) never increases the number of results.
len(search(query + " word")) <= len(search(query)). It is the classic metamorphic of search engines, and the historical example that motivated metamorphic testing —testing a search engine without knowing the "correct" results.
Note that none of these properties needs to know the exact value of the output. That is the power of the pattern: it tests the logic of functions whose exact result you couldn't predict by hand.
Summary and next step
In this lesson you debuted the metamorphic pattern: when you don't know the exact value of the output but do know how it must change when you move the input in a controlled way. You triggered it with the "if I move the input this way, how does the output move?" question, applied it to the two metamorphics of price_cents (pro <= basic; more hours ⇒ price ≥) and saw them green without writing a single exact value. Above all, you saw it catch the erroneous premium surcharge —a logic bug the sign invariant let through— because the metamorphic compares the pro's output against the basic's instead of looking at each one in isolation.
You learned why the pattern is so powerful: it attacks the business logic directly, translating each rule ("the pro pays less") to a comparison between two calls. And you got to know the molds that make you come up with them —monotonicity, order between variants, scale, permutation/symmetry, combination— so you never again run out of ideas in the face of a function whose exact value you can't predict. With the symmetry of overlaps you saw that a metamorphic can complement an oracle over the same function.
Before moving on you should be able to: formulate a metamorphic relationship without using exact values; manufacture the relationship in the input (generate hours and extra >= 0); explain why the metamorphic catches bugs the range invariant doesn't; and go through the transformation molds to invent properties.
One pattern remains to close the catalog, the most specific of the five: idempotence. In lesson 7 you will see what it means for applying an operation twice to give the same as applying it once —idempotent cancel, a clamp that trims to the range— and the alternative design of the cancel that raises the second time, with its own guard property. Let's continue.
Resources
- Metamorphic testing — Wikipedia — the theoretical foundation of the pattern: how to test programs without an oracle (search engines, compilers, scientific software) through metamorphic relationships. The historical context of what you saw here.
- Stateless Properties — PropEr Testing (Fred Hébert) — it treats the "generalization" of tests and the relationships between inputs and outputs, which is the heart of the metamorphic pattern.
hypothesis.strategies— official reference —st.sampled_from(to choose betweenBASICandPRO) andst.integers(for theextra >= 0that manufactures the monotonic relationship) are the pieces with which a metamorphic is written.