Skip to content

Hypothesis Patterns -- Property-Based Testing

Why This Matters in Interviews

Reaching for Hypothesis during a coding interview signals testing sophistication well beyond unit tests. Three things it demonstrates:

  1. You test the specification, not specific cases. Instead of checking that sort([3, 1, 2]) == [1, 2, 3], you assert result == sorted(result) for any generated input. This catches edge cases you'd never think to write by hand.

  2. You know the oracle pattern. Compare your optimized solution against a known-correct brute-force reference. For example, validate top_k_frequent (bucket sort, O(n)) against Counter.most_common(k) --- the obvious but slower approach. One line of property, thousands of test cases.

  3. You can articulate invariants. "The output is always sorted", "every returned element exists in the input", "length never exceeds k" are properties, not examples. Hypothesis tests them across hundreds of random inputs automatically.

How the Tests Are Wired Up

The source module below (src/concepts/hypothesis_patterns.py) defines two systems-under-test: SortedList and BoundedCounter. The real tutorial lives in the test file (tests/concepts/test_hypothesis_patterns.py), which walks through six Hypothesis patterns with runnable, heavily-commented code:

Pattern What It Does When to Reach for It
@composite Build complex test data step by step Inter-dependent constraints (e.g., pre-filled data structures)
RuleBasedStateMachine Stateful testing with an oracle Sequences of operations that must maintain invariants
@example Pin specific inputs Regression tests, known edge cases
@settings Control intensity CI (high) vs local dev (fast) profiles
st.builds Auto-generate dataclass instances Any __init__ that takes simple args
st.recursive Generate nested/tree structures JSON-like data, ASTs, recursive types

Quick Interview Demo

To demonstrate PBT mastery in under 2 minutes during an interview:

from hypothesis import given
from hypothesis import strategies as st

@given(data=st.lists(st.integers(), min_size=1))
def test_top_1_matches_counter(data: list[int]) -> None:
    """Oracle pattern: compare O(n) bucket sort against Counter."""
    from collections import Counter
    result = top_k_frequent(data, 1)
    expected_freq = Counter(data).most_common(1)[0][1]
    assert Counter(data)[result[0]] == expected_freq

This single test generates hundreds of random inputs and validates your implementation against the stdlib oracle. No hand-written edge cases needed.


Systems Under Test

hypothesis_patterns

Systems-under-test for Hypothesis property-based testing patterns.

This module provides two deliberately simple but non-trivial classes that are ideal targets for Hypothesis strategies and stateful testing:

  1. SortedList — a list that maintains sorted order via bisect
  2. BoundedCounter — an integer counter clamped to [min_val, max_val]

The real tutorial lives in the corresponding test file (tests/concepts/test_hypothesis_patterns.py), which demonstrates @composite, RuleBasedStateMachine, @example, settings profiles, st.builds, and st.recursive.

References

  • bisect module — https://docs.python.org/3.14/library/bisect.html
  • Hypothesis docs — https://hypothesis.readthedocs.io/en/latest/

SortedList

A list that maintains its elements in sorted order at all times.

Insertions use bisect.insort for O(n) insertion with O(log n) position finding. Searching uses bisect.bisect_left for O(log n) membership checks.

sl = SortedList() sl.insert(3) sl.insert(1) sl.insert(2) sl.to_list() [1, 2, 3] sl.search(2) True sl.search(4) False

