Skip to content

Shrinking and the Edge-Case Superpower

The previous page, Property-Based Testing: Generators and Invariants, left us with a machine that throws thousands of random inputs at an invariant and waits for one to break it. That sounds powerful until the first failure arrives — and it arrives as a 400-element list of random integers, or an 8 KB blob of random Unicode. You have found a bug. But you have found it wearing a disguise, buried in noise. Which part of that giant input actually matters?

This page is about the feature that answers that question automatically, and it is not an exaggeration to call it the reason property-based testing is usable at all. Without it, generators would hand you a needle inside a haystack and wish you luck. With it, they hand you the needle. That feature is shrinking.

A generator’s whole job is to sample a space too large to enumerate, and to lean on the boundaries while it does. That means when it finally trips your property, the input it tripped it with is almost always far bigger and messier than the input it needed. The randomness that made it good at finding the bug now works against you: 399 of those 400 elements are irrelevant, but nothing tells you which 399.

Debugging a random blob is miserable in a specific way. You cannot tell signal from noise, so you cannot form a hypothesis. Is it the length? A particular value? The order? A duplicate? Every one of those is plausible and the input is too large to reason about by hand. This is exactly the situation an example-based test never puts you in — because you chose the input, so you already understand it.

Shrinking closes that gap. After a property fails, the framework does not stop and report the raw failing input. It starts a second, automatic search: it looks for the smallest input that still fails, and reports that instead.

The mechanism is a loop, and it is simpler than it sounds:

1. A property fails on some input X (large, random, ugly).
2. Propose a set of "smaller" candidates derived from X:
- drop elements / halve the list
- move integers toward 0
- shorten the string
- remove characters
3. Run the property on each candidate.
- candidate still FAILS? keep it, recurse from there.
- candidate PASSES? discard it, try another.
4. When no smaller candidate fails, stop.
5. Report the last failing candidate — the MINIMAL case.

“Smaller” is defined per type. For a list, smaller means fewer elements (and elements that are themselves smaller). For an integer, smaller means closer to zero. For a string, smaller means shorter, and simpler characters. The framework keeps greedily shrinking as long as the property keeps failing, and stops the moment every proposed reduction starts passing. What is left is a local minimum: an input where removing anything at all makes the bug disappear. That minimum is, by construction, the clearest possible statement of what triggers the failure.

Crucially, this is not you doing “minimize the repro” by hand — the tedious ritual of deleting half the input, re-running, deleting half again. Shrinking is that ritual, automated and exhaustive, run in the milliseconds after the failure.

The difference is the whole pitch, so look at it concretely. Suppose you wrote a property claiming a function normalize(xs) returns a list with no negative numbers, and it has a bug: it mishandles a negative that follows a zero.

WHAT THE GENERATOR FOUND (the raw failure):
xs = [ 88, -3, 41, 0, 17, 6, -91, 44, ... 392 more elements ]
└────────── 400 random integers, one of them fatal ──────────┘
WHAT SHRINKING REPORTS (the minimal failure):
xs = [0, -1]
└─ two elements. The bug is: a zero, then any negative. ─┘

You went from “somewhere in these 400 numbers is a problem” to “the bug is a zero followed by a negative” — a one-line reproduction you can read, understand, and fix in one sitting. The same thing happens for text:

RAW FAILURE: "aX9 \t𝕌ن​7Qz…" (a 2,000-char random blob)
SHRUNK FAILURE: "" (the empty string)

An empty-string failure is a diagnosis: your function does not handle the zero-length case. You did not have to spot that in two thousand characters of noise. Shrinking spotted it for you, and threw the noise away.

Why generators aim at the edges in the first place

Section titled “Why generators aim at the edges in the first place”

Shrinking explains why a failure is small. But there is a second, related superpower that explains why the failures show up at all: good generators are not uniformly random. They are biased toward the boundaries — the exact values that break software.

A generator for integers does not just draw from the whole 64-bit range with equal probability. It deliberately over-samples a small set of usual suspects:

Values a well-designed integer generator favors:
0, 1, -1, 2, -2,
MIN, MAX, MIN+1, MAX-1 (overflow / underflow edges)
Values a string generator favors:
"" (empty)
" " (whitespace-only)
"\0" (embedded null)
multi-byte Unicode, combining marks, RTL text, emoji
Collection generators favor:
[] (empty)
[x] (single element)
[x, x] (duplicates)
already-sorted and reverse-sorted orders

This bias is not decoration — it encodes decades of hard-won knowledge about where bugs live. Off-by-one errors cluster at 0 and 1. Overflow lives at MIN/MAX. Half of all string bugs are the empty string or a multi-byte character that some code assumed was one byte. Null handling breaks on null. So a property test surfaces the classic edge cases automatically, without you writing a single explicit edge-case assertion — the generator was always going to try the empty list, the zero, the MAX, the null, and the emoji, because those are the first places a competent test author would look too. It just never gets tired of looking.

Put the two features together and you have the full trick. Generators bias toward the edges to find the failure, and shrinking pulls toward the edges to report it: shrinking an integer moves it toward 0, shrinking a list moves it toward []. So the minimal failing case a property reports is very often itself a textbook edge — [0, -1], "", MAX + 1. The tool finds edges and then, unprompted, hands you the edge in its purest form.

Reproducibility: seeds turn a fluke into a regression test

Section titled “Reproducibility: seeds turn a fluke into a regression test”

There is one obvious worry. If the inputs are random, how do you ever debug — and how do you stop the same bug from silently returning? A test that fails on a different input every run is worse than useless.

The answer is the seed. A property-based framework does not use true randomness; it uses a pseudo-random generator initialized from a seed number. Same seed → the exact same sequence of “random” inputs → the exact same failure, byte for byte. When a property fails, the framework prints the seed (and usually the shrunk value) so you can replay the run deterministically:

