The Shrinking Process
Relevant source files
The following files were used as context for generating this wiki page:- packages/fast-check/src/check/arbitrary/definition/Value.ts
- packages/fast-check/src/arbitrary/_internals/helpers/ShrinkInteger.ts
- packages/fast-check/test/e2e/ComplexShrink.spec.ts
- packages/fast-check/test/unit/arbitrary/_internals/LimitedShrinkArbitrary.spec.ts
- website/blog/2024-07-12-whats-new-in-fast-check-3-20-0/index.md
- CONTRIBUTING.md
The shrinking process is a cornerstone feature of fast-check. When a property-based test fails, fast-check does not immediately report the large, complex value that caused the failure. Instead, it initiates a "shrinking" phase to find a simpler, smaller counterexample that still triggers the same failure. This process is crucial for debugging, as it provides the user with the most minimal case to analyze, significantly reducing the complexity of identifying the root cause of the bug.
For instance, a test might fail for a large array of integers. The shrinker will try to reduce the size of the array and the magnitude of its elements, aiming to find a minimal array like [0, 1] that still fails the test. This is much easier to debug than the original, randomly generated array. The effectiveness of this process is demonstrated in complex scenarios where multiple values are related, and the shrinker must intelligently navigate the search space to find a true local minimum.
Sources: packages/fast-check/test/e2e/ComplexShrink.spec.ts:5-32
Core Components
The shrinking mechanism is built upon a few key components that work together to generate, track, and reduce failing test cases.
The Value Class
The Value class is a simple yet fundamental data structure that encapsulates a generated value and its associated context. This context is opaque to the user but is essential for the shrinker to know how to produce smaller versions of the value.
// packages/fast-check/src/check/arbitrary/definition/Value.ts
/**
* A `Value<T>` holds a value of type `T` and its context
*
* @internal
*/
export class Value<T> {
/**
* The underlying value
*/
readonly value: T;
/**
* The context to be used when shrinking the value
* @internal
*/
readonly context: unknown;
// ... constructor ...
}
Sources: packages/fast-check/src/check/arbitrary/definition/Value.ts:1-21
The diagram below shows the structure of the Value class.
The Arbitrary.shrink Method
Every Arbitrary in fast-check has a shrink method. When given a value and its context (retrieved from a Value object), this method returns a Stream of smaller Value objects. A Stream is a lazy iterator, which is efficient as it only generates smaller values as they are needed by the test runner.
The following diagram illustrates the general flow of shrinking after a test failure.
Shrinking in Action
Complex Scenarios
The power of fast-check's shrinker becomes evident in complex scenarios involving multiple, related inputs. For example, consider a property that fails only when two numbers, a and b, are close to 1000 and b is exactly 10 greater than a.
A test for this might initially fail with large numbers like [2147483642, 2147483641]. The shrinker's job is to reduce this to the minimal failing case, which might be [1000, 1010]. This requires a sophisticated shrinker that can intelligently reduce both values in tandem. Older versions of the integer shrinker had issues with such scenarios, sometimes leading to "barely infinite" shrinks because the context was not properly shared between shrinking levels. The current implementation is designed to handle these cases efficiently.
// packages/fast-check/test/e2e/ComplexShrink.spec.ts:34-45
const MaximalValue = 1000000;
const out = fc.check(
fc.property(fc.nat(MaximalValue), fc.nat(MaximalValue), (a: number, b: number) => {
if (a < 1000) return true;
if (b < 1000) return true;
if (b < a) return true;
if (Math.abs(a - b) < 10) return true;
return b - a >= 1000;
}),
{ seed, numRuns: 5000 },
);
// ...
expect(minimal).toEqual([1000, 1010]);
Sources: packages/fast-check/test/e2e/ComplexShrink.spec.ts:8-32, 54-55
Controlling the Shrink Process
While shrinking is powerful, there are cases where you might want to control its behavior, primarily for performance reasons or when dealing with very complex custom arbitraries.
Sources: website/blog/2024-07-12-whats-new-in-fast-check-3-20-0/index.md
Disabling and Limiting Shrinking
fast-check provides wrapper arbitraries to modify the shrinking behavior of an existing arbitrary.
| Function | Description |
|---|---|
fc.noShrink(arb) | Completely disables shrinking for the given arbitrary arb. |
fc.limitShrink(arb, maxShrinks) | Limits the number of shrink values produced at each level for the given arbitrary arb. |
Sources: website/blog/2024-07-12-whats-new-in-fast-check-3-20-0/index.md, packages/fast-check/test/unit/arbitrary/_internals/LimitedShrinkArbitrary.spec.ts
The LimitedShrinkArbitrary is the internal class that powers fc.limitShrink. It wraps another arbitrary and intercepts the call to its shrink method, cutting off the returned Stream of values after the specified limit.
The following diagram shows the logic of the LimitedShrinkArbitrary.
It is generally recommended to use options like endOnFailure or interruptAfterTimeLimit to manage test run time rather than disabling shrinking, as a minimal counterexample is extremely valuable for debugging. Limiting shrinking is mostly useful when building complex new arbitraries where shrinking some less relevant parts can be deprioritized.
Sources: website/blog/2024-07-12-whats-new-in-fast-check-3-20-0/index.md
Testing and Quality Assurance
To ensure the reliability and correctness of its own shrinkers, fast-check has a suite of test helpers. When contributing a new arbitrary, developers are expected to add integration tests that validate its shrinking behavior.
Sources: CONTRIBUTING.md
Shrinker Assertions
The following helper functions are used to assert the quality of a new arbitrary's shrinker:
| Assertion Helper | Purpose |
|---|---|
assertProduceValuesShrinkableWithoutContext | Ensures that generated values can be shrunk even without their original generation context. |
assertShrinkProducesSameValueWithoutInitialContext | Confirms that the shrinker can reproduce the same shrink values without the initial context. |
assertShrinkProducesStrictlySmallerValue | Verifies that every value produced by the shrinker is "strictly smaller" than the original, preventing infinite loops. |
These assertions are crucial for maintaining the robustness of the framework and preventing regressions in shrinking capabilities.
Sources: CONTRIBUTING.md
Conclusion
The shrinking process is an integral part of fast-check that transforms property-based testing from a simple bug detection tool into a powerful debugging assistant. By automatically distilling failing test cases down to their simplest form, it saves developers significant time and effort. The architecture, with its Value class, Stream-based shrink methods, and robust internal testing, creates a flexible and reliable system for finding minimal counterexamples. While controls exist to manage its behavior, the default shrinking process is highly optimized and a key reason for fast-check's effectiveness.