python-hypothesis-patterns
Use this when testing a parser, serializer, validator, or state machine where an invariant is articulable, when a fixed parametrize table feels insufficient for an unbounded input space, when seeding
SKILL.mdskills/python-hypothesis-patterns/SKILL.md
GitHubpython-hypothesis-patterns (v1.0.0)
Rationale
Example-based tests prove a function works on the cases the author thought of. Property-based tests assert an invariant and let the framework search for COUNTEREXAMPLES — including edge cases the author would never have thought of. For SDK code, properties shine on serializers / parsers / validators / state machines: "for any input, parse(serialize(x)) == x", "for any sequence of operations, the final state is deterministic", "for any URL the validator accepts, the request building doesn't crash."
hypothesis is the canonical Python library. It generates random inputs, shrinks failures down to a minimal reproducer, and persists failing seeds across runs.
This skill is cited by code-reviewer-python (test-quality), python-pytest-patterns (Rule 2 — parametrize for finite cases, hypothesis for infinite spaces), python-mypy-strict-typing (st.from_type uses annotations), and sdk-convention-devil-python (C-14 testing).
Activation signals
- SDK has a parser, serializer, validator, or state machine.
- TPRD §3 declares "for any input, X must hold".
- Reviewing whether a fixed table of test cases is sufficient (probably not).
- A bug ships that example tests missed — would a property have caught it?
- Code under test has a round-trip property:
decode(encode(x)) == x.
Core decision — property or example?
Use property-based testing when:
- The input space is large or unbounded (strings, integers, lists, JSON).
- An INVARIANT is articulable: round-trip, idempotent, monotonic, commutative, ordering-preserving.
- The function is pure (or has a fakeable seam — see
python-mock-strategy).
Use example-based testing when:
- Specific inputs and expected outputs are the contract (e.g., REST API validates
topicmatches a known regex). - The function has side effects you specifically test for.
- The bug surface is narrow.
Both layers complement each other; ship both for any non-trivial parser / validator.
Rule 1 — Strategies are the input space
from hypothesis import given, strategies as st
@given(st.text())
def test_normalize_topic_idempotent(t: str) -> None:
assert normalize_topic(normalize_topic(t)) == normalize_topic(t)
@given(st.integers(min_value=0, max_value=10_000))
def test_backoff_bounded(attempt: int) -> None:
delay = estimate_backoff_seconds(attempt=attempt)
assert 0.0 <= delay <= 30.0
@given(st.lists(st.integers(), min_size=1, max_size=100))
def test_sort_then_min_equals_min(xs: list[int]) -> None:
assert sorted(xs)[0] == min(xs)
hypothesis.strategies (st) builds typed input spaces:
| Strategy | Generates |
|---|---|
st.text() |
Strings (unicode by default) |
st.text(alphabet=st.characters(min_codepoint=32, max_codepoint=126)) |
ASCII printable |
st.binary() |
bytes |
st.integers(min_value, max_value) |
int |
st.floats(min_value, max_value, allow_nan=False, allow_infinity=False) |
float |
st.booleans() |
True / False |
st.lists(elements, min_size, max_size, unique=True) |
list |
st.dictionaries(keys, values) |
dict |
st.sets(elements) |
set |
st.tuples(s1, s2, ...) |
tuple |
st.one_of(s1, s2, ...) |
union |
st.sampled_from([...]) |
element from finite set |
st.builds(Class, **strategy_kwargs) |
Class instance |
st.from_type(Cls) |
infer from type annotations |
st.datetimes(), st.dates(), st.times(), st.timedeltas() |
datetime types |
st.uuids() |
UUID |
st.emails() |
email-shape strings |
st.ip_addresses() |
IPv4/IPv6 |
st.fixed_dictionaries({"k": st.integers()}) |
dict with fixed shape |
For SDK domain objects, build a strategy:
@st.composite
def topics(draw) -> Topic:
"""Strategy for valid Topic instances.
Constraints (per domain rules): namespace and name non-empty, no '.' inside.
"""
alpha = st.characters(min_codepoint=ord("a"), max_codepoint=ord("z"))
namespace = draw(st.text(alphabet=alpha, min_size=1, max_size=20))
name = draw(st.text(alphabet=alpha, min_size=1, max_size=20))
return Topic(namespace=namespace, name=name)
@given(topics())
def test_topic_qualified_round_trip(t: Topic) -> None:
assert Topic.parse(t.qualified) == t
@st.composite is the canonical pattern for building related-field combinations. The draw callable pulls from inner strategies; the function body returns the assembled object.
Rule 2 — st.from_type for typed dataclasses
from dataclasses import dataclass
from hypothesis import given, strategies as st
@dataclass(frozen=True, slots=True, kw_only=True)
class Config:
base_url: str
timeout_s: float = 5.0
max_retries: int = 3
@given(st.from_type(Config))
def test_config_post_init_invariant(cfg: Config) -> None:
"""Whatever Config we generate, timeout_s > 0 must hold per __post_init__."""
assert cfg.timeout_s > 0
# Register a custom strategy if the default is too broad
st.register_type_strategy(Config, st.builds(
Config,
base_url=st.sampled_from(["http://x", "https://y"]),
timeout_s=st.floats(min_value=0.1, max_value=60.0),
max_retries=st.integers(min_value=0, max_value=10),
))
st.from_type(T) reads T's type annotations and synthesizes a strategy. For dataclasses + plain int/str/float/bool fields, it works out of the box. For custom types with constraints, st.register_type_strategy() overrides the default.
Rule 3 — @settings for time / iteration budgets
from hypothesis import given, settings, HealthCheck
@given(...)
@settings(
max_examples=200, # default 100; raise for thorough coverage
deadline=500, # ms; default 200; raise for slow properties
suppress_health_check=[HealthCheck.too_slow],
)
def test_thing(...) -> None:
...
Defaults are conservative for unit tests. For integration-grade properties (touch a fake / fixture), raise deadline to the realistic upper bound. NEVER set deadline=None to silence flakes — the test then hangs CI.
For long-running properties run as a separate suite, profile via:
@settings(profile="ci") # uses the "ci" profile registered globally
# In conftest.py:
from hypothesis import settings, Phase
settings.register_profile("ci", max_examples=500, deadline=2000)
settings.register_profile("dev", max_examples=20, deadline=200)
settings.load_profile("dev") # default
HYPOTHESIS_PROFILE=ci pytest switches to the CI profile.
Rule 4 — @example seeds known cases
from hypothesis import example, given
@given(st.text())
@example("") # empty string edge
@example("a" * 256) # max length edge
@example("