Source code in src/concepts/hypothesis_patterns.py
class SortedList:
    """A list that maintains its elements in sorted order at all times.

    Insertions use ``bisect.insort`` for O(n) insertion with O(log n)
    position finding.  Searching uses ``bisect.bisect_left`` for
    O(log n) membership checks.

    >>> sl = SortedList()
    >>> sl.insert(3)
    >>> sl.insert(1)
    >>> sl.insert(2)
    >>> sl.to_list()
    [1, 2, 3]
    >>> sl.search(2)
    True
    >>> sl.search(4)
    False
    """

    def __init__(self) -> None:
        self._data: list[int] = []

    def insert(self, val: int) -> None:
        """Insert *val* in sorted position.

        >>> sl = SortedList()
        >>> sl.insert(5)
        >>> sl.insert(2)
        >>> sl.to_list()
        [2, 5]
        """
        # bisect.insort maintains sort invariant in O(n) time.
        bisect.insort(self._data, val)

    def search(self, val: int) -> bool:
        """Return ``True`` if *val* exists in the list.

        Uses binary search via ``bisect_left`` — O(log n).

        >>> sl = SortedList()
        >>> sl.insert(10)
        >>> sl.search(10)
        True
        >>> sl.search(99)
        False
        """
        idx = bisect.bisect_left(self._data, val)
        return idx < len(self._data) and self._data[idx] == val

    def remove(self, val: int) -> None:
        """Remove one occurrence of *val*.

        Raises ``ValueError`` if *val* is not present.

        >>> sl = SortedList()
        >>> sl.insert(1)
        >>> sl.remove(1)
        >>> sl.to_list()
        []
        """
        idx = bisect.bisect_left(self._data, val)
        if idx < len(self._data) and self._data[idx] == val:
            self._data.pop(idx)
        else:
            msg = f"{val} not in SortedList"
            raise ValueError(msg)

    def to_list(self) -> list[int]:
        """Return a copy of the internal sorted list.

        >>> sl = SortedList()
        >>> sl.insert(3)
        >>> sl.insert(1)
        >>> sl.to_list()
        [1, 3]
        """
        return list(self._data)

    def __len__(self) -> int:
        return len(self._data)

    def __repr__(self) -> str:
        return f"SortedList({self._data!r})"

insert

insert(val: int) -> None

Insert val in sorted position.

sl = SortedList() sl.insert(5) sl.insert(2) sl.to_list() [2, 5]

Source code in src/concepts/hypothesis_patterns.py
def insert(self, val: int) -> None:
    """Insert *val* in sorted position.

    >>> sl = SortedList()
    >>> sl.insert(5)
    >>> sl.insert(2)
    >>> sl.to_list()
    [2, 5]
    """
    # bisect.insort maintains sort invariant in O(n) time.
    bisect.insort(self._data, val)

search

search(val: int) -> bool

Return True if val exists in the list.

Uses binary search via bisect_left — O(log n).

sl = SortedList() sl.insert(10) sl.search(10) True sl.search(99) False

Source code in src/concepts/hypothesis_patterns.py
def search(self, val: int) -> bool:
    """Return ``True`` if *val* exists in the list.

    Uses binary search via ``bisect_left`` — O(log n).

    >>> sl = SortedList()
    >>> sl.insert(10)
    >>> sl.search(10)
    True
    >>> sl.search(99)
    False
    """
    idx = bisect.bisect_left(self._data, val)
    return idx < len(self._data) and self._data[idx] == val

remove

remove(val: int) -> None

Remove one occurrence of val.

Raises ValueError if val is not present.

sl = SortedList() sl.insert(1) sl.remove(1) sl.to_list() []

Source code in src/concepts/hypothesis_patterns.py
def remove(self, val: int) -> None:
    """Remove one occurrence of *val*.

    Raises ``ValueError`` if *val* is not present.

    >>> sl = SortedList()
    >>> sl.insert(1)
    >>> sl.remove(1)
    >>> sl.to_list()
    []
    """
    idx = bisect.bisect_left(self._data, val)
    if idx < len(self._data) and self._data[idx] == val:
        self._data.pop(idx)
    else:
        msg = f"{val} not in SortedList"
        raise ValueError(msg)

to_list

to_list() -> list[int]

Return a copy of the internal sorted list.

sl = SortedList() sl.insert(3) sl.insert(1) sl.to_list() [1, 3]

