Property-Based Test Shrink Discipline
Table of Contents
A methodology note prompted by a real observed event during the round-4
adtech aggregation. Captures what test.check shrinks are, how to
treat them when they fire, why we let them stay non-deterministic by
default, and the pin-on-find pattern.
1. The observed event
During round-4 aggregation (2026-06-01), an initial JVM run reported
1 failure across 710 tests / 1581 assertions. Two subsequent runs of
the identical command (same code, different test.check seed) reported
0 failures. The CLJS cross-host run was clean (721/1609/0) on every
attempt.
A single property fired on a single seed and never reproduced in the session window. The shrunk counterexample was not captured – the output had scrolled past before the seed could be pinned.
This is the kind of event a casual reader would dismiss as "flake." It is not flake; it is signal. The remediation is not "ignore" – it is "capture next time it fires."
2. What test.check shrinks are
When defspec or tc/quick-check finds a counterexample, the
generator framework shrinks it: repeatedly tries smaller values
until it can no longer produce a failure. The smallest still-failing
value is the shrunk counterexample.
Example: a property (prop/for-all [n gen/nat] (< (* n 2) 1000))
catches n=500, then shrinks to n=500 (can't go smaller without
passing), reports the smallest value that breaks the claim.
The output looks like:
{:result false,
:seed 1780279059130,
:failing-size 47,
:num-tests 48,
:fail [{:n 500}],
:shrunk {:total-nodes-visited 9,
:depth 6,
:result false,
:smallest [{:n 500}]}}
The :seed is the reproducer key. The :smallest is the shrunk
counterexample.
3. Why non-deterministic seeds by default
Most of our defspec forms pass no :seed option and take the
framework default, which test.check derives from the current time.
Trade-off:
| Choice | Pro | Con |
|---|---|---|
| Fixed seed | Reproducible; CI never goes flaky | Same paths every run; coverage stagnates |
| Non-deterministic seed | Different paths each run; coverage grows over time | Occasional "found a real edge case once" event |
We pick the non-deterministic seed deliberately. The whole point of property tests is to find the edge cases we didn't think to write. A fixed seed turns property tests into expensive deterministic tests.
The cost is: a property that holds for 999 seeds and fails on 1 will fire occasionally in CI. That's not a bug; it's discovery.
4. The pin-on-find pattern (when a shrink fires)
When a property test fails – even once – the right response is:
- Capture the seed and the shrunk counterexample. Both are in the
defspecoutput. Save them immediately; they will scroll away. Reproduce locally with the seed:
(require '[clojure.test.check :as tc] '[wal-sh.adtech.<m>.core-prop-test :as p]) (tc/quick-check 100 p/<the-prop> :seed 1780279059130)If it reproduces – fix the impl. If it doesn't – the bug is non-determinism in the impl, which is a real bug in its own right.
Promote the shrunk counterexample to an example test in
core_test.cljc:(deftest pinned-from-shrink-2026-06-01 ;; Originally found by property test, seed 1780279059130. ;; Shrunk counterexample: {:n 500}. (is (not (your-property-pred {:n 500}))))This means the case is now in CI deterministically, regardless of whether the property's random seed ever hits it again.
- Leave the property in place. The shrunk example pins THIS case; the property still hunts for other cases.
5. Why this isn't tracked per-test
We could enforce "every defspec records its last 10 seeds" or similar machinery. We don't, because:
- The pin-on-find pattern only needs the one seed that found a real bug; tracking every seed is overhead with no payoff.
- Most properties pass on every seed; the few that don't deserve manual investigation, not automation.
- The
defspecforms intest/pocket_es/token_test.cljcpass only a trial count (100) and no:seedoption, so they run on the framework default seed. That is the working norm; deviating per-module would create inconsistency.
6. What to do about THIS event (2026-06-01)
The seed wasn't captured before scroll-back. Three options:
- Wait – if the failure repeats, capture it then. (Defensible: single occurrence in ~700 tests across 5 runs is below noise floor.)
- Re-run with verbose output to file until it fires:
for i in 1..50; do clojure -M:test ... > /tmp/run-$i.txt; donethen grep for:result false. Catches the seed eventually. - Bisect by module – if you suspect a specific port introduced it, run that module's prop-tests in isolation many times to surface it.
For this event we chose (1). The CLJS cross-host gate is clean, so
the bug – if real – is JVM-specific, which narrows the search space.
JVM-only test.check bugs are usually in java.util.Random seeding
interaction with bit-and masks; the recent FNV-1a hash work
(round-3 ab-engine, round-4 referral-program / content-gate
extensions) is the most likely surface.
If it repeats, we'll capture and pin. No recurrence recorded as of
2026-09-10: the commit history under test/ since 2026-06-01 carries
no reference to this failure, and no pinned-from-shrink test exists
in the tree.
7. The broader principle
Property tests find bugs you didn't write tests for. Some of those bugs are subtle enough that ONE seed in a thousand exposes them.
A flake in a property test is a successful test – it found something. The failure mode is not "property tests are unreliable"; it's "we didn't have a process for capturing the discovery when it happened."
This doc IS that process.
8. Related lessons in the catalog
The full catalog lives in _drafts/adtech-cljc/EXPERIENCE-REPORT.org
(unpublished).
- Round 2 attribution: Shapley float drift at n=7 channels ~ε=1e-15 – pinned a per-model tolerance in the property assertion.
- Round 3 prebid-lite: granularity-bucket IEEE-754 failure –
(floor (/ 0.30 0.10)) = 2.0not 3.0; switched to integer thousandths. - Round 4 referral-program: agent pre-wrote a guessed cross-host vector literal; JVM caught the mismatch on first run. Write the test, run it, pin from the output – don't guess golden values.
All three are pin-on-find applied to specific shrinks. The 2026-06-01 event is the first where the shrink fired without being captured – hence this doc.