The Imperius Curse: Property-Based Testing


Hypothesis — When You Don't Control the Inputs

🧠 The Imperius Curse — You Don't Choose the Inputs

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.

1. Example-Based vs. Property-Based — The Paradigm Shift

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.

2. The Hypothesis Library — Your Dark Arts Toolkit

pip install hypothesis

# Hypothesis integrates seamlessly with pytest.
# Just use @given and strategies — no special runner needed.

3. The @given Decorator and Strategies — Casting the Curse

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

4. Writing Properties — Spell and Counter-Spell

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

5. Combining Strategies — Composing Curses

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

6. Testing the RPNCalculator — A Practical Example

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()

7. Shrinking — Finding the Minimal Failing Case

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.

Summary

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.