Source code in src/concepts/hypothesis_patterns.py
def to_list(self) -> list[int]:
    """Return a copy of the internal sorted list.

    >>> sl = SortedList()
    >>> sl.insert(3)
    >>> sl.insert(1)
    >>> sl.to_list()
    [1, 3]
    """
    return list(self._data)

BoundedCounter dataclass

An integer counter clamped between min_val and max_val.

Attempting to increment past max_val or decrement past min_val raises ValueError.

bc = BoundedCounter(min_val=0, max_val=3) bc.increment() bc.increment() bc.value 2 bc.decrement() bc.value 1

Source code in src/concepts/hypothesis_patterns.py
@dataclass
class BoundedCounter:
    """An integer counter clamped between *min_val* and *max_val*.

    Attempting to increment past *max_val* or decrement past *min_val*
    raises ``ValueError``.

    >>> bc = BoundedCounter(min_val=0, max_val=3)
    >>> bc.increment()
    >>> bc.increment()
    >>> bc.value
    2
    >>> bc.decrement()
    >>> bc.value
    1
    """

    min_val: int = 0
    max_val: int = 10
    _count: int = field(default=0, init=False, repr=False)

    def __post_init__(self) -> None:
        if self.min_val > self.max_val:
            msg = f"min_val ({self.min_val}) > max_val ({self.max_val})"
            raise ValueError(msg)
        # Start the counter at min_val.
        self._count = self.min_val

    @property
    def value(self) -> int:
        """Current counter value.

        >>> BoundedCounter(min_val=5, max_val=10).value
        5
        """
        return self._count

    def increment(self) -> None:
        """Add 1, raising ``ValueError`` if at *max_val*.

        >>> bc = BoundedCounter(min_val=0, max_val=1)
        >>> bc.increment()
        >>> bc.increment()
        Traceback (most recent call last):
            ...
        ValueError: counter at maximum (1)
        """
        if self._count >= self.max_val:
            msg = f"counter at maximum ({self.max_val})"
            raise ValueError(msg)
        self._count += 1

    def decrement(self) -> None:
        """Subtract 1, raising ``ValueError`` if at *min_val*.

        >>> bc = BoundedCounter(min_val=0, max_val=5)
        >>> bc.decrement()
        Traceback (most recent call last):
            ...
        ValueError: counter at minimum (0)
        """
        if self._count <= self.min_val:
            msg = f"counter at minimum ({self.min_val})"
            raise ValueError(msg)
        self._count -= 1

value property

value: int

Current counter value.

BoundedCounter(min_val=5, max_val=10).value 5

increment

increment() -> None

Add 1, raising ValueError if at max_val.

bc = BoundedCounter(min_val=0, max_val=1) bc.increment() bc.increment() Traceback (most recent call last): ... ValueError: counter at maximum (1)

Source code in src/concepts/hypothesis_patterns.py
def increment(self) -> None:
    """Add 1, raising ``ValueError`` if at *max_val*.

    >>> bc = BoundedCounter(min_val=0, max_val=1)
    >>> bc.increment()
    >>> bc.increment()
    Traceback (most recent call last):
        ...
    ValueError: counter at maximum (1)
    """
    if self._count >= self.max_val:
        msg = f"counter at maximum ({self.max_val})"
        raise ValueError(msg)
    self._count += 1

decrement

decrement() -> None

Subtract 1, raising ValueError if at min_val.

bc = BoundedCounter(min_val=0, max_val=5) bc.decrement() Traceback (most recent call last): ... ValueError: counter at minimum (0)

Source code in src/concepts/hypothesis_patterns.py
def decrement(self) -> None:
    """Subtract 1, raising ``ValueError`` if at *min_val*.

    >>> bc = BoundedCounter(min_val=0, max_val=5)
    >>> bc.decrement()
    Traceback (most recent call last):
        ...
    ValueError: counter at minimum (0)
    """
    if self._count <= self.min_val:
        msg = f"counter at minimum ({self.min_val})"
        raise ValueError(msg)
    self._count -= 1