Understanding Test Failures & Shrinking
Relevant source files
The following files were used as context for generating this wiki page:- packages/fast-check/src/check/runner/utils/RunDetailsFormatter.ts
- packages/fast-check/src/check/runner/reporter/RunDetails.ts
- packages/fast-check/src/check/arbitrary/definition/Arbitrary.ts
- website/blog/2023-10-05-finding-back-a-redos-vulnerability-in-zod/index.md
- website/blog/2025-03-10-whats-new-in-fast-check-4-0-0/index.md
- packages/worker/CHANGELOG.md
- website/blog/2023-09-17-whats-new-in-fast-check-3-13-0/index.md
Introduction
The core mission of fast-check is to uncover bugs automatically by supplying a wide range of generated inputs to a property. A key feature that distinguishes fast-check is its "shrinking" mechanism. When a property-based test fails, fast-check doesn't just report the complex, randomly generated value that caused the failure. Instead, it automatically tries to find the smallest and simplest possible input that still triggers the bug. This process significantly simplifies debugging by presenting a minimal, reproducible counterexample.
This document details the lifecycle of a test failure, from initial detection through the shrinking process to the final report. It also covers advanced failure scenarios, such as timeouts and precondition failures, and how to interpret the detailed failure reports provided by the framework. The goal is to empower developers to focus on defining what their code should do, while fast-check handles the discovery and simplification of what can go wrong.
Sources: website/blog/2023-09-17-whats-new-in-fast-check-3-13-0/index.md
The Failure and Shrinking Lifecycle
When fc.assert executes a property, it follows a distinct workflow upon encountering a failure. The process is designed to not only identify a failing case but to simplify it for easier debugging.
The diagram below illustrates the high-level workflow of a fast-check run when a failure is detected.
This flowchart shows the test run proceeding until a failure triggers the shrinking loop, which continues until a minimal counterexample is found and reported.
Sources: website/blog/2023-10-05-finding-back-a-redos-vulnerability-in-zod/index.md
1. Failure Detection
A failure occurs when the predicate function within a property (e.g., fc.property(...)) either throws an exception or returns false. fast-check's runner catches this and, instead of immediately stopping, initiates the shrinking process.
2. The Shrinking Process
Shrinking is the mechanism for reducing a failing input to its simplest form. This is a core feature and is built into the definition of every Arbitrary.
Arbitrary and Shrinking
The Arbitrary<T> class is responsible for both generating values of type T and shrinking them. It contains a shrink method that takes a failing value and returns an iterable stream of "smaller" candidate values.
// Simplified from packages/fast-check/src/check/arbitrary/definition/Arbitrary.ts
export abstract class Arbitrary<T> {
abstract generate(mrng: Random, biasFactor: number | undefined): Value<T>;
abstract canShrinkWithoutContext(value: unknown): value is T;
abstract shrink(value: T, context: unknown): Stream<Value<T>>;
}
The runner uses the shrink method to explore simpler values, repeatedly calling the test predicate to confirm if the smaller value still causes a failure. This continues until no smaller value can be found that still fails the test.
Sources: packages/fast-check/src/check/arbitrary/definition/Arbitrary.ts:13-15
Disabling Shrinking
While shrinking is highly valuable for debugging, it can be disabled for specific use cases, such as performance-sensitive fuzzing where finding any failure quickly is more important than finding the smallest one. This can be done by passing { endOnFailure: true } to fc.assert. The older .noShrink method was deprecated and removed in version 4.0.0, indicating a move towards configuration-based control.
Sources: website/blog/2023-10-05-finding-back-a-redos-vulnerability-in-zod/index.md, website/blog/2025-03-10-whats-new-in-fast-check-4-0-0/index.md:36-36
Failure Reporting
When fast-check completes the shrinking process, it generates a detailed report to help the user understand and reproduce the failure.
The RunDetails Interface
The result of a test run is captured in an object that implements the RunDetails interface. This object contains all the critical information about the failure.
The following class diagram shows the structure of the RunDetails interface.
Key Failure Report Properties
The table below describes the most important properties of the RunDetails object for debugging failures.
| Property | Type | Description |
|---|---|---|
failed | boolean | true if the property failed. |
numRuns | number | The number of tests executed before the first failure was found. |
numShrinks | number | The number of successful shrinks performed to simplify the counterexample. |
seed | number | The seed used for the random number generator. Re-using this seed will reproduce the same test run. |
path | string | The path taken by the generator to produce the initial failing value. Combined with the seed, it allows for exact reproduction. |
counterexample | any | The minimal failing input found after the shrinking process. |
error | string | The error message produced by the failing predicate. |
Sources: packages/fast-check/src/check/runner/reporter/RunDetails.ts, website/blog/2023-10-05-finding-back-a-redos-vulnerability-in-zod/index.md
Example Failure Output
The RunDetailsFormatter class takes the RunDetails object and formats it into a human-readable error message. A typical failure report looks like this:
Error: Property failed after 1233 tests
{ seed: 2051841007, path: "1232:5:1:2:12:18:24:30:36", endOnFailure: true }
Counterexample: ["..."]
Shrunk 8 time(s)
Got error: The computation took 1667.1613000035286ms
This output provides the seed and path for reproduction, the final Counterexample, the number of shrinks, and the specific error message, giving the developer everything needed to start debugging.
Sources: packages/fast-check/src/check/runner/utils/RunDetailsFormatter.ts, website/blog/2023-10-05-finding-back-a-redos-vulnerability-in-zod/index.md
Advanced Failure Scenarios
fast-check can handle more than just simple predicate failures.
Timeouts and Infinite Loops
Synchronous JavaScript code that enters an infinite loop or a very long computation will block the main thread, and fast-check alone cannot interrupt it. To handle these scenarios, the @fast-check/worker package can be used. It runs each property execution in a separate worker thread.
With @fast-check/worker, you can configure an interruptAfterTimeLimit. If a single run exceeds this limit, the worker is terminated, and the run is marked as a failure. This is essential for detecting performance issues like Regular Expression Denial of Service (ReDoS) vulnerabilities.
Sources: website/blog/2023-10-05-finding-back-a-redos-vulnerability-in-zod/index.md, packages/worker/CHANGELOG.md
Precondition Failures
Properties can use fc.pre(condition) to specify preconditions for a test to run. If the condition is not met, fast-check discards the generated value and tries a new one. However, if too many values are discarded in a row, fast-check will fail the entire property run. This is not a bug in the code under test but rather an indication that the arbitraries are not generating suitable data for the property, or the precondition is too restrictive.
Sources: packages/worker/CHANGELOG.md
Conclusion
Understanding how fast-check handles failures and shrinking is crucial for leveraging its full potential. The framework is designed not just to find bugs but to make them easy to understand and fix. By automatically simplifying failing inputs into minimal counterexamples and providing detailed, reproducible reports, fast-check streamlines the debugging process. For complex scenarios involving long-running computations, the @fast-check/worker extension provides robust timeout handling, ensuring that even performance-related bugs can be reliably caught and analyzed.