top of page

Property-Based Testing: A Beginner's Tour

  • Contributor
  • Mar 22
  • 4 min read

Most tests are example-based: you write specific inputs and verify specific outputs. Property-based tests are different — you describe a property the code should have, and the testing framework generates many inputs to try to violate it. When it can't break your property after thousands of attempts, you have stronger evidence the property holds.

This guide is the basic idea and where it pays off.

The Core Concept

Example-based test:

def test_reverse_twice_returns_original():
    assert reverse(reverse([1, 2, 3])) == [1, 2, 3]
    assert reverse(reverse([4, 5])) == [4, 5]
    assert reverse(reverse([])) == []

Three specific inputs. You picked them; they pass.

Property-based test:

@given(lists(integers()))
def test_reverse_twice_returns_original(xs):
    assert reverse(reverse(xs)) == xs

The framework generates many lists of integers — empty, small, large, with duplicates, with negative numbers, with extreme values — and verifies the property for each.

If any input violates the property, the framework reports it (often after "shrinking" to find the simplest counter-example).

What Properties Look Like

Common patterns:

  • Round-trip: parse(serialize(x)) == x

  • Idempotence: f(f(x)) == f(x)

  • Commutativity: f(a, b) == f(b, a)

  • Invariants: "the total is always positive," "the list is always sorted"

  • Comparative: "the new implementation gives the same result as the old one"

The property is the invariant that should hold for all valid inputs.

When Property-Based Testing Shines

Particularly useful for:

  • Parsers and serializers. Round-trip properties catch a class of bug example tests miss.

  • Data transformations. "Filter then map equals map then filter" kinds of properties.

  • Algorithms. Sort, merge, search — invariants are well-defined.

  • Math-heavy code. Numerical properties have natural invariants.

  • State machines. Properties like "applying any sequence of operations preserves invariants."

Less useful for:

  • UI code (hard to define properties)

  • Side-effect-heavy code (properties on what?)

  • Wire-format details (specifics matter more than properties)

  • Business rules without clear invariants

The Shrinking Property

A core feature of property-based testing tools: when they find a counter-example, they shrink it.

If a test fails with input [3, 7, -1, 14, 0, 9], the framework tries to find a simpler failing input — maybe [3, -1] is enough, or even [-1].

Shrinking dramatically improves the debugging experience. The framework hands you a minimal failing case, not a complex one you have to reduce yourself.

Tools

Popular libraries:

  • Python: Hypothesis

  • Haskell: QuickCheck (the original)

  • JavaScript: fast-check

  • Java: jqwik

  • Rust: proptest

  • Go: rapid

Hypothesis is particularly mature and well-documented; a good starting point for those learning the technique.

Writing Your First Property Test

Start small. Pick a function with a simple invariant.

from hypothesis import given, strategies as st

@given(st.integers(), st.integers())
def test_addition_commutative(a, b):
    assert add(a, b) == add(b, a)

Run it. If it passes, the property holds for many cases. If it fails, you get a counter-example.

Build from there. The technique requires practice to get good at framing properties.

Combining With Example-Based Tests

Property-based and example-based tests complement each other.

  • Example-based: explicit verification of specific cases. Readable. Documents expected behavior.

  • Property-based: broad verification of invariants. Catches edge cases.

A good test suite often includes both. The example tests show what you intended; the property tests verify the intent holds in general.

Generators and Strategies

A key concept: how the framework generates inputs.

Most frameworks provide:

  • Primitive generators (integers, floats, strings, bytes)

  • Compound generators (lists, dicts, tuples)

  • Filtered generators (positive integers, valid emails)

  • Custom generators (your domain objects)

The quality of your tests depends partly on the quality of your generators. A generator that only produces small numbers won't find issues with large ones.

Stateful Property Testing

Beyond functional properties, frameworks like Hypothesis support stateful testing: generate sequences of operations and verify invariants hold throughout.

class ShoppingCartStateMachine(RuleBasedStateMachine):
    @rule(item=st.text())
    def add_item(self, item):
        self.cart.add(item)
    
    @rule()
    def remove_item(self):
        if self.cart:
            self.cart.remove_last()
    
    @invariant()
    def cart_count_non_negative(self):
        assert self.cart.count() >= 0

The framework generates random sequences of operations and verifies invariants. Particularly useful for testing classes with complex state.

What Goes Wrong

Trivial properties. f(x) == f(x) always passes. Make sure the property is meaningful.

Tautological properties. Properties expressed in terms of the implementation. They pass because they restate the code.

Slow tests. Generating thousands of inputs takes time. Limit the input space or reduce the number of examples per test.

Misleading shrinks. Sometimes shrinking produces a misleading minimal case that's not the simplest cause. Investigate before trusting.

Flaky tests. Property-based tests can fail intermittently when the input space is large. Use deterministic seeds for CI.

Determinism in CI

For CI, you want deterministic test runs. Two strategies:

  • Fixed seed: the framework uses the same seed each run, generating the same inputs

  • Replay failing cases: when a test fails, the failing input is recorded; subsequent runs always include it

Most frameworks support both. The combination gives you determinism plus accumulation of "interesting" inputs over time.

When to Reach for Property Testing

Triggers:

  • "We need to test a parser/serializer; round-trip property is natural"

  • "We're rewriting an algorithm; want to verify equivalence with the old version"

  • "We have a class with complex state and lots of operations"

  • "Example tests pass but bugs keep appearing in production"

In each case, properties capture what the code should do more broadly than examples.

Limitations

Property-based testing isn't a universal answer:

  • Not all behavior has clean properties

  • Properties can be wrong (just like tests can be wrong)

  • Generators take effort to write well

  • Debugging property failures takes more thought than debugging example failures

Use where it fits. Don't force it.

Combining With Other Testing

For a typical codebase, property tests might cover:

  • 5-15% of test count

  • 20-40% of the test value for the areas they apply to

  • Specific risky modules: parsers, transformers, complex state machines

The rest is example-based. The combination is more powerful than either alone.

Key Takeaway

Property-based testing describes invariants and lets the framework generate inputs to try to violate them. Particularly useful for parsers, transformations, algorithms, and stateful systems. Common properties: round-trip, idempotence, commutativity, invariants. Shrinking gives you minimal failing cases for debugging. Use alongside example-based tests, not as a replacement. Start small with a clear invariant; expand as the technique becomes familiar.

Related reading

Keep learning. This article is part of the Software Testing Foundations path in the ShiftQuality Learning Center. Learn to design tests that catch real bugs.

bottom of page