DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

A Practical Guide to Property-Based Testing in Python

A Practical Guide to Property-Based Testing in Python

A Practical Guide to Property-Based Testing in Python

Property-based testing helps you test behavior by describing rules your code should always obey, instead of hand-picking a few example inputs. It is especially useful for validating edge cases, input validation, parsing, transformations, and logic that can fail in surprising ways.

Why this approach matters

Traditional example-based tests are good when you already know the exact inputs and outputs you want to check. Property-based testing shifts the focus to invariants, such as “sorting should not change the items,” “round-tripping should preserve data,” or “parsing then serializing should not lose information.”

That makes it a strong fit for code with lots of possible inputs, because it can uncover cases you did not think to write manually. It also encourages you to define what must always be true, which usually leads to clearer requirements.

What you will build

In this tutorial, we will test a small Python module that normalizes email addresses and processes payment amounts. The examples will use Hypothesis, a property-based testing library that generates many inputs for you and shrinks failing cases to something minimal and readable.

Project setup

Install Hypothesis and pytest:

pip install hypothesis pytest
Enter fullscreen mode Exit fullscreen mode

Create a file named app.py:

def normalize_email(email: str) -> str:
    local, domain = email.strip().lower().split("@")
    return f"{local}@{domain}"

def apply_discount(amount_cents: int, percent: int) -> int:
    if amount_cents < 0:
        raise ValueError("amount_cents must be non-negative")
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")
    return amount_cents - (amount_cents * percent // 100)
Enter fullscreen mode Exit fullscreen mode

This code is intentionally simple, but it gives us two good targets: string normalization and arithmetic invariants. Property-based tests are particularly effective when you want to check whole ranges of values rather than one fixed input.

First property test

Create test_app.py:

from hypothesis import given, strategies as st
from app import normalize_email

@given(st.text(min_size=1), st.text(min_size=1))
def test_normalize_email_is_lowercase(local, domain):
    email = f"{local}@{domain}"
    normalized = normalize_email(email)
    assert normalized == normalized.lower()
Enter fullscreen mode Exit fullscreen mode

This test states a broad rule: the result should always be lowercase. Hypothesis will try many combinations of text values, which is more effective than manually checking a few examples.

Testing round-trip behavior

A powerful property is round-tripping: if you transform data one way and then transform it back, you should recover the original meaning. For email normalization, a useful property is that normalization should be idempotent, meaning doing it twice should produce the same result as doing it once.

from hypothesis import given, strategies as st
from app import normalize_email

@given(
    local=st.text(min_size=1).filter(lambda s: "@" not in s),
    domain=st.text(min_size=1).filter(lambda s: "@" not in s),
)
def test_normalize_email_is_idempotent(local, domain):
    email = f"{local}@{domain}"
    once = normalize_email(email)
    twice = normalize_email(once)
    assert once == twice
Enter fullscreen mode Exit fullscreen mode

This is a classic property because it checks an important behavioral rule without requiring specific examples. Idempotence is often a good signal that your normalization logic is stable.

Testing boundaries

Arithmetic code benefits from boundary-value thinking. Mutation testing articles often highlight that tests should catch off-by-one mistakes and bad operator changes, and property-based tests are excellent at exercising those edges automatically.

from hypothesis import given, strategies as st
from app import apply_discount

@given(
    amount_cents=st.integers(min_value=0, max_value=1_000_000),
    percent=st.integers(min_value=0, max_value=100),
)
def test_discount_never_increases_amount(amount_cents, percent):
    result = apply_discount(amount_cents, percent)
    assert 0 <= result <= amount_cents
Enter fullscreen mode Exit fullscreen mode

This property checks two important facts at once: the output cannot go negative, and a discount should never make the amount bigger. That kind of invariant is easy to understand and hard to fake with weak tests.

Catching real bugs

Suppose you accidentally write this version:

def apply_discount(amount_cents: int, percent: int) -> int:
    if amount_cents < 0:
        raise ValueError("amount_cents must be non-negative")
    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")
    return amount_cents + (amount_cents * percent // 100)
Enter fullscreen mode Exit fullscreen mode

A few example-based tests might miss the bug if they only check one happy path. The property-based test above would fail quickly because the result is now larger than the original amount, which violates the invariant.

Designing good properties

Good properties are usually simple statements about behavior that should hold across many inputs. Examples include “output is sorted,” “parsing and serialization preserve the record,” “the function is deterministic,” and “the result stays within bounds.”

A practical way to find properties is to ask:

  • What must always be true?
  • What should never happen?
  • What should remain unchanged after repeating the operation?
  • What relationships should hold between related inputs?

These questions help you write tests that describe the system rather than the implementation. That usually makes the tests more resilient as code changes over time.

Shrinking and debugging

One of Hypothesis’s biggest strengths is shrinking: when it finds a failing input, it tries to reduce it to the smallest example that still fails. That makes failures easier to understand and faster to debug than a giant random case.

For example, a messy failure involving a long Unicode string may shrink to a one-character string that reveals the actual problem. In practice, this helps you fix the bug itself instead of wrestling with an unreadable test case.

Practical workflow

A good property-based testing workflow looks like this:

  1. Start with one small function or module.
  2. Write one invariant that should always hold.
  3. Add a second property for a different edge of the behavior.
  4. Run the tests and let Hypothesis search the input space.
  5. When a failure appears, minimize the code and strengthen the property if needed.

This works best when combined with a few example-based tests for obvious happy paths and expected error messages. Property-based testing is not a replacement for all other tests; it is a way to cover the awkward spaces that examples often miss.

Common mistakes

A common mistake is writing properties that are too weak, such as “the function returns something” or “the function does not crash.” Those assertions often pass even when the code is broken.

Another mistake is making the property too implementation-specific, which locks you into the current code structure instead of the behavior you actually care about. Aim for observable behavior, not internal details.

Finally, keep your input strategies realistic. If your function expects IDs, amounts, or structured records, generate those shapes directly rather than throwing completely arbitrary data at it. Good strategies make failures meaningful and speed up debugging.

A complete example

Here is a compact test suite you can use as a starting point:

from hypothesis import given, strategies as st
from app import normalize_email, apply_discount

@given(st.text(min_size=1), st.text(min_size=1))
def test_normalize_email_is_lowercase(local, domain):
    email = f"{local}@{domain}"
    normalized = normalize_email(email)
    assert normalized == normalized.lower()

@given(
    local=st.text(min_size=1).filter(lambda s: "@" not in s),
    domain=st.text(min_size=1).filter(lambda s: "@" not in s),
)
def test_normalize_email_is_idempotent(local, domain):
    email = f"{local}@{domain}"
    once = normalize_email(email)
    twice = normalize_email(once)
    assert once == twice

@given(
    amount_cents=st.integers(min_value=0, max_value=1_000_000),
    percent=st.integers(min_value=0, max_value=100),
)
def test_discount_never_increases_amount(amount_cents, percent):
    result = apply_discount(amount_cents, percent)
    assert 0 <= result <= amount_cents
Enter fullscreen mode Exit fullscreen mode

This suite checks a normalization rule, a stability rule, and a numeric boundary rule. Together, they give you much better coverage than a small list of hand-written examples.

When to use it

Use property-based testing when the domain has many input combinations, when bugs hide in edge cases, or when you care about invariants more than exact snapshots. It is especially good for parsers, serializers, validators, calculators, data transformations, and protocol logic.

It is less useful when the output is trivial to predict with a few examples or when the main risk is visual or workflow regression rather than logical correctness. In those cases, example tests, approval tests, or exploratory testing may be a better fit.

Next steps

Once you are comfortable with basic properties, try combining multiple strategies, adding custom generators, and testing stateful behavior. A good next exercise is to test a parser/serializer pair or a function that manipulates lists, dates, or money.

The main habit to build is simple: stop asking, “What example should I test next?” and start asking, “What must always be true?” That shift is what makes property-based testing so effective.

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)