You need property-based testing with Hypothesis to generate data whose parts are not independent: a list and a valid index into it, a date range whose end is after its start, a discriminated union whose payload depends on its tag. Drawing the parts separately produces nonsense — an index past the end of the list, an end date before the start. The fix is strategy composition: map, filter, flatmap, builds, and @st.composite each transform generation differently, and reaching for the wrong one is the difference between clean generation and a starved, health-check-tripping test. This guide draws the boundaries.
Prerequisites
hypothesis >= 6.0, Python 3.9+- The strategy basics from Hypothesis Framework Fundamentals and the custom-generator patterns in Generating Custom Strategies with hypothesis.strategies.
Solution
All five combinators import from hypothesis.strategies (aliased st):
from hypothesis import given, strategies as st
map — apply a pure function to one drawn value. No dependency between values.
# Generate even integers by doubling any integer.
even_ints = st.integers().map(lambda n: n * 2)
@given(even_ints)
def test_even(n):
assert n % 2 == 0
filter — reject drawn values that fail a predicate. Cheap only when the predicate passes most of the time; an over-strict filter starves the generator and trips a health check.
# Fine: roughly half of integers pass.
nonzero = st.integers().filter(lambda n: n != 0)
@given(nonzero)
def test_nonzero(n):
assert n != 0
flatmap — the key tool for interdependent values. flatmap(fn) draws a value, then calls fn(value) which must return a new strategy for the dependent part. Classic case: a list plus a valid index into it.
# Draw a non-empty list, THEN draw an index that is valid for THAT list.
def list_and_index(xs):
# xs is already drawn; build a strategy for a valid index into it.
return st.tuples(st.just(xs), st.integers(min_value=0, max_value=len(xs) - 1))
list_with_valid_index = st.lists(st.integers(), min_size=1).flatmap(list_and_index)
@given(list_with_valid_index)
def test_index_in_bounds(pair):
xs, i = pair
assert xs[i] == xs[i] # never IndexError: i depends on len(xs)
If you tried this with two independent strategies (st.tuples(st.lists(...), st.integers())), most generated indices would be out of bounds and you would be forced into an assume() that discards most examples. flatmap makes the dependency structural.
builds — construct an object by drawing each constructor argument independently. Ideal for flat objects with no cross-field constraints.
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
points = st.builds(Point, x=st.integers(), y=st.integers())
@given(points)
def test_point(p):
assert isinstance(p, Point)
@st.composite — imperative, multi-draw generation when several fields depend on each other. The decorated function receives a draw callable as its first argument; each draw(strategy) pulls a concrete value, and you combine them however you like.
@st.composite
def date_range(draw):
# draw() pulls concrete values; later draws can depend on earlier ones.
start = draw(st.integers(min_value=0, max_value=10_000))
span = draw(st.integers(min_value=0, max_value=365))
end = start + span # guaranteed end >= start by construction
return (start, end)
@given(date_range())
def test_range_ordered(r):
start, end = r
assert end >= start
A discriminated union — payload depends on the tag — is the canonical @composite shape, and shows why it beats flatmap once there are several interdependent draws:
@st.composite
def event(draw):
kind = draw(st.sampled_from(["login", "purchase"]))
if kind == "login":
payload = {"session": draw(st.uuids())}
else:
payload = {"amount": draw(st.integers(min_value=1, max_value=10_000))}
return {"kind": kind, "payload": payload}
@given(event())
def test_event_shape(e):
if e["kind"] == "purchase":
assert e["payload"]["amount"] >= 1
The difference between the two is where the dependency between draws is expressed.
Why this works
map and filter are single-value transforms — one in, one out — so they cannot express that a second value depends on a first. flatmap closes that gap by handing you the drawn value and letting you return a tailored strategy, which is exactly enough for one dependency. @st.composite generalizes this to arbitrarily many interdependent draws via repeated draw() calls, and because Hypothesis records every draw, it can still shrink a composite example to its minimal failing form. builds is the optimized path for the independent case and should be preferred over @composite when no field references another, because it lets Hypothesis use its internal flat generators.
Edge cases and failure modes
filterstarvation. A predicate that rejects most candidates raisesHealthCheck.filter_too_much; move the constraint into generation (bounded strategy or@composite) instead. See fixing Hypothesis FlakyHealthCheck failures.flatmapshrinks worse than@composite. Deeply nestedflatmapchains can shrink poorly; for three or more interdependent values, prefer@composite, which Hypothesis shrinks more effectively.- Calling
draw()outside@composite.drawis only valid inside a@st.compositefunction; using it elsewhere raises an error. Conversely, never call a composite strategy'sdrawargument yourself — Hypothesis supplies it. buildswith interdependent fields. Usingbuildswhen fields must agree produces invalid objects; switch to@compositethe moment one field's range depends on another's value.mapwith impure functions.mapshould be a pure transform; side effects run on every generation and every shrink attempt, which is wasteful and can corrupt shared state.
Keeping composite strategies shrinkable
A composite strategy that generates good data but shrinks badly is worse than a simpler one, because the counterexample it reports is too large to read. Three habits keep shrinking effective.
Draw in dependency order and nothing else. Every draw() call is a decision Hypothesis can simplify, and it simplifies them roughly in the order they were made. A strategy that draws a size, then draws that many elements, shrinks the size first and the elements second, which produces the minimal failing input naturally. A strategy that draws the elements first and then derives a size from them gives the shrinker nothing to pull on.
Filter narrowly, and prefer construction over rejection. assume() and .filter() discard whole examples, and a filter that rejects most draws both slows generation and blocks shrinking, because the shrinker's smaller candidates keep getting rejected. Build the constraint into the draw instead:
from hypothesis import strategies as st
# Rejection: most draws are thrown away, and shrinking stalls.
bad = st.tuples(st.integers(), st.integers()).filter(lambda t: t[0] < t[1])
# Construction: every draw is valid by design, and both values shrink freely.
@st.composite
def ordered_pair(draw, min_value=0, max_value=1000):
low = draw(st.integers(min_value=min_value, max_value=max_value - 1))
high = draw(st.integers(min_value=low + 1, max_value=max_value))
return low, high # invariant holds by construction, not by luck
Return plain data, not objects that hide their provenance. A composite that returns a fully built domain object is convenient, but when the test fails, Hypothesis prints the object's repr. If that repr is uninformative, the counterexample tells you nothing. Either give the object a faithful repr, or return the primitive draws alongside it so the report shows what was generated.
@st.composite
def order_with_lines(draw):
n = draw(st.integers(min_value=1, max_value=5)) # shrinks toward 1
prices = draw(st.lists(st.integers(1, 10_000), min_size=n, max_size=n))
# Returning both keeps the failing report readable.
return {"line_count": n, "prices": prices}
The payoff is visible the first time a suite fails on real data: a well-ordered composite reports {"line_count": 1, "prices": [1]} where a rejection-heavy one reports a twelve-element list that happens to contain the trigger.
Frequently Asked Questions
When should I use flatmap instead of map in Hypothesis?
Use map when the transformation is a pure function of one drawn value. Use flatmap when a later value depends on an earlier drawn value, because flatmap receives the drawn value and must return a new strategy for the dependent part.
What does the draw function do inside an @st.composite strategy?draw pulls a concrete value from another strategy at generation time. Calling draw multiple times lets you build interdependent values imperatively, and Hypothesis records every draw so it can shrink the composite example correctly.
How is builds() different from @composite?builds() constructs an object by drawing each argument from a strategy independently and calling the target, ideal for flat objects with no cross-field constraints. @composite is for imperative, interdependent generation where one field's strategy depends on another field's drawn value.
Related
These combinators are the building blocks for generating custom strategies with hypothesis.strategies, where the same routing pattern keeps objects valid by construction. When a @composite chain slows a suite down, see reducing Hypothesis test execution time, and when an over-strict filter trips a health check, fixing Hypothesis FlakyHealthCheck failures covers the diagnosis. The discriminated-union @composite shape here also underpins modeling REST APIs as state machines, where each transition draws a payload conditioned on the chosen command.
← Back to Advanced Property-Based Testing