Writing Your First Property
Relevant source files
The following files were used as context for generating this wiki page:Property-based testing is a powerful technique that goes beyond example-based testing. Instead of writing tests for specific, hard-coded inputs, you define general properties or invariants that your code should always satisfy. The fast-check library then generates a large number of random inputs to try and find a "counterexample" that falsifies your property. This approach is highly effective at discovering edge cases and bugs that might be missed with traditional testing.
This guide will walk you through the fundamental concepts of property-based testing with fast-check, using a simple sort function as a running example. You will learn how to define properties, use arbitraries to generate data, and integrate fast-check with your test runner.
Core Concepts
A property-based test in fast-check is composed of three main parts: arbitraries, a property, and an assertion runner.
- Arbitraries: These are generators for random data.
fast-checkprovides a rich set of built-in arbitraries for primitive types, complex data structures, and more (e.g.,fc.integer(),fc.string(),fc.array(...)). - Property: This is a function that takes the generated data as input and returns
trueorvoidif the invariant holds, orfalseor throws an error if it is violated. Properties are defined usingfc.property(...). - Assertion Runner: This is the function that runs the test, typically
fc.assert(...)or a test runner integration like@fast-check/vitest'sit.prop. It repeatedly calls the property with new inputs, and if a failure is found, it attempts to "shrink" the input to the smallest possible counterexample.
The Property Testing Flow
The following diagram illustrates the lifecycle of a property-based test executed by fast-check.
This diagram shows how fast-check generates inputs, runs the test predicate, and upon failure, enters a shrinking phase to find a minimal counterexample.
Sources: examples/001-simple/sort/main.spec.ts, website/blog/2023-10-05-finding-back-a-redos-vulnerability-in-zod/index.md:58-71
Example: Testing a Sort Function
Let's write properties for a simple sort function. The function takes an array of numbers and is expected to return a new, sorted array.
The Function Under Test
Here is the sort implementation we will be testing. It creates a copy of the input array and sorts it.
// examples/001-simple/sort/src/sort.ts
// sort an array of numbers
export const sort = (data: number[]): number[] => {
const dataCopied = [...data];
dataCopied.sort((a, b) => a - b);
return dataCopied;
};
Sources: examples/001-simple/sort/src/sort.ts:3-7
Defining the Properties
Instead of testing with sort([3, 1, 2]), we define general properties that should hold for any array of numbers.
Property 1: The output should be sorted
A sorted array has the property that every element is less than or equal to the element that follows it.
// examples/001-simple/sort/main.spec.ts:10-18
it('should produce a sorted array', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
const sorted = sort(data);
for (let idx = 1; idx < sorted.length; ++idx) {
expect(sorted[idx - 1]).toBeLessThanOrEqual(sorted[idx]);
}
}),
);
});
Here, fc.array(fc.integer()) is an arbitrary that generates arrays of random integers. The property then checks if the output of sort is ordered correctly.
Sources: examples/001-simple/sort/main.spec.ts:10-18
Property 2: The output should contain the same elements as the input
A sort function should not add or remove elements. A simple way to check this is to sort both the original and the output array and compare them. Since our function is supposed to sort, sorting the output again (idempotency) should yield the same array.
// examples/001-simple/sort/main.spec.ts:20-26
it('should have the same elements as the original array', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
const sorted = sort(data);
expect(sort(sorted)).toEqual(sorted);
}),
);
});
This property checks for idempotency: sorting an already sorted array should not change it.
Sources: examples/001-simple/sort/main.spec.ts:20-26
Property 3: The output should have the same length as the input
The length of the array should remain unchanged after sorting.
// examples/001-simple/sort/main.spec.ts:28-34
it('should not change the size of the array', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
const sorted = sort(data);
expect(sorted.length).toBe(data.length);
}),
);
});
Sources: examples/001-simple/sort/main.spec.ts:28-34
How to Find Properties
Coming up with good properties is a key skill in property-based testing. The fast-check repository provides several common patterns for finding them.
| Pattern | Description | Example |
|---|---|---|
| Independent Characteristics | The output has a characteristic that doesn't depend on the input's value. | for any integer n, Math.abs(n) ≥ 0 |
| Derived Characteristics | The output's characteristic is derived from the input's value. | for any array data, sorted(data) and data contain the same elements |
| Restricted Inputs | For a specific subset of inputs, the output is known. | for any prime number p, its decomposition into prime factors is itself |
| Round-tripping | A combination of functions returns the original input. | for any file f, unzip(zip(f)) is the original file |
| Comparison to Simpler Model | The function's output is compared to a simpler, slower, but known-to-be-correct implementation. | Comparing a complex sort algorithm against a naive bubble sort. |
Sources: examples/README.md:43-71
Integration with Test Runners
While fc.assert is a standalone runner, fast-check offers seamless integration with popular test runners like Vitest and Jest. This is done through dedicated packages like @fast-check/vitest.
The integration provides a .prop method on the test runner's it and test functions, which combines the test definition and property assertion into a single, clean statement.
Test Runner Integration Flow
This diagram shows how a test runner like Vitest interacts with fast-check when using the .prop API.
Example with @fast-check/vitest
The following test shows how to use it.prop to write a concise property-based test. This code is from an internal test for the @fast-check/vitest package itself.
// packages/vitest/test/vitest-fast-check.spec.ts:51-57
it.concurrent(`should support it.prop`, async () => {
// Arrange
const specDirectory = await writeToFile('it', () => {
runner.prop([fc.string(), fc.string(), fc.string()])('property', (a, b, c) => {
return `${a}${b}${c}`.includes(b);
});
});
// ...
});
In this snippet, runner is an alias for it from @fast-check/vitest. The .prop method takes an array of arbitraries and the predicate function, handling the assert call internally.
Sources: packages/vitest/test/vitest-fast-check.spec.ts:51-57
Advanced Use Case: Detecting Timeouts
Properties are not limited to checking correctness; they can also be used to detect non-functional issues like performance regressions or Denial of Service (DoS) vulnerabilities.
By wrapping the function call with performance.now(), you can write a property that fails if the execution time exceeds a certain threshold.
// website/blog/2023-10-05-finding-back-a-redos-vulnerability-in-zod/index.md:38-53
import { z } from 'zod'; // eg.: 3.22.2 is vulnerable
import fc from 'fast-check';
const timeLimitMs = 1_000;
const validator = z.string().email();
fc.assert(
fc.property(fc.emailAddress(), (rawString) => {
const startTime = performance.now();
try {
validator.parse(rawString);
} catch (err) {}
const endTime = performance.now();
const delayMs = endTime - startTime;
if (delayMs > timeLimitMs) {
throw new Error(`The computation of validator.parse took ${delayMs}ms`);
}
}),
);
This example shows how to find a ReDoS vulnerability in zod's email validator by asserting that parsing does not take too long. For synchronous code that might hang indefinitely, the @fast-check/worker package can be used to run the property in a separate worker thread with a hard time limit.
Sources: website/blog/2023-10-05-finding-back-a-redos-vulnerability-in-zod/index.md:38-53, packages/worker/CHANGELOG.md:120-122
Conclusion
Writing your first property with fast-check involves shifting your mindset from specific examples to general invariants. By defining properties about your code's behavior and letting fast-check generate a vast range of inputs, you can uncover subtle bugs and edge cases with confidence. The core workflow involves defining arbitraries for your data, wrapping your checks in fc.property, and using an assertion runner like fc.assert or a test runner integration to find counterexamples.