Numerical code breaks on inputs nobody writes by hand: an empty array, a single row, a column of identical values, a float that is exactly representable in one dtype and not another, a NaN in the one position the algorithm did not expect. Example-based tests cover the shapes the author thought of; Hypothesis's extra.numpy and extra.pandas modules generate arrays and frames across shapes, dtypes and element values, and then shrink any failure to the smallest one that still breaks.
The strategies are declarative — dtype, shape bounds, element strategy — and most of the skill is in constraining them to the inputs the code is supposed to handle while leaving enough variety to find the ones it mishandles. A deliberate policy on NaN and infinity, chosen per property, is the single most important decision. Get it wrong in one direction and every property has to hedge against values that make it meaningless; get it wrong in the other and the suite never sees the missing values real data is full of.
Prerequisites
hypothesis[numpy,pandas] >= 6.100, NumPy and pandas.- The strategy-design principles from designing strategies for domain data.
- The float edge cases in testing numeric code with floats and NaN edge cases.
Solution
import numpy as np
import pandas as pd
from hypothesis import given, settings, strategies as st
from hypothesis.extra import numpy as npst
from hypothesis.extra import pandas as pdst
finite = st.floats(min_value=-1e6, max_value=1e6, allow_nan=False, allow_infinity=False)
@given(npst.arrays(dtype=np.float64,
shape=npst.array_shapes(min_dims=1, max_dims=2, max_side=20),
elements=finite))
def test_normalise_preserves_shape_and_is_bounded(arr):
out = normalise(arr)
assert out.shape == arr.shape
assert np.all((out >= 0.0) & (out <= 1.0) | np.isnan(out))
orders = pdst.data_frames(
columns=[
pdst.column("order_id", dtype=str, elements=st.text(min_size=1, max_size=8),
unique=True),
pdst.column("amount_minor", dtype=np.int64,
elements=st.integers(min_value=0, max_value=10_000_000)),
pdst.column("currency", elements=st.sampled_from(["GBP", "USD", "JPY"])),
],
index=pdst.range_indexes(min_size=0, max_size=30), # includes the empty frame
)
@settings(max_examples=150)
@given(orders)
def test_totals_by_currency_match_a_plain_python_sum(df):
result = totals_by_currency(df)
for currency, group in df.groupby("currency"):
assert result[currency] == int(group["amount_minor"].sum())
Why this works
npst.arrays draws a shape from the shape strategy, then fills an array of that shape with values from the element strategy, cast to the dtype. Because shape and elements are both strategies, shrinking reduces both: a failure on a 17×9 array of large floats shrinks to the smallest shape and simplest values that still fail — often a 1×1 array containing zero, or an empty array, which points straight at the bug.
pdst.data_frames does the same per column and builds the frame through pandas, so the result has real dtypes and a real index. Declaring columns explicitly makes generated frames schema-valid by construction, and range_indexes(min_size=0) guarantees the empty frame is among the inputs — the case most groupby and aggregation code gets wrong first.
Edge cases and failure modes
- Unbounded floats.
st.floats()includes values near1e308whose sums overflow to infinity. Bound them to the range the code handles. - NaN excluded everywhere. Excluding NaN in every strategy hides the bugs where real data contains it. Keep at least one property that allows it.
- Large frames. Big
max_sizevalues make each example and every shrink step slow. Twenty or thirty rows find nearly all bugs. - Object dtypes. Columns without a dtype default to object, which pandas treats differently from typed columns. Give every column an explicit dtype matching production.
- Comparing floats exactly. A reference computed in a different order can differ in the last bit. Use
np.allcloseormath.isclosewith a tolerance justified by the algorithm.
Dtype-specific surprises generation finds
A large share of the bugs these strategies find are not logic errors but dtype behaviour the author did not expect, and knowing the usual suspects makes the shrunk counterexamples quicker to read.
Integer overflow without an error. NumPy integer arithmetic wraps silently: np.int32(2**31 - 1) + 1 is a large negative number, not an exception. A sum over a generated int32 column with large values produces a negative total, and the counterexample is typically two elements near the dtype's maximum. The fix is an explicit wider dtype for the accumulation, and the test is what reveals that one is needed.
Float precision across dtypes. Values exactly representable in float64 may round in float32, so a round trip through a float32 column changes them. Properties comparing before and after a cast need a tolerance, or a strategy restricted to values representable in the narrower type — st.floats(width=32) generates exactly those.
Signed zero and NaN comparisons. -0.0 == 0.0 is true but they sort and hash differently in some contexts; NaN != NaN breaks any equality-based deduplication. A generated column containing both zeros or a NaN will find every place that assumes otherwise.
Nullable versus NumPy dtypes in pandas. Int64 with capital I supports missing values, int64 does not, and a column that acquires a missing value is silently upcast to float64. Generating frames with an occasional None in an integer column exposes the upcast, which usually shows up downstream as a comparison failing on 1.0 != 1.
Reference implementations as the property
The hardest part of property-testing numerical code is stating a property at all — "the output is correct" is not checkable without an oracle. The most productive oracle is a slow, obvious implementation of the same computation written in plain Python, with the vectorised or optimised version tested for agreement against it on generated inputs.
That is the pattern in the DataFrame test above: totals_by_currency might use a vectorised groupby with categorical dtypes and a pre-sorted index, while the check uses a loop over groups that nobody could get wrong. Any disagreement is a bug in the fast version — or occasionally a precision question worth understanding — and Hypothesis shrinks it to the smallest frame that disagrees, typically two or three rows that make the cause obvious.
The reference need not be efficient, and should not be clever. Its only job is to be obviously correct for small inputs, which is exactly the size Hypothesis generates and shrinks to. The same idea applies to any optimised numerical routine: a moving average against an explicit window loop, a matrix operation against nested loops, a custom aggregation against its textbook definition. It is the property-based equivalent of a golden reference, without the need to store golden outputs.
Keeping generation affordable
Array and frame strategies are the most expensive in Hypothesis, and a suite that adopts them carelessly can add minutes to every run. Three habits keep the cost proportionate to the value.
Bound sizes aggressively. Bugs in numerical code almost always reproduce on tiny inputs — the shrinker proves this every time it reduces a failure to a 2×1 array — so max_side=20 or a thirty-row frame finds essentially everything a thousand-row one would, at a fraction of the cost. Large-input behaviour such as performance or memory belongs in a benchmark, not a property test. Keeping the two concerns separate keeps both fast.
Generate only the columns the property reads. A frame with twelve columns where the property touches two spends most of its time building data nobody checks. Declaring the two, and filling the rest with constants in the function under test's fixture if it requires them, keeps each example cheap.
Use profiles to scale the budget. A development profile with fifty examples keeps the edit loop fast; the CI profile runs a few hundred; a nightly profile runs thousands with larger shapes. Nothing about the tests changes between them — not the strategies, not the assertions — only the search budget, which is the approach set out in Hypothesis integration with pytest and frameworks.
Frequently Asked Questions
Should generated floats include NaN and infinity?
Decide per property. A property about shape or dtype holds with NaN present and should include it; a property about sums, means or sorting usually needs finite values and should exclude them explicitly with allow_nan=False and allow_infinity=False. Test the non-finite behaviour in its own property rather than weakening every assertion.
Why is DataFrame generation slow?
Because every example builds a full frame through pandas, and shrinking rebuilds many more. Keep row counts small with max_size, restrict columns to what the property needs, and prefer generating arrays and constructing the frame yourself when the schema is simple.
How do I generate a DataFrame that matches a real schema?
Declare each column with hypothesis.extra.pandas.column, giving its dtype and an element strategy that respects the domain — non-negative amounts, a fixed set of currency codes — and optionally unique=True. The resulting frames are valid by construction.
Related
- Designing Strategies for Domain Data — constraining generation at the source.
- Testing Numeric Code with Floats and NaN Edge Cases — the float behaviour these strategies expose.
- Writing Metamorphic Properties — properties for numerical code without an oracle.
- Reducing Hypothesis Test Execution Time — keeping frame generation affordable.
← Back to Designing Strategies for Domain Data