Property failed after 218 tests.
Shrunk to: xs = [0, -1]
Seed: 0x5F3759DF ← re-run with this to reproduce exactly

That single line converts a random discovery into something permanent, and this is the workflow that matters:

  1. The property finds a failure on some seeded run.
  2. It shrinks the failure to a minimal case — say [0, -1].
  3. You take that minimal case and write it as an ordinary example-based test: assert normalize([0, -1]) has no negatives. Now the exact bug is pinned by a fast, deterministic regression test that will fail forever if it ever returns.
  4. You fix the code. Both the property and the new example test go green.

This is the two ideas of the whole Part shaking hands. The property (page 7) explores the vast space and finds the edge. Shrinking distills it. And the distilled case becomes a hand-written example — the very thing Example-Based vs Property-Based Testing argued was complementary, not opposed. Property testing does not replace example tests; it generates the best ones for you, aimed precisely at the edges you would never have typed.

Under the hood — why shrinking can mislead, and how frameworks fight it

Section titled “Under the hood — why shrinking can mislead, and how frameworks fight it”

Shrinking is a greedy local search, and greedy searches have a known failure mode: they can get stuck at a local minimum that is not the global one. Two honest caveats follow, and a good tester keeps them in view.

First, the shrunk case is minimal with respect to the reductions the framework knows how to make. If your data type has structure the shrinker does not understand — an invariant between two fields, say — it may fail to shrink past it, or worse, shrink to a value that violates your generator’s own preconditions and reports a “failure” that could never actually occur. Modern frameworks address this with integrated shrinking (the shrinker is derived from the generator, so it can only produce values the generator could have produced), which largely removes the invalid-shrink problem.

Second, shrinking assumes the failure is deterministic: it re-runs candidates and trusts a pass to mean “not the bug.” If your property is flaky — dependent on time, ordering, a shared global, or an actual race — shrinking can wander, because a candidate that “passed” only passed by luck. A wandering or nonsensical shrink is itself a signal: it often means the thing under test is not as deterministic as you assumed, which is worth knowing.

  • Why does it exist? Because a generator that finds a bug on a giant random input has only done half the job — it located a failure but not the cause. Shrinking exists to close that gap: to turn a random, oversized failing input into the smallest input that still reproduces the failure, so you debug a diagnosis instead of a blob.
  • What problem does it solve? The “needle in a haystack” problem of random testing. It removes the noise that randomness introduces, converting an unreadable 400-element failure into a one-line reproduction like [0, -1] or "" that a human can understand and fix immediately.
  • What are the trade-offs? It costs extra property runs after a failure (usually cheap and paid by the machine), and it can mislead when the property is flaky or the shrinker misunderstands your data’s structure — potentially reporting a “minimal” case that violates a precondition. It trades a little machine time and a little subtlety for an enormous reduction in human debugging time.
  • When should I avoid it? You almost never disable shrinking, but distrust its output when the property is non-deterministic (time, ordering, shared state, real races): a wandering shrink is a warning that the failure is not reproducible, and you should fix the flakiness before trusting the minimal case.
  • What breaks if I remove it? Property-based testing becomes practically unusable. Every failure would arrive as a random blob, debugging would collapse into manual repro-minimization by hand, and most teams would quietly abandon the technique — which is exactly why shrinking, not generation, is the feature that makes the whole approach viable.
  1. In one sentence each, state what a generator does and what a shrinker does, and why you need both rather than just the generator.
  2. A property fails on a 400-element list and the framework reports [0, -1]. Explain the search the framework ran to get from one to the other, and what “minimal” means in its result.
  3. Why do generators bias toward values like 0, MIN, MAX, the empty string, and multi-byte Unicode instead of sampling uniformly? Connect this to how the classic edge cases get surfaced “for free.”
  4. What is a seed, and walk through how it lets a random failure become a permanent, deterministic regression test. Which idea from earlier in the Part does that final regression test embody?
  5. Shrinking is a greedy local search. Name one situation where its reported “minimal” case can mislead you, and say what that misleading shrink is actually telling you about the code under test.
Show answers
  1. A generator samples the vast input space (biasing toward boundaries) to find a failing input; a shrinker searches, after a failure, for the smallest input that still fails so you get a readable reproduction. You need both because generation finds the bug but hands it to you buried in random noise — the shrinker is what makes that failure debuggable.
  2. On failure the framework proposes “smaller” candidates derived from the failing input (drop elements, halve the list, pull integers toward zero), re-runs the property on each, keeps any that still fail and recurses, and discards any that pass. It stops when no smaller candidate fails. “Minimal” means a local minimum: removing or reducing anything further makes the bug disappear, so the reported case is the clearest statement of the trigger.
  3. Because bugs cluster at boundaries — off-by-one at 0/1, overflow at MIN/MAX, string bugs at "" and multi-byte characters, null handling at null. Biasing toward these encodes where defects actually live, so a property test tries the empty list, the zero, the max, the null, and the emoji automatically, surfacing the classic edge cases without you writing an explicit assertion for each.
  4. A seed is the number that initializes the framework’s pseudo-random generator; the same seed reproduces the exact same sequence of inputs and therefore the exact same failure. So you re-run with the printed seed to reproduce deterministically, take the shrunk minimal case, and write it as a plain example-based test that pins the bug forever. That final regression test embodies example-based testing — property testing generated the best example for you, showing the two approaches are complementary, not opposed.
  5. When the property is flaky (depends on time, ordering, shared global state, or a real race), a candidate can “pass” only by luck, so the greedy search wanders and may report a nonsensical or non-reproducing minimal case. That misleading shrink is telling you the code (or the test) is not as deterministic as you assumed — a signal worth acting on before trusting any minimal case.