Python Hypothesis Custom Strategies with Pytest
python hypothesis custom strategies with pytest: Learn how to define custom Hypothesis strategies for property-based testing and integrate them with pytest to generate...
When you're working with python hypothesis custom strategies with pytest, the built-in strategy library covers many common data shapes, but real-world domains often require generators that respect specific invariants. A custom strategy lets you encode those invariants directly into the data generation process, so every example your test sees is valid by construction.
Why Custom Strategies Matter
Hypothesis provides a rich set of built-in strategies for integers, strings, lists, dictionaries, and more. These are useful for generic tests, but they quickly become insufficient when your code expects a particular structure. For example, a function that processes user records might require a username with a minimum length, an email address matching a pattern, and an age between 18 and 120. You could filter built-in strategies, but filtering often leads to low generation efficiency and poor shrinking. A custom strategy, on the other hand, generates only valid values from the start, which keeps the search space focused and makes failure reproduction more reliable.
Composing Strategies with .map and .filter
Before reaching for @composite, consider whether you can build what you need by transforming existing strategies. The .map method applies a function to each generated value, and .filter restricts values to those satisfying a predicate. These are the simplest building blocks for custom strategies.
from hypothesis import strategies as st # A strategy that generates even integers between 0 and 100 even_ints = st.integers(min_value=0, max_value=100).map(lambda n: n * 2) # A strategy that generates non-empty strings of lowercase letters lowercase_words = st.text(min_size=1, alphabet=st.characters(min_codepoint=97, max_codepoint=122))
The .map approach is concise and often sufficient. However, when you need to coordinate multiple values or apply complex validation, .map and .filter become awkward. That's where @composite comes in.
Building Complex Strategies with @composite
The @composite decorator turns a function into a strategy. Inside the function, you draw from other strategies using draw, and you return a value that combines those draws. This is the most flexible way to define a custom strategy because it lets you use regular Python control flow.
from hypothesis import strategies as st from hypothesis.strategies import composite @composite def user_strategy(draw): username = draw(st.text(min_size=3, max_size=20, alphabet=st.characters(whitelist_categories=('Ll', 'Lu', 'Nd')))) email = draw(st.emails()) age = draw(st.integers(min_value=18, max_value=120)) return {"username": username, "email": email, "age": age}
The draw function pulls a value from the given strategy. The decorated function returns the final object. Hypothesis treats this as a single strategy, so it participates in shrinking and example generation just like a built-in.
One important detail: the @composite function must return a value, not a strategy. If you need to return a strategy itself, you can wrap it in st.just(...) or use st.builds instead.
Integrating Custom Strategies with pytest
Hypothesis integrates with pytest through the @given decorator. You pass your custom strategy as an argument, and Hypothesis generates examples for each test invocation. The test function receives the generated value as a parameter.
from hypothesis import given def test_user_validation(user): assert len(user["username"]) >= 3 assert "@" in user["email"] assert 18 <= user["age"] <= 120
To run this with pytest, simply save the file as a test module and execute pytest. Hypothesis will run the test with a number of examples (default 100) and report any failing example as a minimal counterexample.
You can also combine @given with pytest fixtures, but be careful: the fixture and the strategy arguments are independent. A common pattern is to define the strategy as a module-level constant and reference it in @given.
Example: A Custom Strategy for a Domain Object
Let's build a more realistic example. Suppose you have a Product class with a SKU that follows a specific format: three uppercase letters, a hyphen, and four digits. You also need a price that is a positive decimal with two places. A custom strategy ensures every generated product is valid.
from hypothesis.strategies import composite, text, integers, floats @composite def product_strategy(draw): sku_prefix = draw(text(min_size=3, max_size=3, alphabet=st.characters(min_codepoint=65, max_codepoint=90))) sku_suffix = draw(integers(min_value=0, max_value=9999).map(lambda n: f"{n:04d}")) sku = f"{sku_prefix}-{sku_suffix}" price = draw(floats(min_value=0.01, max_value=1000.00).map(lambda x: round(x, 2))) return Product(sku=sku, price=price)
In your test, you can then write:
@given(product_strategy()) def test_product_total(product): assert product.price > 0 assert product.sku[3] == "-"
This strategy generates valid products exclusively. If a test fails, Hypothesis will shrink the product to the simplest failing example, which is far easier to debug than a random string that happens to violate the format.
Performance and Shrinking Considerations
Custom strategies affect two aspects of the testing experience: generation performance and shrinking quality. When you use @composite, each draw is a separate strategy invocation, which adds a small overhead compared to a single built-in strategy. This is usually negligible, but if you are generating thousands of examples, it can add up.
Shrinking is more important. Hypothesis tries to find the smallest failing example. With @composite, the shrinker works by simplifying each drawn component independently. This means the structure of your custom strategy directly influences how well shrinking works. For instance, if you use .filter heavily, the shrinker may struggle because it has to find a value that passes the filter. Prefer generating valid values directly, as we did with the SKU example, over filtering a broad strategy.
Another consideration is the use of .map with functions that are not injective. If your mapping loses information, shrinking may produce less intuitive examples. Keep transformations simple and reversible where possible.
When to Write a Custom Strategy vs. Using Built-ins
Use a custom strategy when the built-in strategies cannot express the constraints you need, or when filtering becomes so aggressive that Hypothesis spends most of its time rejecting examples. A custom strategy is also appropriate when you want to reuse the same generation logic across multiple tests.
On the other hand, if a simple .map or .filter on a built-in strategy suffices, prefer that. It is less code to maintain and leverages Hypothesis's internal optimizations. For example, st.emails() already generates valid email addresses, so you don't need to build an email strategy from scratch.
The decision comes down to the complexity of the invariants. If your data requires coordinated constraints across multiple fields, @composite is the right tool. If you only need to add a small transformation, use .map.
A custom strategy also gives you a single place to update when the domain rules change. Instead of editing every test that constructs a product, you modify the strategy and all tests automatically use the new rules. This centralization is a strong maintainability benefit, especially in larger codebases where property-based tests are numerous.