Under the Imperius Curse, the victim has no control over their own actions — someone else decides what they do. In property-based testing, you surrender control of test inputs to the framework. Instead of hand-picking examples like a careful wizard, you let hypothesis generate hundreds of random inputs and verify that certain properties always hold.
Traditional (example-based) testing: you choose the inputs. Like a wizard practicing a spell on a specific dummy.
# Example-based: YOU pick the input
def test_reverse_string():
assert reverse("hello") == "olleh"
assert reverse("") == ""
assert reverse("a") == "a"
Property-based testing: the framework picks inputs. Like casting a spell at random targets and verifying the property always holds:
# Property-based: HYPOTHESIS picks the input
from hypothesis import given
from hypothesis import strategies as st
@given(st.text())
def test_reverse_is_involution(s):
"""Reversing twice returns the original — always!"""
assert reverse(reverse(s)) == s
Hypothesis will generate hundreds of random strings — empty, long, unicode, emoji-laden — and verify the property for all of them. You're under the Imperius: you don't control what's tested.
pip install hypothesis
# Hypothesis integrates seamlessly with pytest.
# Just use @given and strategies — no special runner needed.
Strategies are the source of random inputs. Think of them as the caster of the Imperius — they decide what your function receives:
from hypothesis import given
from hypothesis import strategies as st
# Integers in any range
@given(st.integers())
def test_abs_non_negative(n):
assert abs(n) >= 0
# Text (unicode strings)
@given(st.text(min_size=1))
def test_string_not_empty(s):
assert len(s) > 0
# Lists of floats
@given(st.lists(st.floats(allow_nan=False, allow_infinity=False)))
def test_sorted_length(lst):
assert len(sorted(lst)) == len(lst)
# Bounded integers
@given(st.integers(min_value=0, max_value=100))
def test_percentage_range(pct):
assert 0 <= pct <= 100
A good property is like a spell/counter-spell pair: if you cast and then reverse, you should end up where you started.
import json
from hypothesis import given
from hypothesis import strategies as st
# Encode/decode roundtrip: the counter-spell undoes the spell
@given(st.dictionaries(st.text(), st.integers()))
def test_json_roundtrip(d):
"""JSON encode then decode = identity (the counter-spell works)."""
encoded = json.dumps(d) # Cast the spell
decoded = json.loads(encoded) # Cast the counter-spell
assert decoded == d # Back to original!
# Another classic: sorting is idempotent
@given(st.lists(st.integers()))
def test_sort_idempotent(lst):
"""Sorting twice gives the same result as sorting once."""
once = sorted(lst)
twice = sorted(once)
assert once == twice
Like combining spells for more powerful effects:
from hypothesis import given
from hypothesis import strategies as st
# one_of: either an int or a float
@given(st.one_of(st.integers(), st.floats(allow_nan=False)))
def test_numeric_addition(n):
assert n + 0 == n
# .map: transform strategy output
@given(st.integers(min_value=1, max_value=100).map(lambda x: x * 2))
def test_even_numbers(n):
assert n % 2 == 0
# .filter: reject unwanted values
@given(st.integers().filter(lambda x: x != 0))
def test_division_by_nonzero(n):
assert 1 / n != None # won't get ZeroDivisionError
Let's test a Reverse Polish Notation calculator with random inputs:
from hypothesis import given, assume
from hypothesis import strategies as st
class RPNCalculator:
def __init__(self):
self.stack = []
def push(self, value):
self.stack.append(value)
def add(self):
b, a = self.stack.pop(), self.stack.pop()
self.stack.append(a + b)
def peek(self):
return self.stack[-1]
@given(st.integers(), st.integers())
def test_rpn_addition_commutative(a, b):
"""Addition is commutative: a + b == b + a"""
calc1 = RPNCalculator()
calc1.push(a)
calc1.push(b)
calc1.add()
calc2 = RPNCalculator()
calc2.push(b)
calc2.push(a)
calc2.add()
assert calc1.peek() == calc2.peek()
@given(st.integers(), st.integers(), st.integers())
def test_rpn_addition_associative(a, b, c):
"""Addition is associative: (a+b)+c == a+(b+c)"""
calc1 = RPNCalculator()
calc1.push(a)
calc1.push(b)
calc1.add()
calc1.push(c)
calc1.add()
calc2 = RPNCalculator()
calc2.push(b)
calc2.push(c)
calc2.add()
calc2.push(a)
calc2.add() # a + (b+c) but via stack
# Actually: calc2 computes (b+c) + a which == a + (b+c)
assert calc1.peek() == calc2.peek()
When Hypothesis finds a failing input, it doesn't stop. It shrinks the input to the smallest, simplest example that still fails. Like a skilled Auror extracting only the essential evidence:
@given(st.lists(st.integers()))
def test_list_never_has_duplicates_after_sort(lst):
"""This will FAIL — Hypothesis will find a minimal counterexample."""
sorted_lst = sorted(lst)
# This property is FALSE (sorting doesn't remove duplicates)
assert len(sorted_lst) == len(set(sorted_lst))
# Hypothesis output:
# Falsifying example: test_list_never_has_duplicates_after_sort(
# lst=[0, 0] # <-- MINIMAL! Shrunk from something like [347, -19, 347, 88]
# )
Hypothesis found a complex failing list, then shrank it to [0, 0] — the simplest possible counterexample. This is the true power of the Imperius: not just finding bugs, but isolating them to their purest form.
Under the Imperius Curse of property-based testing:
"The Imperius Curse can be fought, and I'll be teaching you how, but it takes real strength of character..." — Mad-Eye Moody. Similarly, writing good properties takes real understanding of your code's invariants.