Property-Based Testing in Python: Beyond Basic Unit Tests with Hypothesis

Overview of Property-Based Testing

Traditional unit testing follows the Arrange-Act-Assert pattern. You pick a specific input, run your code, and check if the output matches your manual calculation. While effective, this approach is limited by your own imagination; you only test the edge cases you can think of.

shifts this
Property-Based Testing
by testing properties rather than specific examples. Instead of asserting that add(1, 2) equals 3, you assert that add(a, b) always equals add(b, a). This allows the framework to generate hundreds of random inputs to try and break your logic, often finding bugs in corners of the code you never thought to check.

Prerequisites

To follow this guide, you should have a solid grasp of

fundamentals, including decorators and basic data structures. Familiarity with
pytest
is recommended, as we will use it to execute our test suites. You should also understand the basics of unit testing and assertion logic.

Key Libraries & Tools

  • Hypothesis
    : A powerful library for property-based testing that generates test data and simplifies failing cases.
  • pytest
    : The standard testing framework used to run and organize Python test scripts.
  • QuickCheck
    : The original functional programming tool that inspired the property-based testing movement.

Code Walkthrough: Reversible Operations

A classic use case for property testing is an encoder-decoder pair. If you convert a string to ASCII codes and back, you should always end up with the original string.

from hypothesis import given, example
from hypothesis.strategies import text
from my_code import to_ascii_codes, from_ascii_codes

@given(text())
@example("")
def test_decode_inverts_encode(test_string):
    assert from_ascii_codes(to_ascii_codes(test_string)) == test_string

In this snippet, @given(text()) tells

to generate various strings. The @example("") decorator ensures that the empty string—a common edge case—is always included in the test run. When you run this with
pytest
, the library generates a wide array of Unicode characters and lengths to verify the property holds true.

Custom Strategies with Composite

Sometimes, simple types like integers or strings aren't enough. You might need to generate complex objects, like a team of employees.

provides the @composite decorator to build these custom data generators.

from hypothesis import strategies as st

@st.composite
def teams_strategy(draw):
    size = draw(st.integers(min_value=1, max_value=20))
    return generate_random_team(size)

@given(teams_strategy())
def test_team_has_ceo(team):
    assert Employee.CEO in team

The draw function allows you to pull values from other strategies (like integers) and pass them into your business logic to create valid test objects. This modularity keeps your test code clean and reusable.

Syntax Notes

Notice the use of decorators to inject data into test functions.

intercepts these functions and calls them repeatedly. Another important feature is shrinking: when
Hypothesis
finds a failure, it doesn't just give you a massive, confusing input. It automatically attempts to find the smallest, simplest version of that input that still triggers the error, making debugging significantly easier.

Practical Examples & Tips

Property testing excels at verifying data invariants (e.g., a sorting function should never change the length of a list) and stateful systems.

Tips & Gotchas:

  • Limit your ranges: Use min_value and max_value in strategies to avoid generating unrealistic data that might cause timeouts.
  • Don't abandon unit tests: Use property-based testing for logic and invariants, but keep traditional unit tests for specific regression bugs.
  • Settings: Use the settings decorator to control max_examples if your tests are running too slowly in CI environments.
Property-Based Testing in Python: Beyond Basic Unit Tests with Hypothesis

Fancy watching it?

Watch the full video and context

4 min read