Property-based testing plugin for pytest-bdd — express universal invariants in standard Gherkin, executed by Hypothesis
Project description
pytest-bdd-property
Property-based testing for Gherkin scenarios, powered by Hypothesis.
Express universal invariants in standard Gherkin, and have Hypothesis generate 100+ inputs to find counterexamples automatically.
Quick Start
1. Tag your scenario with @property-based
@property-based
Feature: Password Hashing Properties
Scenario: A password always verifies against its own hash
Given any valid password <P>
When <P> is hashed producing <H>
Then <P> verifies against <H>
2. Register your conftest step definitions
In your root conftest.py:
import pytest_bdd_property.plugin # registers pytest hooks
from pytest_bdd import given, when, then, parsers
from pytest_bdd_property.plugin import (
handle_given_step,
handle_when_step,
handle_then_step,
)
@given(parsers.re(r"(?P<text>any .+|<.+)"))
def property_given_step(text):
handle_given_step(text)
@when(parsers.re(r"(?P<text><.+)"))
def property_when_step(text):
handle_when_step(text)
@then(parsers.re(r"(?P<text><.+)"))
def property_then_step(text):
handle_then_step(text)
3. Write property step definitions
from pytest_bdd import scenarios
from pytest_bdd_property import register_strategy, property_when, property_then
from hypothesis import strategies as st
scenarios("my_feature.feature")
register_strategy("valid password", lambda: st.text(min_size=8, max_size=128))
@property_when(r"<(\w+)> is hashed producing <(\w+)>")
def hash_step(vals, results, pw_var, hash_var):
results[hash_var] = hash_password(vals[pw_var])
@property_then(r"<(\w+)> verifies against <(\w+)>")
def verify_step(vals, results, pw_var, hash_var):
assert verify_password(vals[pw_var], results[hash_var])
4. Run tests
pytest tests/ -v
Hypothesis runs each @property-based scenario 100 times with generated inputs. On failure, it shrinks to find the minimal counterexample.
How It Works
Two-Phase Execution Model
Phase 1 (Registration): During normal pytest-bdd step execution:
Given any <type> <var>→ registers a Hypothesis strategyAnd <A> is not equal to <B>→ registers an assumption (filter)When/Thensteps → register callbacks (don't execute yet)
Phase 2 (Execution): After all steps run, a pytest hook triggers:
- Hypothesis builds a composite strategy from all registered strategies
- Runs the property 100+ times with generated inputs
- Applies assumptions via
hypothesis.assume() - Executes When callbacks (actions), then Then callbacks (assertions)
- On failure: shrinks to find the minimal counterexample
Architecture
conftest.py test_my_feature.py
├── plugin hooks ├── scenarios("my.feature")
└── catch-all steps ├── register_strategy(...)
│ ├── @property_when(...)
▼ └── @property_then(...)
plugin.py
├── pytest_runtest_setup → detect @property-based tag
├── handle_given_step → strategy + assumption registration
├── handle_when_step → action callback registration
├── handle_then_step → assertion callback registration
└── pytest_runtest_call → run_property_test() via Hypothesis
Built-in Strategies
Use these directly in your feature files with Given any <type> <var>:
Primitives
| Strategy Name | Description | Hypothesis Equivalent |
|---|---|---|
text |
Arbitrary Unicode strings | st.text() |
non-empty text |
Strings with length ≥ 1 | st.text(min_size=1) |
integer |
Arbitrary integers | st.integers() |
positive integer |
Integers ≥ 1 | st.integers(min_value=1) |
negative integer |
Integers ≤ -1 | st.integers(max_value=-1) |
natural |
Integers ≥ 0 | st.integers(min_value=0) |
float |
Floats (no NaN/Infinity) | st.floats(allow_nan=False, allow_infinity=False) |
boolean |
True or False | st.booleans() |
Strings
| Strategy Name | Description | Hypothesis Equivalent |
|---|---|---|
ascii text |
ASCII-only strings | st.text(alphabet=st.characters(codec="ascii")) |
alphanumeric |
[a-z0-9]+ strings |
st.from_regex(r"[a-z0-9]+") |
hex string |
[0-9a-f]+ strings |
st.from_regex(r"[0-9a-f]+") |
Identifiers
| Strategy Name | Description | Hypothesis Equivalent |
|---|---|---|
uuid |
UUID v4 strings | st.uuids().map(str) |
email |
Email-shaped strings | st.emails() |
url |
URL-shaped strings | st.from_regex(...) |
Temporal
| Strategy Name | Description | Hypothesis Equivalent |
|---|---|---|
date |
Date objects | st.dates() |
Structured
| Strategy Name | Description | Hypothesis Equivalent |
|---|---|---|
json value |
Recursive JSON values | st.recursive(...) |
json object |
dict[str, str] |
st.dictionaries(st.text(), st.text()) |
Domain Defaults
| Strategy Name | Description | Hypothesis Equivalent |
|---|---|---|
password |
Text, 8-128 chars | st.text(min_size=8, max_size=128) |
username |
[a-z0-9_]{3,32} |
st.from_regex(...) |
Custom Strategies
Register domain-specific strategies in your test file:
from pytest_bdd_property import register_strategy
from hypothesis import strategies as st
register_strategy("valid password", lambda: (
st.tuples(
st.text(alphabet="abcdefghijklmnopqrstuvwxyz", min_size=2, max_size=10),
st.text(alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ", min_size=2, max_size=10),
st.text(alphabet="0123456789", min_size=2, max_size=5),
st.text(alphabet="!@#$%^&*", min_size=2, max_size=3),
).map(lambda parts: parts[0] + parts[1] + parts[2] + parts[3])
))
Then in Gherkin:
Given any valid password <P>
Built-in Assumptions
Use these in Given/And steps to filter generated inputs:
Equality
| Pattern | Meaning | Maps To |
|---|---|---|
<A> is not equal to <B> |
A ≠ B | assume(A != B) |
<A> is equal to <B> |
A = B | assume(A == B) |
Numeric Comparison
| Pattern | Meaning | Maps To |
|---|---|---|
<A> is greater than <B> |
A > B | assume(A > B) |
<A> is less than <B> |
A < B | assume(A < B) |
<A> is greater than or equal to <B> |
A ≥ B | assume(A >= B) |
<A> is less than or equal to <B> |
A ≤ B | assume(A <= B) |
Emptiness
| Pattern | Meaning | Maps To |
|---|---|---|
<A> is not empty |
len(A) > 0 | assume(len(A) > 0) |
<A> is empty |
len(A) = 0 | assume(len(A) == 0) |
Length
| Pattern | Meaning | Maps To |
|---|---|---|
<A> has length greater than N |
len(A) > N | assume(len(A) > N) |
<A> has length less than N |
len(A) < N | assume(len(A) < N) |
Containment
| Pattern | Meaning | Maps To |
|---|---|---|
<A> contains <B> |
B in A | assume(B in A) |
<A> does not contain <B> |
B not in A | assume(B not in A) |
Type Checks
| Pattern | Meaning | Maps To |
|---|---|---|
<A> is a number |
isinstance(A, (int, float)) | assume(isinstance(A, (int, float))) |
<A> is a string |
isinstance(A, str) | assume(isinstance(A, str)) |
Custom Assumptions
from pytest_bdd_property import register_assumption
register_assumption(
r"^<(\w+)> is a valid email$",
lambda var: lambda vals: "@" in str(vals[var])
)
Configuration via Tags
@property-based @num-runs:500 @seed:42 @verbose
Scenario: Stress test hashing
...
| Tag | Effect |
|---|---|
@num-runs:<n> |
Override number of generated examples (default: 100) |
@seed:<n> |
Fix the random seed for reproducibility |
@verbose |
Enable verbose output |
Common Patterns
Round-trip (Serialization)
@property-based
Scenario: JSON round-trip preserves data
Given any json object <D>
When <D> is serialized to JSON producing <J>
And <J> is deserialized producing <D2>
Then <D> is equal to <D2>
Idempotency
@property-based
Scenario: Normalizing email twice gives the same result
Given any email <E>
When <E> is normalized producing <N1>
And <N1> is normalized producing <N2>
Then <N1> is equal to <N2>
No False Positives
@property-based
Scenario: Wrong password never verifies
Given any valid password <P>
And any valid password <Q>
And <P> is not equal to <Q>
When <P> is hashed producing <H>
Then <Q> does not verify against <H>
No Information Leakage
@property-based
Scenario: Hash never contains plaintext
Given any valid password <P>
When <P> is hashed producing <H>
Then <H> does not contain <P>
API Reference
register_strategy(name, factory)
Register a named strategy for Given any <name> <var>.
name: Case-insensitive strategy namefactory:() -> SearchStrategycallable
property_when(pattern)
Decorator to register a When step for property-based scenarios.
@property_when(r"<(\w+)> is hashed producing <(\w+)>")
def hash_step(vals, results, pw_var, hash_var):
results[hash_var] = hash_password(vals[pw_var])
vals:dict[str, Any]— generated values keyed by variable nameresults:dict[str, Any]— intermediate results from When steps- Additional args: regex capture groups from the pattern
property_then(pattern)
Decorator to register a Then step for property-based scenarios.
@property_then(r"<(\w+)> verifies against <(\w+)>")
def verify_step(vals, results, pw_var, hash_var):
assert verify_password(vals[pw_var], results[hash_var])
Raise AssertionError to signal a property violation. Hypothesis shrinks to minimal counterexample.
register_assumption(pattern, builder)
Register a custom assumption pattern.
register_assumption(
r"^<(\w+)> is a valid email$",
lambda var: lambda vals: "@" in str(vals[var])
)
resolve_strategy(name)
Resolve a strategy name to a Hypothesis SearchStrategy. Raises ValueError if not found.
list_strategies()
Return all registered strategy names, sorted.
Coexistence with Behavioral Scenarios
Property-based and behavioral scenarios coexist naturally:
Feature: User Authentication
# Behavioral (concrete examples)
Scenario: User can log in with correct password
Given a user with password "hunter2"
When they log in with "hunter2"
Then they are authenticated
# Property (universal invariant)
@property-based
Scenario: Wrong password never authenticates
Given any text <P>
And any text <Q>
And <P> is not equal to <Q>
Given a user with password <P>
When they log in with <Q>
Then they are rejected
The @property-based tag tells the plugin to intercept execution. Scenarios without the tag run normally through pytest-bdd.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pytest_bdd_property-0.1.0.tar.gz.
File metadata
- Download URL: pytest_bdd_property-0.1.0.tar.gz
- Upload date:
- Size: 15.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.0 {"installer":{"name":"uv","version":"0.10.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
140f1dd16d37f021f863de0dfc393fa1dee6309784703603ce01435df3a6ed41
|
|
| MD5 |
4f69f91d2b01a050a830c3ff7d5196ab
|
|
| BLAKE2b-256 |
d284ab8f78ec3c62ea5bd8d9d74a9797fce26f3e6f0e9567d9aff682a1ab0860
|
File details
Details for the file pytest_bdd_property-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pytest_bdd_property-0.1.0-py3-none-any.whl
- Upload date:
- Size: 14.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.10.0 {"installer":{"name":"uv","version":"0.10.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
51f2b2521e66f4c0a4b4d49c77217e18da683bbeb5865a13a90fa1df03e6f51a
|
|
| MD5 |
0be33c892fcb1e033ae6a23b5c12b64d
|
|
| BLAKE2b-256 |
ce4ca4a93aaacd78422959552ad349556a1223ece21d1334cb2a3bd5053ed225
|