Hypothesis & Fuzzing

Generating Custom Strategies with hypothesis.strategies

You have a domain object — a date range, a financial record, a config with cross-field rules — and the obvious approach, st.builds(Model).filter(is_valid), has turned your suite into a CI bottleneck: --hypothesis-show-statistics shows most generated examples being discarded, shrinking stalls, and runs occasionally Unsatisfiable. The fix is to stop generating arbitrary data and filtering it, and instead build valid-by-construction strategies whose every output already satisfies the invariants. This guide shows the @st.composite, st.builds, and type-registration patterns that get rejection rates below the 15% threshold where Hypothesis stays fast and shrinking stays deterministic.

Prerequisites

Solution

The core technique is conditional routing: pick the categorical fields first, then draw dependent fields from bounds derived from those choices, so no invalid object is ever produced.

Python
from datetime import date, timedelta
import hypothesis.strategies as st
from hypothesis import given, settings, assume, Phase, Verbosity

@st.composite
def valid_time_ranges(draw: st.DrawFn) -> dict[str, date | int]:
    # Draw start from a bounded domain so CI runs stay deterministic
    start = draw(st.dates(min_value=date(2020, 1, 1), max_value=date(2024, 12, 31)))
    # Route end_date generation: a valid future date OR the same day — never < start
    end = draw(st.one_of(
        st.dates(min_value=start, max_value=start + timedelta(days=365)),
        st.just(start),
    ))
    assume(end >= start)  # cheap edge guard only; routing already guarantees validity
    return {"start_date": start, "end_date": end, "duration_days": (end - start).days}

@given(time_range=valid_time_ranges())
@settings(max_examples=200, phases=[Phase.generate, Phase.shrink],
          verbosity=Verbosity.normal, database=None)
def test_time_range_invariants(time_range: dict[str, date | int]) -> None:
    assert time_range["start_date"] <= time_range["end_date"]  # holds by construction
    assert time_range["duration_days"] >= 0

When the strategy for a dependent field — not merely its numeric bounds — depends on an earlier draw, chain generators with flatmap and nested composites rather than widening a single st.one_of; that keeps each branch valid and shrinkable.

For pure constructors whose fields are independent, st.builds is more declarative and resolves type hints automatically. Register the strategy so st.from_type() finds it everywhere:

Python
from dataclasses import dataclass, field
from typing import Optional, Literal
import hypothesis.strategies as st
from hypothesis import given, settings, Phase

@dataclass
class UserConfig:
    username: str
    tier: Literal["free", "pro", "enterprise"]
    max_requests: Optional[int] = None
    metadata: dict[str, str] = field(default_factory=dict)

def user_config_strategy() -> st.SearchStrategy[UserConfig]:
    return st.builds(
        UserConfig,
        username=st.text(min_size=3, max_size=20).filter(str.isalnum),  # cheap, rarely rejects
        tier=st.sampled_from(["free", "pro", "enterprise"]),
        max_requests=st.one_of(st.none(), st.integers(min_value=100, max_value=10_000)),
        metadata=st.dictionaries(st.text(min_size=1, max_size=15), st.text(max_size=50)),
    )

st.register_type_strategy(UserConfig, user_config_strategy())  # st.from_type() now resolves it

@given(config=st.from_type(UserConfig))
@settings(max_examples=100, phases=[Phase.generate, Phase.shrink])
def test_user_config(config: UserConfig) -> None:
    assert config.username.isalnum()
    if config.max_requests is not None:
        assert config.max_requests >= 100
Reject-and-retry versus route-and-construct generation Two pipelines. The top lane draws arbitrary data, passes it through st.builds().filter(), and loops most draws back for a retry, so rejection above fifteen percent collapses throughput. The bottom lane draws categorical fields first, derives dependent bounds from those choices, routes with one_of, and yields a valid object directly, so every draw is valid and rejection is near zero. Reject-and-retry vs. route-and-construct Filter & reject — validation after generation reject · retry — most draws discarded arbitrary draw .filter(is_valid) @given test rejection > 15% → throughput collapses, shrinking non-deterministic Route & construct — validation during generation draw categorical sampled_from derive bounds from choice route one_of() valid object → @given test every draw valid by construction → rejection ≈ 0%, shrinking deterministic
Filtering validates after generation, so a low pass rate loops most draws back through a rejection-retry cycle; routing validates during generation — categorical fields first, then dependent bounds derived from those choices — so every object is valid on the first draw.

To extract a minimal failing input without the @given runner, use hypothesis.find(strategy, predicate) — it applies the same shrinking machinery and returns the smallest input satisfying the predicate, ideal for isolating a known business-rule violation.

Every strategy is built from the same four operations, and knowing which one you need shortens the search considerably.

The four ways to build a new strategy A table of the four strategy-building operations - map, filter, flatmap and the composite decorator - with what each does to an existing strategy and its effect on shrinking quality. The four ways to build a new strategy Criterion What it does Shrinking .map(f) transform each value preserved .filter(pred) reject invalid values degraded .flatmap(f) value chooses strategy preserved @composite imperative multi-draw best control
Prefer map and composite; every filter is a rejection loop that costs generation time and shrinking quality.

Why this works

