Property-Based Testing and Shrinking
Property-based testing checks a rule against many generated inputs, while shrinking simplifies a failing input until it becomes a small, reproducible counterexample. Instead of writing only particular examples by hand, you describe what valid inputs look like and what must always be true about them.
The problem it solves
Example-based tests are good at documenting known cases:
def test_total_with_tax():
assert total(100, 0.2) == 120
But a function may fail for combinations you did not think to write down: an empty collection, a boundary value, unusual Unicode text, duplicate records, or a deeply nested structure. The number of possible inputs quickly exceeds the number a person can reasonably enumerate.
Property-based testing addresses this by generating many inputs automatically and checking a property—a rule that should hold for every valid input. The test author supplies the input space and the rule; the framework supplies varied examples and repeats the check.
Without shrinking, this approach has a practical weakness. When one generated input fails, it may be a large, noisy object containing many irrelevant details. A failure involving a 200-item list is harder to understand than one involving two items. Shrinking exists to turn the former into the latter.
Strategies describe structured inputs
A property-based testing framework usually uses a strategy: a recipe for producing values of a particular shape. A strategy can describe simple values, such as integers or strings, and combine them into records, lists, dictionaries, and nested structures.
For example, this Python test uses Hypothesis, a property-based testing library:
from hypothesis import given, strategies as st
@given(st.lists(st.integers(), min_size=2))
def test_first_value_is_smallest(values):
assert values[0] == min(values)
The strategy says that values is a list of integers containing at least two items. The test does not choose the list itself. Hypothesis generates many lists and runs the assertion for each one.
Strategies can be composed to match an application's data model:
record = st.fixed_dictionaries({
"name": st.text(min_size=1),
"age": st.integers(min_value=0, max_value=120),
})
@given(st.lists(record, min_size=1, max_size=20))
def test_names_are_strings(records):
assert all(isinstance(record["name"], str) for record in records)
The important detail is that generation is structured, not just random bytes. The framework knows that this value is a list, that each element is a dictionary, and that each dictionary has an integer age and non-empty text. That knowledge lets it generate meaningful cases and later simplify each part correctly.
A strategy can also choose among alternatives, generate values related to one another, or restrict values to those satisfying a condition. Poorly designed restrictions can waste work: repeatedly generating invalid values and discarding them is called rejection, and too much rejection can make a test slow or prevent it from finding examples. Whenever possible, express the valid shape directly in the strategy.
What generation looks like during a run
The framework runs the test repeatedly, usually with a mixture of ordinary values and values chosen to exercise boundaries. Integers near zero, empty collections, short strings, maximum sizes, and repeated elements are often especially useful because bugs cluster around them.
The exact sequence is not normally a fixed list stored in the test. It is produced from the strategy and the framework's search process. Frameworks also record enough information—often including a seed or an internal example database—to replay a failure. That is why a test report may contain a generated input, a seed, or a message saying that an example was found: those details are the bridge between automatic discovery and local debugging.
The goal is not to prove the property for every possible value. A finite test run cannot do that. The goal is to explore a broad and deliberately shaped sample of the valid input space, repeatedly and cheaply enough to expose cases humans would miss.
Shrinking turns failure into an explanation
When an input makes the property fail, the framework searches for a simpler input that still fails. This is shrinking. It is usually applied component by component rather than by trying arbitrary new values.
For a list, useful simplifications include:
- removing elements;
- shortening nested elements;
- replacing values with simpler ones, such as moving an integer toward zero;
- reducing strings toward shorter text;
- simplifying a chosen alternative to a less complex alternative.
The framework keeps a candidate only if the test still fails. If a failing list contains 100 integers, it may try shorter lists, then simpler integers, and continue until no available simplification preserves the failure. The earlier test might therefore report a counterexample such as [1, 0] rather than a long randomly generated list.
“Smallest” means smallest according to the framework's shrinking rules, not necessarily the mathematically smallest object under every possible definition of size.
Shrinking is more than cosmetic. A small counterexample identifies the condition that matters. If a parser fails only after a list is reduced to one particular pair of tokens, or a calculation fails only at a boundary value, the reduced input points directly toward the bug and often becomes a permanent regression test.
Shrinking can be constrained by dependencies. Suppose a test generates a start date and an end date where the end must follow the start. Simplifying one independently may make the pair invalid. A good structured strategy represents that relationship so the shrinker can preserve it, rather than generating two unrelated dates and filtering most attempts away.
The full feedback loop
The mechanism is therefore a loop:
- Define the valid input structure with strategies.
- Define a property that should hold for every generated value.
- Generate and test many values.
- Detect the first failing case.
- Shrink it while preserving the failure.
- Report the reduced counterexample for diagnosis and replay.
Property-based testing supplies breadth; shrinking supplies clarity. Together they explain why a test can discover a failure in an input space far larger than the examples you wrote, yet present the result as a short, understandable case rather than an inscrutable random payload.