Testing with hypothesis#
This guide shows how to use the unxt-hypothesis package for property-based testing of code that uses unxt quantities.
What is Property-Based Testing?#
Property-based testing is a testing methodology where you specify properties that should hold true for all inputs, and the testing framework (Hypothesis) generates random test cases to verify those properties.
Instead of writing:
import unxt as u
def test_addition():
x, y = u.Q(5, "m"), u.Q(3, "m")
assert x + y == y + x
You write:
from hypothesis import given
import unxt_hypothesis as ust
@given(q1=ust.quantities("m"), q2=ust.quantities("m"))
def test_addition_commutative(q1, q2):
"""Addition of Quantity is commutative."""
assert q1 + q2 == q2 + q1
Hypothesis will generate random test cases with different values, uncovering edge cases you might not have thought of.
Installation#
pip install unxt-hypothesis
```bash
uv add unxt-hypothesis
Basic Examples#
from hypothesis import given, assume, strategies as st
import unxt_hypothesis as ust
import unxt as u
import jax
import jax.numpy as jnp
Testing Quantity Properties#
@given(q=ust.quantities()) # any Quantity
def test_quantity_has_value_and_unit(q):
"""Every Quantity has a value and a unit."""
assert q.value is not None
assert q.unit is not None
@given(q=ust.quantities("m"))
def test_quantity_has_meter_units(q):
"""Scalar Quantity have ndim of 0."""
assert q.unit == u.unit("m")
@given(q=ust.quantities(shape=(3,)))
def test_vector_quantity_has_correct_shape(q):
"""Vector Quantity have the expected shape."""
assert q.shape == (3,)
Testing Physical Laws#
@given(q1=ust.quantities("m", shape=()), q2=ust.quantities("m", shape=()))
def test_addition_commutative(q1, q2):
"""Addition of lengths is commutative."""
assert jnp.allclose((q1 + q2).value, (q2 + q1).value)
@given(q=ust.quantities("m", shape=()))
def test_multiplication_identity(q):
"""Multiplying by 1 (dimensionless) preserves the quantity."""
one = u.Q(1.0, "")
result = q * one
assert result.unit == q.unit
assert jnp.allclose(result.value, q.value)
@given(mass=ust.quantities("kg", shape=()), velocity=ust.quantities("m/s", shape=()))
def test_kinetic_energy_units(mass, velocity):
"""Kinetic energy has correct units."""
ke = 0.5 * mass * velocity**2
# Energy has dimension of mass * length^2 / time^2
assert u.dimension_of(ke) == "energy"
Testing Unit Conversions#
@given(q=ust.quantities("m"))
def test_length_conversion_reversible(q):
"""Converting to another length unit and back is reversible."""
in_km = q.uconvert("km")
back_to_m = in_km.uconvert("m")
assert jnp.allclose(q.value, back_to_m.value, rtol=1e-5)
@given(q=ust.quantities("m"))
def test_conversion_preserves_dimension(q):
"""Unit conversion preserves physical dimension."""
converted = q.uconvert("km")
assert u.dimension_of(converted) == u.dimension_of(q)
Testing Array Operations#
@given(q=ust.quantities(shape=st.integers(1, 20)))
def test_sum_reduces_dimension(q):
"""Summing a quantity reduces one dimension."""
total = jnp.sum(q)
assert total.ndim == 0
assert total.unit == q.unit
@given(q=ust.quantities(shape=(5, 5)))
def test_transpose_shape(q):
"""Transposing a matrix quantity swaps dimensions."""
qt = jnp.transpose(q)
assert qt.shape == (5, 5)
assert qt.unit == q.unit
@given(q1=ust.quantities(shape=(3, 4)), q2=ust.quantities(shape=(4, 5)))
def test_matrix_multiplication_shape(q1, q2):
"""Matrix multiplication produces correct shape."""
# Make dimensionless for matrix multiplication
q1_dimensionless = q1.ustrip("")
q2_dimensionless = q2.ustrip("")
result = jnp.matmul(q1_dimensionless, q2_dimensionless)
assert result.shape == (3, 5)
Using Type-Based Strategies with st.from_type()#
The unxt-hypothesis package automatically registers type strategies for Hypothesis’s st.from_type() function. This allows you to use type annotations directly in your tests, and Hypothesis will automatically generate appropriate test data.
Important: You must import unxt_hypothesis to activate the type strategy registrations, even if you don’t use it directly:
from hypothesis import given, strategies as st
import unxt as u
import unxt_hypothesis as ust # Must import to register strategies
Registered Quantity Types#
The following quantity types are automatically registered:
# AbstractQuantity generates Quantity instances
@given(q=st.from_type(u.AbstractQuantity))
def test_any_quantity(q):
"""Test with any Quantity type."""
assert isinstance(q, u.AbstractQuantity)
assert u.dimension_of(q) is not None
# Quantity generates Quantity instances with dimension checking
@given(q=st.from_type(u.Quantity))
def test_regular_quantity(q):
"""Test with standard Quantity instances."""
assert isinstance(q, u.Quantity)
# BareQuantity generates instances without dimension checking
@given(bq=st.from_type(u.quantity.BareQuantity))
def test_bare_quantity(bq):
"""Test with BareQuantity (no dimension checks)."""
assert isinstance(bq, u.quantity.BareQuantity)
assert bq.unit is not None
# StaticQuantity generates instances with StaticValue wrapper
@given(sq=st.from_type(u.quantity.StaticQuantity))
def test_static_quantity(sq):
"""Test with StaticQuantity (non-traced values)."""
assert isinstance(sq, u.quantity.StaticQuantity)
# StaticQuantity uses StaticValue wrapper
assert isinstance(sq.value, u.quantity.StaticValue)
Registered Angle Type#
The Angle type is registered with the angle dimension:
@given(angle=st.from_type(u.Angle))
def test_angle(angle):
"""Test with Angle instances."""
assert isinstance(angle, u.Angle)
assert u.dimension_of(angle) == u.dimension("angle")
Registered Unit System Type#
Unit systems can also be generated via st.from_type():
@given(sys=st.from_type(u.AbstractUnitSystem))
def test_unit_system(sys):
"""Test with generated unit systems."""
assert isinstance(sys, u.AbstractUnitSystem)
When to Use st.from_type() vs Explicit Strategies#
Use st.from_type() when:
You want tests to be concise and type-focused
You’re testing generic code that works with any quantity
You don’t need to control specific properties (shape, unit, etc.)
Use explicit strategies (e.g., ust.quantities()) when:
You need specific units or dimensions
You need specific shapes or dtypes
You want to control value ranges with
elementsYou need reproducible tests with particular configurations
Example combining both approaches:
# Generic test using st.from_type()
@given(q1=st.from_type(u.Quantity), q2=st.from_type(u.Quantity))
def test_quantity_equality_reflexive(q1, q2):
"""Quantity equality is reflexive."""
assert q1 == q1
assert q2 == q2
# Specific test using explicit strategy
@given(
pos=ust.quantities("kpc", shape=(3,)),
vel=ust.quantities("km/s", shape=(3,)),
)
def test_phase_space_vectors(pos, vel):
"""Test phase space with specific units and shapes."""
assert pos.shape == (3,)
assert vel.shape == (3,)
assert u.dimension_of(pos) == u.dimension("length")
assert u.dimension_of(vel) == u.dimension("velocity")
Intermediate Examples#
Using Unit Strategies#
@given(length_unit=ust.units("length"), q=ust.quantities(ust.units("length")))
def test_all_lengths_convertible(length_unit, q):
"""All length Quantity can convert to any length unit."""
converted = q.uconvert(length_unit)
assert u.dimension_of(converted) == "length"
@given(velocity_unit=ust.units("velocity", max_complexity=1))
def test_simple_velocity_units(velocity_unit):
"""Simple velocity units have expected dimension."""
assert u.dimension_of(velocity_unit) == "velocity"
# Can decompose into length/time
decomposed = u.unit(velocity_unit).decompose()
assert "m" in str(decomposed) or "km" in str(decomposed)
assert "s" in str(decomposed)
Using Dimensions to Generate Units#
You can pass a dimension directly to quantities() to generate quantities with varying units of that dimension:
@given(q=ust.quantities(u.dimension("length"), shape=3))
def test_length_quantities_from_dimension(q):
"""Test generating length quantities from dimension."""
# Will create quantities with different length units (m, km, etc.)
assert u.dimension_of(q) == u.dimension("length")
assert q.shape == (3,)
@given(
pos=ust.quantities(u.dimension("length"), shape=(3,)),
vel=ust.quantities(u.dimension("velocity"), shape=(3,)),
)
def test_phase_space_from_dimensions(pos, vel):
"""Test creating phase space coordinates from dimensions."""
# Units will vary, but dimensions are guaranteed
assert u.dimension_of(pos) == u.dimension("length")
assert u.dimension_of(vel) == u.dimension("velocity")
Using Dimension Strategies#
For even more flexibility, use strategies that generate different dimensions:
@given(
q=ust.quantities(
st.sampled_from([u.dimension("length"), u.dimension("mass")]),
shape=(),
)
)
def test_mixed_dimension_quantities(q):
"""Test with quantities of different dimensions."""
dim = u.dimension_of(q)
assert dim in (u.dimension("length"), u.dimension("mass"))
@given(
q=ust.quantities(
st.sampled_from([u.dimension("velocity"), u.dimension("acceleration")]),
shape=3,
)
)
def test_kinematic_vectors(q):
"""Test vectors that could be velocity or acceleration."""
dim = u.dimension_of(q)
assert dim in (u.dimension("velocity"), u.dimension("acceleration"))
assert q.shape == (3,)
Constraining Value Ranges with Elements#
The elements parameter allows you to control the range of values in generated quantities. This is particularly useful for physical quantities with natural constraints.
Important: When using custom elements strategies with float32 dtype (the default), always specify width=32 in st.floats() to ensure compatibility with JAX’s array API.
Positive Distances#
Distances are always non-negative:
@given(
q=ust.quantities(
unit="kpc",
shape=3,
elements=st.floats(min_value=0, max_value=100, width=32),
)
)
def test_galactic_positions(q):
"""Test 3D positions with reasonable galactic distances."""
assert jnp.all(q.value >= 0)
assert jnp.all(q.value <= 100)
# Use position vector for some calculation
distance = jnp.linalg.norm(q.value)
assert distance >= 0
Longitude Angles (0 to 360°)#
Longitude angles are typically constrained to [0, 360] degrees:
@given(
lon=ust.quantities(
unit="deg",
shape=(),
elements=st.floats(min_value=0, max_value=360, allow_nan=False, width=32),
)
)
def test_longitude_wrapping(lon):
"""Test longitude angle operations."""
assert 0 <= lon.value <= 360
# Test that wrapping works correctly
wrapped = lon.value % 360
assert 0 <= wrapped < 360
Latitude Angles (-90 to 90°)#
Latitude angles are constrained to [-90, 90] degrees:
@given(
lat=ust.quantities(
unit="deg",
shape=100,
elements=st.floats(min_value=-90, max_value=90, allow_nan=False, width=32),
)
)
def test_latitude_constraints(lat):
"""Test latitude angle array."""
assert jnp.all(lat.value >= -90)
assert jnp.all(lat.value <= 90)
# cos(lat) should always be positive
assert jnp.all(jnp.cos(jnp.deg2rad(lat.value)) >= 0)
Physical Scales#
Constrain values to physically meaningful ranges:
@given(
radius=ust.quantities(
unit="m",
shape=(),
elements=st.floats(min_value=1e-3, max_value=1e3, allow_nan=False, width=32),
)
)
def test_realistic_radius(radius):
"""Test with radii from millimeters to kilometers."""
assert 1e-3 <= radius.value <= 1e3
# Physical calculations
area = 4 * 3.14159 * radius.value**2
assert area > 0
Using Dtype Strategies#
The dtype parameter can also be a strategy, allowing you to test across different numeric types:
@given(
q=ust.quantities(
unit="m",
dtype=st.sampled_from([jnp.float32, jnp.float64]),
shape=(3,),
)
)
def test_precision_independence(q):
"""Test that operations work with different precisions."""
assert q.dtype in (jnp.float32, jnp.float64)
# Operation should work regardless of dtype
norm = jnp.linalg.norm(q.value)
assert jnp.isfinite(norm)
@given(
q=ust.quantities(
unit="rad",
dtype=st.sampled_from([jnp.float32, jnp.float64, jnp.complex64]),
shape=(),
)
)
def test_angle_with_various_dtypes(q):
"""Test that angle operations handle different dtypes."""
# Even complex dtypes might be used in some contexts
assert q.dtype in (jnp.float32, jnp.float64, jnp.complex64)
Combining Strategies#
You can combine dimension and dtype strategies for comprehensive testing:
@given(
q=ust.quantities(
st.sampled_from([u.dimension("length"), u.dimension("time")]),
dtype=st.sampled_from([jnp.float32, jnp.float64]),
shape=(5,),
)
)
def test_combined_strategies(q):
"""Test with varying dimensions and dtypes."""
# Both dimension and dtype will vary across test runs
dim = u.dimension_of(q)
assert dim in (u.dimension("length"), u.dimension("time"))
assert q.dtype in (jnp.float32, jnp.float64)
assert q.shape == (5,)
Using Unit Systems#
@given(sys=ust.unitsystems("m", "s", "kg", "rad"))
def test_mks_system_consistency(sys):
"""MKS unit system has expected properties."""
assert len(sys) == 4
# Each dimension is represented
dims = [str(u) for u in sys]
assert "m" in dims
assert "s" in dims
assert "kg" in dims
assert "rad" in dims
@given(
sys=ust.unitsystems(ust.units("length"), "s", "kg", "rad"),
q=ust.quantities(ust.units("length")),
)
def test_quantity_in_system_units(sys, q):
"""Quantities can be expressed in system units."""
# The quantity should be expressible in the system's length unit
length_unit = list(sys)[0]
converted = q.uconvert(length_unit)
assert u.dimension_of(converted) == "length"
Advanced Patterns#
Testing Angle Quantities#
Use the quantities() strategy with quantity_cls=u.Angle to generate angle quantities. For wrapping angles to a specific range, use the wrap_to() strategy.
@given(angle=ust.quantities("rad", quantity_cls=u.Angle))
def test_angle_is_angle_type(angle):
"""Generated angles are Angle instances."""
assert isinstance(angle, u.Angle)
assert u.dimension_of(angle) == u.dimension("angle")
@given(angle=ust.quantities("deg", quantity_cls=u.Angle, shape=()))
def test_angle_in_degrees(angle):
"""Angles can be generated in different units."""
assert angle.unit == u.unit("deg")
Wrapping Quantities to a Range#
Use the wrap_to() strategy to wrap generated quantities to a specific [min, max) range. This is particularly useful for angular quantities like longitude and latitude:
@given(
lon=ust.wrap_to(
ust.quantities("deg", quantity_cls=u.Angle),
min=u.Q(0, "deg"),
max=u.Q(360, "deg"),
)
)
def test_longitude_range(lon):
"""Longitude angles wrapped to [0, 360) degrees."""
assert isinstance(lon, u.Angle)
assert 0 <= lon.value < 360
@given(
lat=ust.wrap_to(
ust.quantities("deg", quantity_cls=u.Angle, shape=()),
min=u.Q(-90, "deg"),
max=u.Q(90, "deg"),
)
)
def test_latitude_range(lat):
"""Latitude angles wrapped to [-90, 90) degrees."""
assert isinstance(lat, u.Angle)
assert -90 <= lat.value < 90
The wrap_to() strategy can wrap any quantity, not just angles:
@given(
distance=ust.wrap_to(
ust.quantities("kpc", shape=10), min=u.Q(0, "kpc"), max=u.Q(100, "kpc")
)
)
def test_distance_range(distance):
"""Distances wrapped to [0, 100) kpc."""
assert jnp.all(distance.value >= 0)
assert jnp.all(distance.value < 100)
Using the quantity_cls Parameter#
The quantity_cls parameter controls the type of quantity object created. By default, it’s u.Quantity, but you can specify u.Angle or other quantity subclasses:
# Generate Angle objects
@given(angle=ust.quantities("rad", quantity_cls=u.Angle, shape=3))
def test_angle_generation(angle):
"""Generate Angle instances using quantity_cls."""
assert isinstance(angle, u.Angle)
assert angle.unit == u.unit("rad")
assert angle.shape == (3,)
# Generate plain Quantity objects (default)
@given(distance=ust.quantities("kpc", shape=()))
def test_distance_generation(distance):
"""Generate Quantity instances (default quantity_cls)."""
assert isinstance(distance, u.Quantity)
assert distance.unit == u.unit("kpc")
# Combine with other parameters
@given(
angle=ust.quantities(
"deg",
quantity_cls=u.Angle,
dtype=jnp.float64,
elements=st.floats(min_value=0, max_value=360, width=64),
)
)
def test_angle_with_constraints(angle):
"""Combine quantity_cls with dtype and element constraints."""
assert isinstance(angle, u.Angle)
assert angle.dtype == jnp.float64
assert 0 <= angle.value <= 360
Testing Coordinate Transformations#
@given(
x=ust.quantities("m", shape=()),
y=ust.quantities("m", shape=()),
z=ust.quantities("m", shape=()),
)
def test_cartesian_to_spherical_radius(x, y, z):
"""Spherical radius is always non-negative."""
r = jnp.sqrt(x**2 + y**2 + z**2)
assert jnp.all(r.value >= 0)
assert u.dimension_of(r) == "length"
@given(
r=ust.quantities("m", shape=()),
theta=ust.quantities(
"rad",
quantity_cls=u.Angle,
elements=st.floats(min_value=0, max_value=3.14159, width=32),
),
phi=ust.quantities(
"rad",
quantity_cls=u.Angle,
elements=st.floats(min_value=0, max_value=6.28318, width=32),
),
)
def test_spherical_to_cartesian_reversible(r, theta, phi):
"""Converting spherical to cartesian and back is reversible."""
assume(r.value > 1e-10) # Avoid numerical issues at origin
# Convert to cartesian
x = r * jnp.sin(theta.value) * jnp.cos(phi.value)
y = r * jnp.sin(theta.value) * jnp.sin(phi.value)
z = r * jnp.cos(theta.value)
# Convert back
r_back = jnp.sqrt(x**2 + y**2 + z**2)
assert jnp.allclose(r.value, r_back.value, rtol=1e-5)
Testing JAX Transformations#
@given(q=ust.quantities("m", shape=(10,)))
def test_vmap_preserves_units(q):
"""vmap over ust.quantities preserves units."""
def square(x):
return x**2
# Apply vmap
squared = jax.vmap(square)(q)
assert squared.shape == q.shape
assert squared.unit == q.unit**2
@given(q=ust.quantities("m", shape=()))
def test_grad_units(q):
"""Gradient of x^2 has correct units."""
def f(x):
return (x**2).ustrip("m^2")
# Gradient w.r.t. a length should give length
grad_f = jax.grad(f)
result = grad_f(q.value)
# Result should be 2*x, so same unit as input
expected = 2 * q.value
assert jnp.allclose(result, expected, rtol=1e-5)
Filtering Invalid Cases#
Use hypothesis.assume() to skip test cases that don’t make sense:
@given(
numerator=ust.quantities("m", shape=()), denominator=ust.quantities("s", shape=())
)
def test_division_units(numerator, denominator):
"""Division produces correct units."""
# Skip cases where denominator is too close to zero
assume(jnp.abs(denominator.value) > 1e-10)
result = numerator / denominator
assert u.dimension_of(result) == "velocity"
@given(q=ust.quantities("m"))
def test_positive_values_only(q):
"""Test function that only works with positive values."""
assume(jnp.all(q.value > 0))
# Now safe to take logarithm
log_q = jnp.log(q.value)
assert jnp.all(jnp.isfinite(log_q))
Best Practices#
1. Start Simple#
Begin with simple properties before testing complex behaviors:
@given(q=ust.quantities())
def test_quantity_repr(q):
"""Quantities have a string representation."""
assert repr(q) is not None
assert "Quantity" in repr(q)
2. Use Appropriate Assumptions#
Don’t overuse assume() as it can slow down tests. Instead, generate appropriate data:
# Instead of this:
@given(q=ust.quantities("m"))
def test_bad(q):
assume(q.value > 0) # Will reject many cases
# ...
# Do this:
@given(
q=ust.quantities(
unit="m",
shape=st.just(()),
)
)
def test_good(q):
# Generate only positive values if needed
q_positive = abs(q)
# ...
3. Test Properties, Not Implementations#
Focus on what should be true, not how it’s computed:
# Good - tests a property
@given(q1=ust.quantities("m"), q2=ust.quantities("m"))
def test_addition_commutative(q1, q2):
assert jnp.allclose((q1 + q2).value, (q2 + q1).value)
# Less good - tests implementation
@given(q1=ust.quantities("m"), q2=ust.quantities("m"))
def test_addition_calls_add(q1, q2):
with mock.patch("jax.numpy.add") as mock_add:
q1 + q2
mock_add.assert_called_once() # Too implementation-specific
4. Use Descriptive Test Names#
Make it clear what property is being tested:
@given(q=ust.quantities("m"))
def test_length_conversion_to_km_preserves_magnitude_within_tolerance(q):
"""Converting meters to kilometers preserves the physical magnitude."""
in_km = q.uconvert("km")
assert jnp.allclose(q.value, in_km.value * 1000, rtol=1e-5)
5. Set Reasonable Limits#
Use strategies wisely to avoid edge cases that aren’t relevant:
# Limit array sizes to reasonable values
@given(q=ust.quantities(shape=st.integers(1, 100), unit="m")) # Not too large
def test_sum_preserves_units(q):
total = jnp.sum(q)
assert total.unit == q.unit
Debugging Failed Tests#
When Hypothesis finds a failing case, it will try to simplify it to a minimal example and provide you with a @example decorator to reproduce it:
Falsifying example: test_addition_commutative(
q1=Quantity(Array([0.], dtype=float32), unit='m'),
q2=Quantity(Array([inf], dtype=float32), unit='m'),
)
You can reproduce this example by temporarily adding @example(q1=Quantity(...), q2=Quantity(...))
as a decorator on top of @given.
Using @example to Reproduce Failures#
The recommended approach is to use Hypothesis’s @example decorator to force the specific failing case to be tested. This ensures the example runs every time and is compatible with Hypothesis’s shrinking process:
from hypothesis import given, example
import unxt as u
import unxt_hypothesis as ust
@given(q1=ust.quantities(), q2=ust.quantities())
@example(
q1=u.Q(jnp.array([0.0], dtype=jnp.float32), "m"),
q2=u.Q(jnp.array([jnp.inf], dtype=jnp.float32), "m"),
)
def test_addition_commutative(q1, q2):
"""Test that addition is commutative."""
# This will run both the generated examples AND the specific failing case
assert jnp.allclose((q1 + q2).value, (q2 + q1).value, equal_nan=True)
The @example decorator ensures that:
The failing case is always tested, even if Hypothesis would otherwise miss it
You can debug with the exact values that caused the failure
The test remains property-based for other inputs
Alternative: Standalone Debug Test#
You can also copy the failing example into a separate test for debugging:
def test_debug_specific_case():
"""Debug the specific failing case."""
q1 = u.Q([0.0], "m")
q2 = u.Q(jnp.inf, "m")
# Add debugging
print(f"q1 = {q1}")
print(f"q2 = {q2}")
result = q1 + q2
print(f"result = {result}")
This approach is useful when you need to:
Step through the code with a debugger
Add extensive logging or inspection
Temporarily isolate the failing case
For more on debugging strategies, see the Hypothesis documentation on Reproducing Failures and Testing Your Tests.
See Also#
Full API Reference