Property-Based Testing
Letting the computer invent test cases: properties instead of examples, Hypothesis strategies, shrinking to the smallest failing case, and properties that are worth testing.
From examples to properties
Almost every test on the other pages of this topic is an example-based test: you pick an input, work out the expected output by hand, and assert that the code produces it. "1.5 kWh at 23:15 on a day/night contract costs €0.34" is an example. Examples are clear and cheap, but they only test what you thought of. The night-rate bug at 23:00 survived because nobody thought of 23:00.
Property-based testing turns this around. You describe the inputs (any amount between 0 and 25 kWh, any time of day) and a rule the output must obey for all of them, and a library generates many inputs, runs the test with each, and reports any input that breaks the rule. Such a rule is a property: a statement about the code's behavior that is true for every valid input, such as:
- the cost of a reading is never negative;
- using more energy never costs less;
- the day part and the night part of a day's readings add up to the day's total;
- writing readings to a CSV (comma-separated values) file and reading them back gives the same readings.
None of these says what the cost is; each says something that must always be true. That is why properties find surprises: the library has no idea which inputs are "normal" and happily tries 0, 25, 00:00, 23:00 and very small amounts that a person would skip. The idea comes from QuickCheck, a Haskell library by Koen Claessen and John Hughes (ICFP 2000); similar libraries exist for most languages today.
Your first property with Hypothesis
Hypothesis is Python's
property-based testing library (version 6.168.5 as of October 2026).
It is not a test runner: you write ordinary
pytest test functions
and add the @given decorator, which says where each
argument comes from. Hypothesis then calls the function about a
hundred times with generated values. (Not to be confused with a
hypothesis test
in statistics, which is something else entirely.)
The file below has one example-based test and two properties for a day/night cost function with exact decimal prices:
from datetime import time
from decimal import ROUND_HALF_UP, Decimal
from hypothesis import given, strategies as st
DAY = Decimal("0.3120")
NIGHT = Decimal("0.2240")
CENT = Decimal("0.01")
def energy_cost(kwh, moment):
"""Cost of one reading on a day/night contract, rounded to cents."""
if kwh < 0:
raise ValueError("kWh cannot be negative")
price = NIGHT if moment.hour < 7 or moment.hour >= 23 else DAY
return (kwh * price).quantize(CENT, ROUND_HALF_UP)
kwh_values = st.decimals(min_value=0, max_value=25, places=3)
def test_example_quarter_past_eleven():
assert energy_cost(Decimal("1.5"), time(23, 15)) == Decimal("0.34")
@given(kwh_values, st.times())
def test_cost_is_never_negative(kwh, moment):
assert energy_cost(kwh, moment) >= 0
@given(kwh_values, kwh_values, st.times())
def test_more_energy_never_costs_less(a, b, moment):
low, high = sorted([a, b])
assert energy_cost(low, moment) <= energy_cost(high, moment)
============================= test session starts ============================== collecting ... collected 3 items test_example.py::test_example_quarter_past_eleven PASSED [ 33%] test_example.py::test_cost_is_never_negative PASSED [ 66%] test_example.py::test_more_energy_never_costs_less PASSED [100%] ============================== 3 passed in 0.42s ===============================
Three things to notice. Each property ran with a hundred generated
inputs (the default max_examples), yet pytest reports
it as one test. The properties never compute an expected price, so
they cannot copy a mistake from the code. And the run is slower than
the example test alone: properties trade a little time for many more
cases.
Strategies: describing the inputs
The arguments of @given are strategies. A
strategy is a
description of a set of possible values that Hypothesis knows how to
generate and, later, how to simplify. You build them from the
functions in hypothesis.strategies, usually imported as
st:
| Strategy | Generates |
|---|---|
st.integers(min_value=0, max_value=100_000)
|
whole numbers in a range (both ends included) |
st.decimals(min_value=0, max_value=25, places=3)
|
Decimal values with three decimals, like meter
readings
|
st.floats(allow_nan=False) |
floats without NaN (not a number); without bounds this still
includes infinity, and without allow_nan=False
NaN as well
|
st.text(),
st.from_regex(r"M-[0-9]{5}", fullmatch=True)
|
any Unicode text; text matching a pattern |
st.dates(), st.times() |
dates and times of day |
st.lists(s, min_size=96, max_size=96) |
lists of values from strategy s |
st.sampled_from(["fixed", "day_night"]) |
one of a fixed set of values |
st.builds(Reading, kwh=...) |
objects made by calling a class or function |
s.map(f), s.filter(p) |
transformed values; values that pass a check |
The example below generates a whole day of 96 quarter-hour readings
and checks that the day/night split loses nothing. Because the
readings are Decimal values, the sums are exact and the
property can use ==:
from decimal import Decimal
from hypothesis import given, strategies as st
day_of_readings = st.lists(
st.decimals(min_value=0, max_value=25, places=3), min_size=96, max_size=96
)
def split_day_night(readings):
"""Return (day kWh, night kWh) for 96 readings starting at 00:00."""
day = night = Decimal(0)
for index, kwh in enumerate(readings):
hour = index // 4
if hour < 7 or hour >= 23:
night += kwh
else:
day += kwh
return day, night
@given(day_of_readings)
def test_day_and_night_add_up_to_the_total(readings):
day, night = split_day_night(readings)
assert day + night == sum(readings)
@given(day_of_readings)
def test_night_covers_32_quarter_hours(readings):
only_night = [kwh if i < 28 or i >= 92 else Decimal(0)
for i, kwh in enumerate(readings)]
assert split_day_night(only_night)[0] == 0
.. [100%] 2 passed in 2.62s
The second property checks the boundaries from the other side:
readings only in the 32 night quarter-hours (00:00–06:45 and
23:00–23:45) must leave the day part at zero. If someone wrote
hour > 23, the 23:00 readings would land in the day
part and Hypothesis would find a list with a non-zero reading there
on the first run.
Shrinking: the smallest failing case
The reading validator sometimes has to spread one reading over several quarter-hours, for example after a gap in the data. The first version divides and rounds to three decimals (whole watt-hours). The property says the parts must add up to the original amount:
from hypothesis import given, strategies as st
def split_kwh(total: float, parts: int) -> list[float]:
"""Split a reading over equal 15-minute parts, rounded to 3 decimals."""
share = round(total / parts, 3)
return [share] * parts
@given(st.integers(min_value=0, max_value=100_000), st.integers(min_value=1, max_value=96))
def test_parts_add_up(total_wh, parts):
total = total_wh / 1000
assert round(sum(split_kwh(total, parts)), 3) == total
F [100%]
=================================== FAILURES ===================================
______________________________ test_parts_add_up _______________________________
@given(st.integers(min_value=0, max_value=100_000), st.integers(min_value=1, max_value=96))
> def test_parts_add_up(total_wh, parts):
^^^
test_example.py:11:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
total_wh = 1, parts = 2
@given(st.integers(min_value=0, max_value=100_000), st.integers(min_value=1, max_value=96))
def test_parts_add_up(total_wh, parts):
total = total_wh / 1000
> assert round(sum(split_kwh(total, parts)), 3) == total
E assert 0.002 == 0.001
E + where 0.002 = round(0.002, 3)
E + where 0.002 = sum([0.001, 0.001])
E + where [0.001, 0.001] = split_kwh(0.001, 2)
E Failing test case: test_parts_add_up(
E total_wh=1,
E parts=2,
E )
test_example.py:13: AssertionError
=========================== short test summary info ============================
FAILED test_example.py::test_parts_add_up - assert 0.002 == 0.001
1 failed in 0.30s
The first input that broke the property was probably something like 73,519 Wh over 41 parts: correct but hard to debug. Hypothesis did not stop there. It tried simpler and simpler variations of that input, kept each one that still failed, and stopped when nothing simpler failed. That process is called shrinking, and its result is the report above: 1 Wh split over 2 parts. Half a watt-hour rounds up to 0.001 kWh per part, so the parts add up to 2 Wh. Reading the minimal case, the bug is obvious: rounding each part separately can create or lose energy.
The fix is to work in whole watt-hours and hand out the remainder one by one, so the parts differ by at most 1 Wh and always add up. The same property, plus a second one, now passes:
from hypothesis import given, strategies as st
def split_wh(total_wh: int, parts: int) -> list[int]:
"""Split whole watt-hours over parts; the first parts get the remainder."""
share, rest = divmod(total_wh, parts)
return [share + 1 if i < rest else share for i in range(parts)]
totals = st.integers(min_value=0, max_value=100_000)
part_counts = st.integers(min_value=1, max_value=96)
@given(totals, part_counts)
def test_parts_add_up(total_wh, parts):
assert sum(split_wh(total_wh, parts)) == total_wh
@given(totals, part_counts)
def test_parts_differ_by_at_most_one_wh(total_wh, parts):
shares = split_wh(total_wh, parts)
assert len(shares) == parts
assert max(shares) - min(shares) <= 1
.. [100%] 2 passed in 0.35s
Reproducing failures
Hypothesis keeps every failing input in a small example database, a
folder called .hypothesis/ in the directory you run the
tests from. The next run replays those inputs first, so a failure
does not disappear just because the random generator went another
way. Add the folder to .gitignore; for a failure that
must never come back, copy the shrunk case into the test as
@example(total_wh=1, parts=2) so it is in version
control. A few settings are worth knowing:
-
@settings(max_examples=500)tries more inputs (the default is 100); use it for important properties, or a smaller number for slow ones. -
deadline(default 200 milliseconds per input) fails a test whose single call is too slow; setdeadline=Nonefor tests that are slow by nature. -
pytest --hypothesis-seed=0fixes the random generator for one run, which helps when you need to reproduce exactly what a pipeline did.
Properties worth testing
The hard part of property-based testing is not the library but finding properties. A useful property is simpler than the code it tests; if you have to re-implement the function inside the test, you will copy its bugs. These patterns cover most cases:
| Pattern | Rule | Kestrel example |
|---|---|---|
| Round trip | decode(encode(x)) == x | write readings to CSV, read them back |
| Invariant | something is preserved or always true | the parts add up; a cost is never negative |
| Oracle | agrees with a slow but obvious version | a new optimized invoice total equals summing every reading in a loop |
| Idempotence | f(f(x)) == f(x) | normalizing a meter ID twice changes nothing more |
| Metamorphic | a known change in input gives a known change in output | twice the energy costs twice as much |
Round trips
A round-trip property says that converting data to another form and back returns the original. It is the easiest property to write and catches real problems in parsers, serializers, exports and imports: lost decimals, wrong quoting, a separator inside a value. Kestrel's KBS export uses semicolons, as many Dutch exports do:
import csv
import io
from decimal import Decimal
from hypothesis import given, strategies as st
readings = st.lists(
st.tuples(
st.from_regex(r"M-[0-9]{5}", fullmatch=True),
st.decimals(min_value=0, max_value=25, places=3),
)
)
def write_readings(rows):
out = io.StringIO()
writer = csv.writer(out, delimiter=";")
writer.writerow(["meter", "kwh"])
for meter, kwh in rows:
writer.writerow([meter, kwh])
return out.getvalue()
def read_readings(text):
reader = csv.reader(io.StringIO(text), delimiter=";")
next(reader) # header
return [(meter, Decimal(kwh)) for meter, kwh in reader]
@given(readings)
def test_csv_round_trip(rows):
assert read_readings(write_readings(rows)) == rows
. [100%] 1 passed in 2.82s
It passes, and it is worth having: if someone later "simplifies" the
export to write float(kwh) or two decimals, the
property fails on the first value that does not survive the trip.
Metamorphic relations
Sometimes you cannot say what the right output is for one input, but
you can say how the output must change when the input changes.
Testing such a relation between two runs is
metamorphic testing. For
the cost function: doubling the energy must double the cost. Written
exactly like that, with
energy_cost(2 * kwh) == 2 * energy_cost(kwh), the
property fails within a second, on a reading of a few watt-hours:
each cost is rounded to whole cents, and twice a rounded amount is
not always the rounded double. That is not a bug in the cost
function; it is a rule the team has to state precisely. The relation
that does hold is "within one cent":
from decimal import ROUND_HALF_UP, Decimal
from hypothesis import given, strategies as st
DAY = Decimal("0.3120")
NIGHT = Decimal("0.2240")
CENT = Decimal("0.01")
def energy_cost(kwh, night):
return (kwh * (NIGHT if night else DAY)).quantize(CENT, ROUND_HALF_UP)
@given(st.decimals(min_value=0, max_value=25, places=3), st.booleans())
def test_double_energy_costs_double_within_a_cent(kwh, night):
difference = energy_cost(2 * kwh, night) - 2 * energy_cost(kwh, night)
assert abs(difference) <= CENT
. [100%] 1 passed in 0.25s
Questions like "do we round per reading or per invoice?" are exactly what properties bring to the surface. If KBS rounds once per invoice line and the portal rounds every reading, the cost page and the invoice will disagree by a few cents; better that Joost Bakker and the Portal team settle the rule now than that a customer finds the difference.
Dates, time zones and the 100-quarter-hour day
Dates are where properties shine, because the dangerous inputs are rare: a day in Amsterdam has 96 quarter-hours, except the last Sunday of March (92) and the last Sunday of October (100). The next one is 25 October 2026. Instead of listing those days, state what must hold for every date and every year:
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
from hypothesis import given, strategies as st
AMSTERDAM = ZoneInfo("Europe/Amsterdam")
def quarter_hours_in_day(day):
start = datetime.combine(day, time(0), tzinfo=AMSTERDAM)
end = datetime.combine(day + timedelta(days=1), time(0), tzinfo=AMSTERDAM)
seconds = (end.astimezone(timezone.utc) - start.astimezone(timezone.utc)).total_seconds()
return int(seconds // 900)
@given(st.dates(min_value=date(2020, 1, 1), max_value=date(2035, 12, 31)))
def test_a_day_has_92_96_or_100_quarter_hours(day):
assert quarter_hours_in_day(day) in {92, 96, 100}
@given(st.integers(min_value=2020, max_value=2035))
def test_a_year_has_96_quarter_hours_per_day_on_average(year):
first = date(year, 1, 1)
days = (date(year + 1, 1, 1) - first).days
total = sum(quarter_hours_in_day(first + timedelta(n)) for n in range(days))
assert total == days * 96
.. [100%] 2 passed in 0.32s
The conversion to UTC (Coordinated Universal Time) before subtracting is essential: Python subtracts two times in the same time zone object as wall-clock times, so without it every day would have 96 quarter-hours and the bug the team is guarding against (BUG-1042, "daily total shows 96 quarter-hours on 25 Oct 2026") would be built into the test helper. The second property is a good sanity check: the 92 in spring and the 100 in autumn cancel out over a year.
When to use properties, and when not
Property-based tests pay off most for:
- Calculations with rules: prices, splits, rounding, units, time zones.
- Parsers and formats: CSV and JSON (JavaScript Object Notation) import and export, meter ID formats, anything with a round trip.
- Code with a simple reference: an optimized version of something you can also write slowly and obviously.
- Input validation: "no input, however strange, makes this function crash" is a property. Hypothesis is not a coverage-guided fuzzer, but it finds many of the same crashes; Security Testing covers fuzzing proper.
They are a poor fit, or need care, when:
- the only property you can think of is "the output equals what the code computes", which means re-implementing it;
- each call is slow (a database, a network call): a hundred inputs become a hundred slow calls;
- the business wants specific, readable cases in the test suite, such as the boundaries of a tariff rule. Keep those as examples and add properties next to them.
Property-based tests also kill surviving mutants well, because generated inputs reach the boundaries that hand-picked ones miss. Similar libraries exist outside Python, such as fast-check for JavaScript and TypeScript, jqwik for Java and FsCheck for .NET; the ideas on this page carry over unchanged.