An example-based test asserts one thing about one input you chose by hand: call this function with these three values, expect this exact result. Property-based testing replaces the hand-picked input with hundreds of generated ones, and replaces the specific expected result with a general rule, called a property, that has to hold no matter which input the framework throws at it. Instead of writing the edge cases yourself, you describe the rule and let the framework go looking for the input that breaks it.
The core idea
You state an invariant: decoding what you just encoded returns the original value, the output list is always sorted, the total never goes negative. You declare generators describing the shape of valid input — arbitrary integers, strings, arrays, or custom domain objects. The framework runs the property against a large number of generated inputs, by default 100 or more per run, actively hunting for the one that disproves it. When it finds a failure, it shrinks the input automatically, replaying smaller and simpler versions until it isolates the minimal case that still breaks the rule, so you debug a 2-element case instead of a 200-element one.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_sorting_is_idempotent(values):
once = sorted(values)
twice = sorted(once)
assert once == twice
Generated inputs versus hand-picked examples
| Question |
Example-based test |
Property-based test |
| Who picks the input? |
You, by hand |
The framework, generated |
| How many cases run? |
As many as you wrote |
Hundreds, by default |
| Where do edge cases come from? |
Whatever you thought of |
Found automatically |
| What failure looks like |
A specific wrong value |
A minimal shrunk input |
| Best for |
Documentation, one clear scenario |
Invariants: parsers, math, sorting |
Neither approach replaces the other. A healthy suite keeps a handful of example tests as readable documentation of intent, and adds properties wherever a real invariant exists to check.
Where generated inputs catch what examples miss
- Parsers and serializers, where encode-then-decode should always return the original value, regardless of how strange the input is.
- API request validators, where a property can assert that anything the validator accepts also round-trips cleanly through your handler.
- Numeric and financial logic, where a total should never go negative and a conversion should never lose more than a rounding unit.
- Anywhere you already wrote three or four near-identical example tests — that pattern is usually a property in disguise, waiting to be generalized.
Common mistakes
Writing a property that just re-implements the function under test. If the property recomputes the same logic to check the result, a bug in that logic is a bug in the property too — state the invariant a different way than the implementation does.
Generating inputs with no realistic constraints. An unconstrained generator wastes runs on inputs that could never occur in production; constrain generators to the actual domain.
Dismissing a shrunk failure because it looks contrived. A shrunk input is still a real input the property does not hold for; contrived-looking failures often reveal a genuine boundary bug.
Reaching for a property where no invariant exists. Simple glue code or a one-off script rarely has a real rule worth generating a hundred inputs against.
FAQ
Is property-based testing the same as fuzzing?
Related, not identical. Fuzzing typically hunts for crashes and security issues with little structure. Property-based testing checks a specific, stated rule and is more common in everyday application test suites.
Do I need a property test for every function?
No. It pays off for functions with a genuine mathematical or structural invariant. Plain glue code with no such rule rarely benefits from generated inputs.
What does shrinking actually do?
It automatically reduces a failing generated input to the smallest, simplest version that still fails, so you debug the real cause instead of an enormous, hard-to-read case.
Which tools should I reach for?
fast-check for JavaScript and TypeScript, Hypothesis for Python, QuickCheck for Haskell where the idea originated, and jqwik for Java. Most mainstream languages have a mature option by now.
Where to go next
See mutation testing explained for 2026 for checking whether your existing tests, generated or hand-written, would actually catch a bug, the test pyramid explained for 2026 for where generated-input tests fit relative to other layers, and what a string is in programming in 2026 for the kind of input these frameworks generate constantly.