Creating Custom Arbitraries
Relevant source files
The following files were used as context for generating this wiki page:- examples/002-recursive/isSearchTree/arbitraries/BinaryTreeArbitrary.ts
- packages/fast-check/src/check/arbitrary/definition/Arbitrary.ts
- CONTRIBUTING.md
- packages/fast-check/MIGRATION_2.X_TO_3.X.md
- website/blog/2023-05-30-whats-new-in-fast-check-3-10-0/index.md
- website/blog/2024-07-12-whats-new-in-fast-check-3-20-0/index.md
fast-check provides a rich set of built-in arbitraries, but for complex data structures or specific data formats, users may need to create their own. Custom arbitraries allow for the generation of tailored test data, ensuring more effective and targeted property-based testing. They can be created by combining existing arbitraries, which is the most common approach, or by implementing the Arbitrary class for advanced scenarios.
This guide covers the fundamental concepts and different methods for creating custom arbitraries, from simple combinations to full implementations with comprehensive testing, as practiced within the fast-check project itself.
Sources: CONTRIBUTING.md
Core Concepts: The Arbitrary<T> Class
The foundation of data generation in fast-check is the Arbitrary<T> class. Every generator, whether built-in or custom, is an instance of this class. It defines the core logic for generating values and, crucially, for shrinking them when a test case fails.
Sources: packages/fast-check/src/check/arbitrary/definition/Arbitrary.ts
Arbitrary<T> Class Structure
The diagram below illustrates the essential methods of the Arbitrary<T> class.
*generate(mrng, biasFactor)**: Produces a new random value of type T. It uses a Random instance (mrng) for randomness and can be influenced by a biasFactor to favor certain "edge case" values.
canShrinkWithoutContext(value): A type guard that checks if a given value can be shrunk by this arbitrary without needing an original generation context.shrink(value, context): Takes a valueTand its generationcontextand returns aStreamof simpler or "smaller" values. This is the core of the shrinking process.
Sources: packages/fast-check/src/check/arbitrary/definition/Arbitrary.ts:18-43
API Evolution: Arbitrary vs. NextArbitrary
In versions prior to 3.x, there were two arbitrary APIs: the legacy Arbitrary and the newer NextArbitrary. As of version 3.x, the legacy API has been removed, and NextArbitrary has been promoted to be the sole Arbitrary class. This change was part of a major refactor to unify and simplify the internal APIs. During the transition, fc.convertFromNext and fc.convertToNext helpers were available to bridge the two APIs, but they have since been removed in v3.
Sources: packages/fast-check/MIGRATION_2.X_TO_3.X.md
Method 1: Combining Existing Arbitraries
The simplest and most powerful way to create custom arbitraries is by composing the built-in ones. fast-check offers several "combiner" functions for this purpose.
Using .map()
The .map() method transforms the output of one arbitrary into another. For example, you can create an arbitrary for a User object from a string and integer arbitrary.
Using fc.tuple() and fc.record()
These functions allow you to build complex objects and arrays with a fixed structure.
Using fc.stringMatching()
For string-based data with a specific format, fc.stringMatching() allows you to generate values from a regular expression. This is often simpler than manually combining string and character arbitraries.
For instance, an IPv4 address arbitrary can be created with a single regex.
// Example of creating an IPv4 arbitrary using stringMatching
const ipV4Arbitrary = fc.stringMatching(
/^(?:\d|[1-9]\d|1\d\d|2[0-5]\d)\.(?:\d|[1-9]\d|1\d\d|2[0-5]\d)\.(?:\d|[1-9]\d|1\d\d|2[0-5]\d)\.(?:\d|[1-9]\d|1\d\d|2[0-5]\d)$/,
);
Note: It is important to anchor the regex with ^ and $ to ensure the entire string matches the pattern, not just a substring. The stringMatching arbitrary has some limitations and does not support all regex features, such as lookaheads/lookbehinds.
Sources: website/blog/2023-05-30-whats-new-in-fast-check-3-10-0/index.md
Method 2: Creating Recursive Arbitraries with fc.letrec
For recursive data structures like trees or linked lists, fast-check provides the fc.letrec helper. It allows an arbitrary to refer to itself or other arbitraries being defined within the same letrec block.
The following diagram shows the logic for building a recursive tree structure.
A critical rule when using fc.letrec with fc.oneof is that the base case (leaf) must be placed before the recursive case (node) in the oneof call. This helps the generator manage recursion depth effectively, especially as it relates to the size parameter.
// Correct usage of fc.letrec with fc.oneof
const { tree } = fc.letrec((tie) => ({
tree: fc.oneof(tie('leaf'), tie('node')), // Good: leaf is first
node: fc.record({ left: tie('tree'), right: tie('tree') }),
leaf: fc.nat(),
}));
// Incorrect usage
// const { tree } = fc.letrec((tie) => ({
// tree: fc.oneof(tie('node'), tie('leaf')), // BAD: recursive case is first
// ...
// }));
Sources: packages/fast-check/MIGRATION_2.X_TO_3.X.md
Method 3: Modifying Arbitrary Behavior
Sometimes, you don't need a new structure but want to alter the behavior of an existing arbitrary. fast-check provides wrapper functions for this.
| Function | Description | Version |
|---|---|---|
fc.noShrink(arb) | Disables the shrinking process for the given arbitrary. | >= 3.20.0 |
fc.noBias(arb) | Disables the biasing mechanism, which favors edge-case values. | >= 3.20.0 |
fc.limitShrink(arb, N) | Limits the shrinker to produce at most N smaller values. | >= 3.20.0 |
These functions replace the deprecated prototype methods (.noShrink(), .noBias()). While limiting shrinking can speed up test runs on failure, it is generally recommended to use endOnFailure or interruptAfterTimeLimit instead, as aggressive shrinking is key to finding the simplest failing case.
Sources: website/blog/2024-07-12-whats-new-in-fast-check-3-20-0/index.md
Testing a New Arbitrary
When contributing a new arbitrary to fast-check or building a complex one for a private library, thorough testing is essential to ensure it adheres to the framework's contracts. The contribution guide outlines a standard testing procedure.
This diagram shows the recommended workflow for adding a new arbitrary to the core library.
Sources: CONTRIBUTING.md
Test Assertions
fast-check includes a set of internal helper functions to validate the behavior of new arbitraries. These assertions check for correctness, determinism, and shrinking properties.
| Assertion Helper | Purpose |
|---|---|
assertProduceSameValueGivenSameSeed | Ensures the arbitrary is deterministic: the same seed always produces the same value. |
assertProduceCorrectValues | Checks that all generated values are valid according to a provided isCorrect predicate. |
assertProduceValuesShrinkableWithoutContext | (Optional) Verifies that generated values can be shrunk without their original context. |
assertShrinkProducesSameValueWithoutInitialContext | (Optional) Confirms that shrinking a value without context yields the same results as shrinking with it. |
assertShrinkProducesStrictlySmallerValue| (Optional) Guarantees that each step of the shrink process produces a value that is "strictly smaller" than the previous one, preventing infinite loops. |
These helpers are typically used within an "integration" test suite for the new arbitrary.
Sources: CONTRIBUTING.md
Conclusion
Creating custom arbitraries is a core feature of fast-check that enables developers to test any data model, no matter how complex. While most needs can be met by combining existing arbitraries with helpers like .map, fc.record, and fc.letrec, the framework also provides the low-level APIs and testing infrastructure to build and validate entirely new arbitraries from scratch, ensuring they are as robust and reliable as the built-in ones.