fast-check (JavaScript): Advanced Property-Based Testing Deep Dive
fast-check is the leading property-based testing library for JavaScript and TypeScript. While many tutorials show basic integer and string properties, real-world use requires custom arbitraries, model-based testing, async support, and proper TypeScript typing. This post covers the advanced patterns.
Why fast-check Over Other JS Options
fast-check has several advantages over older JS property-testing libraries:
- Shrinking built-in: All arbitraries shrink by default, including custom composite ones
- TypeScript-first: Strong typing with no type gymnastics
- Async support: First-class
asyncPropertyfor Promise-returning code - Bias: Generators bias toward edge cases (empty strings, 0, MAX_INT, special chars)
Custom Arbitraries
fc.record() for Object Generation
import fc from 'fast-check';
const userArb = fc.record({
id: fc.uuid(),
email: fc.emailAddress(),
age: fc.integer({ min: 18, max: 120 }),
role: fc.constantFrom('admin', 'user', 'guest'),
createdAt: fc.date({ min: new Date('2020-01-01'), max: new Date() }),
});
// TypeScript infers the type automatically:
// { id: string, email: string, age: number, role: 'admin'|'user'|'guest', createdAt: Date }fc.oneof() for Union Types
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: string; code: number };
function apiResultArb<T>(dataArb: fc.Arbitrary<T>): fc.Arbitrary<ApiResult<T>> {
return fc.oneof(
dataArb.map(data => ({ success: true as const, data })),
fc.record({
success: fc.constant(false as const),
error: fc.string({ minLength: 1 }),
code: fc.constantFrom(400, 401, 403, 404, 429, 500, 503),
})
);
}fc.letrec() for Recursive Structures
For mutually recursive data structures (like ASTs or JSON):
type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
const jsonArb: fc.Arbitrary<JsonValue> = fc.letrec((tie) => ({
json: fc.oneof(
{ depthFactor: 0.5 }, // Reduce depth probability to prevent infinite recursion
fc.constant(null),
fc.boolean(),
fc.double({ noNaN: true, noDefaultInfinity: true }),
fc.string(),
fc.array(tie('json'), { maxLength: 5 }),
fc.dictionary(
fc.string({ minLength: 1, maxLength: 20 }),
tie('json'),
{ maxKeys: 5 }
),
),
})).json;
fc.assert(
fc.property(jsonArb, (value) => {
const serialized = JSON.stringify(value);
const deserialized = JSON.parse(serialized);
expect(deserialized).toEqual(value);
})
);Custom Arbitrary with fc.Arbitrary
For full control, implement a custom arbitrary:
import { Arbitrary, Random, Stream, Value } from 'fast-check';
class SortedArrayArbitrary extends Arbitrary<number[]> {
private readonly inner: Arbitrary<number[]>;
constructor(private readonly size: { min: number; max: number }) {
super();
this.inner = fc.array(fc.integer(), size);
}
generate(mrng: Random, biasFactor: number | undefined): Value<number[]> {
const value = this.inner.generate(mrng, biasFactor);
return new Value(value.value.slice().sort((a, b) => a - b), value.context);
}
canShrinkWithoutContext(value: unknown): value is number[] {
return this.inner.canShrinkWithoutContext(value);
}
shrink(value: number[], context: unknown): Stream<Value<number[]>> {
return this.inner.shrink(value, context)
.map(v => new Value(v.value.slice().sort((a, b) => a - b), v.context));
}
}
const sortedArrayArb = new SortedArrayArbitrary({ min: 0, max: 20 });Model-Based Testing
Model-based testing compares a complex implementation against a simple reference model. fast-check provides fc.commands() for this pattern.
Testing a Priority Queue
import fc from 'fast-check';
// Commands represent operations on the system under test
abstract class PQCommand implements fc.Command<PQModel, PriorityQueue> {
abstract run(model: PQModel, real: PriorityQueue): void;
abstract check(model: PQModel): boolean;
abstract toString(): string;
}
// Model: simple array sorted by priority
interface PQModel {
items: Array<{ priority: number; value: string }>;
}
class PushCommand extends PQCommand {
constructor(
private readonly priority: number,
private readonly value: string
) { super(); }
check(_model: PQModel): boolean { return true; }
run(model: PQModel, real: PriorityQueue): void {
model.items.push({ priority: this.priority, value: this.value });
model.items.sort((a, b) => b.priority - a.priority);
real.push(this.priority, this.value);
}
toString(): string {
return `push(${this.priority}, "${this.value}")`;
}
}
class PopCommand extends PQCommand {
check(model: PQModel): boolean {
return model.items.length > 0;
}
run(model: PQModel, real: PriorityQueue): void {
const expected = model.items.shift()!;
const actual = real.pop();
expect(actual).toEqual({ priority: expected.priority, value: expected.value });
}
toString(): string { return 'pop()'; }
}
class PeekCommand extends PQCommand {
check(model: PQModel): boolean {
return model.items.length > 0;
}
run(model: PQModel, real: PriorityQueue): void {
const expected = model.items[0];
const actual = real.peek();
expect(actual).toEqual({ priority: expected.priority, value: expected.value });
}
toString(): string { return 'peek()'; }
}
const commandArb = fc.commands([
fc.record({ priority: fc.integer({ min: 1, max: 10 }), value: fc.string() })
.map(({ priority, value }) => new PushCommand(priority, value)),
fc.constant(new PopCommand()),
fc.constant(new PeekCommand()),
]);
test('priority queue model-based', () => {
fc.assert(
fc.property(commandArb, (cmds) => {
const model: PQModel = { items: [] };
const real = new PriorityQueue();
fc.modelRun(() => ({ model, real }), cmds);
})
);
});When fast-check finds a failing sequence, it shrinks the command sequence to the minimal sequence that still fails. This is invaluable for debugging stateful bugs.
Async Properties
import fc from 'fast-check';
import axios from 'axios';
test('API always returns valid schema', async () => {
await fc.assert(
fc.asyncProperty(
fc.record({
userId: fc.uuid(),
action: fc.constantFrom('view', 'edit', 'delete'),
}),
async ({ userId, action }) => {
const response = await axios.post('/api/audit', { userId, action });
expect(response.status).toBeGreaterThanOrEqual(200);
expect(response.status).toBeLessThan(600);
if (response.status === 200) {
expect(response.data).toMatchObject({
id: expect.any(String),
timestamp: expect.any(String),
});
}
}
),
{ numRuns: 50 }
);
});TypeScript Integration Patterns
Deriving Arbitraries from Types
fast-check can't automatically derive arbitraries from TypeScript types (unlike libraries in languages with richer type systems), but you can create helpers:
// Helper for exhaustive union coverage
function enumArb<T extends string>(values: readonly T[]): fc.Arbitrary<T> {
return fc.constantFrom(...values);
}
// Example usage with a discriminated union
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
const shapeArb: fc.Arbitrary<Shape> = fc.oneof(
fc.record({ kind: fc.constant('circle' as const), radius: fc.nat() }),
fc.record({
kind: fc.constant('rectangle' as const),
width: fc.nat(),
height: fc.nat()
}),
fc.record({
kind: fc.constant('triangle' as const),
base: fc.nat(),
height: fc.nat()
})
);Property Helpers for Common Patterns
// Encode laws as reusable property factories
const monoidLaws = <T>(
arb: fc.Arbitrary<T>,
concat: (a: T, b: T) => T,
empty: T,
label: string
) => {
test(`${label}: left identity`, () => {
fc.assert(fc.property(arb, (a) => {
expect(concat(empty, a)).toEqual(a);
}));
});
test(`${label}: right identity`, () => {
fc.assert(fc.property(arb, (a) => {
expect(concat(a, empty)).toEqual(a);
}));
});
test(`${label}: associativity`, () => {
fc.assert(fc.property(arb, arb, arb, (a, b, c) => {
expect(concat(concat(a, b), c)).toEqual(concat(a, concat(b, c)));
}));
});
};
// Use for arrays, strings, etc.
monoidLaws(
fc.array(fc.integer()),
(a, b) => [...a, ...b],
[],
'Array'
);Configuring fast-check for CI
// vitest.config.ts or jest.config.ts
fc.configureGlobal({
numRuns: process.env.CI ? 1000 : 100,
seed: process.env.FAST_CHECK_SEED
? parseInt(process.env.FAST_CHECK_SEED)
: undefined,
verbose: process.env.CI ? true : false,
});Reproduce a specific failure:
# fast-check logs: seed=1234567890 path="3:1:2"
FAST_CHECK_SEED=1234567890 npx vitestOr inline:
fc.assert(
fc.property(fc.string(), (s) => s.length >= 0),
{ seed: 1234567890, path: '3:1:2' }
);Biasing and Edge Cases
fast-check automatically biases generation toward edge cases. For numbers:
- 0, 1, -1
- MIN_SAFE_INTEGER, MAX_SAFE_INTEGER
- Small values near the min/max constraints
For strings:
- Empty string
- Single character
- Special characters (null bytes, unicode, control chars)
- Very long strings
This biasing means you don't need to write @example(0) annotations manually—fast-check will try zero automatically.
You can see what's being generated with verbose:
fc.assert(
fc.property(fc.integer(), (n) => n * 2 % 2 === 0),
{ verbose: true }
);Real-World Example: Testing a URL Parser
const urlComponentArb = fc.record({
protocol: fc.constantFrom('http', 'https'),
hostname: fc.domain(),
port: fc.option(fc.integer({ min: 1, max: 65535 }), { nil: undefined }),
path: fc.array(
fc.stringOf(fc.char().filter(c => /[a-zA-Z0-9-_.]/.test(c))),
{ maxLength: 5 }
).map(segments => '/' + segments.join('/')),
query: fc.option(
fc.dictionary(
fc.stringOf(fc.char().filter(c => /[a-zA-Z0-9_]/.test(c)), { minLength: 1 }),
fc.string()
),
{ nil: undefined }
),
});
test('URL parser roundtrip', () => {
fc.assert(
fc.property(urlComponentArb, ({ protocol, hostname, port, path, query }) => {
const queryString = query
? '?' + Object.entries(query)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&')
: '';
const portString = port ? `:${port}` : '';
const url = `${protocol}://${hostname}${portString}${path}${queryString}`;
const parsed = parseUrl(url);
expect(parsed.protocol).toBe(protocol);
expect(parsed.hostname).toBe(hostname);
expect(parsed.port).toBe(port ?? null);
})
);
});Integrating with Your Test Stack
fast-check works with any JS test runner. For Jest/Vitest, no additional setup is needed. For coverage integration:
// Coverage will show which code paths property tests exercise
// This often reveals that property tests hit branches unit tests missFor continuous monitoring of production invariants, combine fast-check's generated inputs with HelpMeTest—run property tests on a schedule against your production API to catch regressions before users do.
Key Takeaways
fc.letrec()handles recursive data structures like JSON and ASTsfc.commands()enables model-based testing for stateful systems—generates and shrinks operation sequencesfc.asyncProperty()first-class async support for testing APIs and async code- TypeScript types are inferred from
fc.record()and other combinators - Seeded replay: failed runs always provide a seed for exact reproduction
- Biased generation automatically tries edge cases you'd forget to write