Hypothesis's rejection sampler discards invalid examples and retries; once rejection exceeds ~15%, throughput collapses and shrinking becomes non-deterministic because the search tree is fragmented by discarded branches. Routing generation with st.one_of and st.sampled_from moves validation from after generation to during it, so the shrinking engine only ever explores valid inputs and can converge on a minimal counterexample in milliseconds. st.builds adds declarative type resolution for the independent-field case, while st.register_type_strategy makes that resolution automatic across the suite.

Edge cases and failure modes

  • .filter() still present on a hot path — even one filter with a low pass rate dominates runtime; replace it with a pre-computed st.sampled_from(valid_values) when the valid set is finite.
  • Circular type resolutionA references B references A causes infinite generation; break it with st.recursive(..., max_leaves=...) or st.just() placeholders, and bound depth explicitly in recursive composites.
  • Unhashable types in st.dictionaries/st.sets — generated mutable values raise TypeError; convert to tuple/frozenset before they are used as keys.
  • Mutable state leakage — if the test mutates a generated object, copy.deepcopy() it first, or a cached example can carry mutations into the next run.
  • Global registration in a monoreporegister_type_strategy is process-global; prefer local registration in the test module (or a fixture that registers and unregisters) to avoid polluting other suites.

Registering strategies for your own types

Once a project has more than a handful of custom strategies, passing them explicitly to every test becomes the dominant cost. hypothesis.strategies.register_type_strategy removes it: register a type once, and st.builds and st.from_type can construct anything that references it.

Python
import uuid
from dataclasses import dataclass
from hypothesis import given, strategies as st

@dataclass(frozen=True)
class AccountId:
    value: uuid.UUID

@dataclass(frozen=True)
class Order:
    account: AccountId
    total_cents: int
    currency: str

# One registration teaches Hypothesis how to build every AccountId it ever needs.
st.register_type_strategy(AccountId, st.uuids().map(AccountId))

@given(st.from_type(Order))          # Order is built from its annotations
def test_total_is_non_negative(order: Order):
    assert order.total_cents >= -2 ** 63

from_type reads the dataclass annotations, resolves each one — using the registry first, then its built-in mapping for standard types — and composes the result. The registration is global to the process, so put it in conftest.py rather than in a test module, and register the narrow types (AccountId, CurrencyCode) rather than the aggregates; aggregates are then derived automatically and stay correct when a field is added.

Two constraints are worth knowing before you lean on this. The registry keys on the exact type, so a subclass does not inherit its parent's registration — register each concrete type you generate. And a registration that produces invalid values is worse than none at all, because it silently affects every test that touches the type: a CurrencyCode strategy that emits arbitrary three-letter strings will generate "ZZZ", and any code path validating against a real currency list starts failing for reasons unrelated to the property under test. Constrain the registration to the domain:

Python
CURRENCIES = ("EUR", "GBP", "USD", "JPY")
st.register_type_strategy(str, st.text())            # do NOT do this: too broad
st.register_type_strategy(CurrencyCode, st.sampled_from(CURRENCIES).map(CurrencyCode))

The second line is the pattern: a NewType or a small wrapper class gives you somewhere to attach the constraint, and sampled_from keeps the generated set inside the domain while still exercising every member.

For types you do not own, st.builds is the lighter-weight alternative — it takes the callable and infers the rest from annotations, letting you override individual arguments where the default inference is wrong:

Python
@given(st.builds(Order, total_cents=st.integers(min_value=0, max_value=10 ** 9)))
def test_positive_orders_round_trip(order: Order):
    assert Order(**{**order.__dict__}) == order

That override is also the escape hatch when a registered strategy is too broad for one specific test: keep the global registration honest and narrow it locally where a property needs a tighter domain.

How from_type resolves a dataclass A vertical flow showing how from_type builds a value: it reads the type annotations, consults the type registry for each one, falls back to the built-in mapping for standard library types, and finally calls the constructor with the drawn arguments. How from_type resolves a dataclass read annotations field name to type Requires resolvable annotations consult the registry your registrations first Exact type match, not subclasses fall back to built-ins int, str, datetime, ... Built-ins cover most stdlib types call the constructor drawn arguments builds() overrides one argument
Registering the small domain types is enough: aggregates are derived from their annotations and stay correct as fields are added.

A final note on debugging a strategy that misbehaves: strategy.example() draws a single value outside a test, which is the fastest way to see what a composite actually produces. It is deliberately unsuitable for use inside tests — it ignores the database, cannot shrink, and Hypothesis warns when it is called from a test function — but at a REPL it turns a strategy that is silently generating the wrong shape into a two-second check. Pair it with st.integers().map(str).validate(), which raises immediately when a strategy is malformed rather than at first draw.

Frequently Asked Questions

Why are my custom strategies so slow? Almost always because .filter() is discarding most generated examples and retrying. Replace it with @st.composite conditional routing or a pre-filtered st.sampled_from so every draw is valid by construction, and verify the rejection rate stays under 15% with --hypothesis-show-statistics.

When should I use st.builds versus @st.composite? Use st.builds for pure constructors whose fields are independent — it maps keyword strategies to arguments and resolves type hints automatically. Use @st.composite when fields are correlated, such as end_date >= start_date, because builds cannot enforce cross-field constraints.

How do I register a strategy so st.from_type() finds it automatically? Call st.register_type_strategy(YourType, your_strategy()). Hypothesis walks the MRO and checks registered strategies before built-in inference. Prefer local registration in test modules to avoid polluting the global cache in large repositories.

← Back to Advanced Property-Based Testing