fast-check
fast-check is property-based testing for JavaScript and TypeScript. Instead of writing example tests one input at a time, you describe the shape of valid inputs with generators it calls arbitraries (fc.string(), fc.integer(), fc.record(...)), then state a property that should hold for every one of them. fast-check runs the property against 100 generated inputs by default, and when one fails it shrinks that input down to the smallest value that still breaks your code and prints a seed plus a path so the exact run can be replayed. It works with any test runner because fc.assert simply throws on failure, and it goes past plain input generation with model-based testing for state machines and a scheduler that reorders promise resolutions to expose race conditions.
The default property-based testing library for JavaScript and TypeScript, with better docs and better shrinking than anything else on npm. Apply it to the handful of pure functions and state machines where invariants genuinely exist, and leave the rest of your suite as example tests.
Use it if
- You have functions with real invariants: parse/serialize round-trips, sorting, deduplication, date math, money rounding, cache keys, anything where "for all inputs, X holds" is a true statement
- You want the smallest failing input, not a random one: shrinking plus the printed seed and path make a counterexample reproducible in CI and locally
- You are hunting async race conditions and fc.scheduler() can reorder how your promises resolve, which finds interleavings a hand-written test would never try
- You are testing a stateful object (a cache, a reducer, a connection pool) and want model-based testing, where generated command sequences run against both a simple model and the real implementation
- Most of your suite checks wiring and specific business rules ("admin sees the delete button", "the webhook posts this exact JSON"): properties do not fit there and you will end up inventing invariants that are not real
- CI minutes are tight: every property runs 100 cases by default, each extra arbitrary multiplies the search space, and a suite converted wholesale to properties gets noticeably slower. Teams also dislike that a new seed can fail a build that passed yesterday
- You only need plausible fake data for fixtures (names, emails, addresses). @faker-js/faker does that with no new concepts; the whole point of fast-check is shrinking and hostile values, which you pay for in learning time
- You are stuck on v3 with no migration budget: v4 deleted the single-character arbitraries (fc.char, fc.hexa, fc.ascii), folded asciiString, fullUnicodeString and stringOf into fc.string({ unit }), removed fc.bigUint and fc.bigIntN, and changed defaults so fc.date() now generates Invalid Date while fc.record() and fc.dictionary() can hand you null-prototype objects
- Nobody on the team wants to think in invariants. Property tests written without that shift end up asserting the implementation back to itself, which costs runtime and catches nothing
Setup reality
npm install --save-dev fast-check is genuinely small: one runtime dependency (pure-rand), no native build, both ESM and CJS entry points. v4 wants an ES2020 runtime and TypeScript 5.0 or later if you type your own arbitraries. The friction shows up after install. Failures print a seed and a path that you must paste back into fc.assert options to replay, and your team needs a policy on whether CI pins the seed or accepts new random runs each build. Test runner integration is manual unless you add @fast-check/vitest or @fast-check/jest for the test.prop syntax. v4 also attaches your original error as an Error cause instead of concatenating messages into one string, so custom reporters that parsed the old format need updating or the includeErrorInReport flag.
Patterns
Write and run a first propertyfirst-property
import fc from 'fast-check';
const slugify = (s) => s.trim().toLowerCase().replace(/\s+/g, "-");
it('slugify never returns leading or trailing dashes', () => {
fc.assert(
fc.property(fc.string(), (input) => {
const out = slugify(input);
return !out.startsWith("-") && !out.endsWith("-");
}),
);
});fc.assert throws on failure, so it plugs into any runner. Returning false fails the property; throwing (for example from expect) also fails it. This example fails on inputs like "a b", which is exactly the point.
Test asynchronous codeasync-property
import fc from 'fast-check';
it('save then load returns the same record', async () => {
await fc.assert(
fc.asyncProperty(fc.string(), fc.integer(), async (id, value) => {
await store.save(id, value);
return (await store.load(id)) === value;
}),
{ numRuns: 50 },
);
});Use fc.asyncProperty and await fc.assert, otherwise the test finishes before the property does and always passes. Lower numRuns when each run touches I/O.
Generate strings the v4 waystring-arbitraries-v4
import fc from 'fast-check';
// default unit is printable ASCII
fc.string({ minLength: 1, maxLength: 20 });
// full unicode, including emoji and combining marks
fc.string({ unit: 'grapheme' });
// lone surrogates and other binary-unsafe content
fc.string({ unit: 'binary' });
// build from your own alphabet (replaces fc.stringOf)
fc.string({ unit: fc.constantFrom('a', 'b', 'c') });v4 collapsed asciiString, unicodeString, fullUnicodeString, string16bits and stringOf into fc.string with a unit constraint. fc.char, fc.hexa and friends are gone; a single character is just minLength: 1, maxLength: 1.
Generate objects with optional keysobject-arbitraries
import fc from 'fast-check';
const userArb = fc.record(
{
id: fc.uuid({ version: 4 }),
name: fc.string({ minLength: 1 }),
age: fc.integer({ min: 0, max: 130 }),
},
{ requiredKeys: ['id'], noNullPrototype: true },
);In v4 fc.record and fc.dictionary may produce objects with a null prototype unless you pass noNullPrototype: true. requiredKeys replaced the removed withDeletedKeys flag; requiredKeys: [] makes every key optional.
Replay an exact failing runreplay-a-failure
// Reported: Property failed after 12 tests (seed: 1527422598337, path: "3:0:1")
fc.assert(myProperty, {
seed: 1527422598337,
path: '3:0:1',
endOnFailure: true,
});Copy the seed and path straight from the failure output. endOnFailure skips shrinking so you land on the reported counterexample instead of re-deriving it. Remove the options once the bug is fixed, or the test stops exploring.
Build custom arbitraries with map, chain and filterderive-arbitraries
import fc from 'fast-check';
// map: transform generated values
const evenArb = fc.integer().map((n) => n * 2);
// chain: use one generated value to build the next arbitrary
const arrayAndIndex = fc
.array(fc.integer(), { minLength: 1 })
.chain((xs) => fc.tuple(fc.constant(xs), fc.nat({ max: xs.length - 1 })));
// filter: drop values you cannot use (keep it cheap)
const nonEmpty = fc.string().filter((s) => s.trim().length > 0);map keeps shrinking working; a one-argument map shrinks through the source arbitrary. Aggressive filter predicates make generation slow or fail outright, so prefer constraints (minLength) or map over filtering.
Skip inputs that do not applypreconditions
import fc from 'fast-check';
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
fc.pre(b !== 0);
return Math.abs(a % b) < Math.abs(b);
}),
);fc.pre marks a run as skipped rather than failed. If too many runs are skipped, fast-check errors out instead of pretending the property passed, which is the behaviour you want.
Always run your known edge casesseed-custom-examples
fc.assert(
fc.property(fc.string(), (s) => parse(format(s)) === s),
{
examples: [[''], ['\n'], ['\u0000'], [' spaced ']],
numRuns: 200,
verbose: true,
},
);examples run before the generated ones, so regressions from past bugs are checked every time without a separate test. verbose: true prints every failing value encountered during shrinking.
Test a stateful class against a modelmodel-based-testing
import fc from 'fast-check';
class PushCommand {
constructor(value) { this.value = value; }
check() { return true; }
run(model, real) {
model.push(this.value);
real.push(this.value);
expect(real.size()).toBe(model.length);
}
}
class PopCommand {
check(model) { return model.length > 0; }
run(model, real) {
expect(real.pop()).toBe(model.pop());
}
}
fc.assert(
fc.property(
fc.commands([fc.integer().map((v) => new PushCommand(v)), fc.constant(new PopCommand())]),
(cmds) => {
fc.modelRun(() => ({ model: [], real: new MyStack() }), cmds);
},
),
);check() decides whether a command is legal in the current model state; fast-check shrinks the command sequence itself, so you get the shortest sequence that breaks the invariant.
Shuffle promise resolution order to find racesdetect-race-conditions
import fc from 'fast-check';
it('concurrent updates never lose a write', async () => {
await fc.assert(
fc.asyncProperty(fc.scheduler(), async (s) => {
const db = new FakeDb(s);
const a = updateProfile(db, { name: "a" });
const b = updateProfile(db, { name: "b" });
await s.waitIdle();
await Promise.all([a, b]);
expect(db.writes).toHaveLength(2);
}),
);
});The scheduler decides the order in which scheduled promises settle, so each run is a different interleaving. Since 4.2 prefer waitNext, waitIdle and waitFor; waitOne and waitAll are deprecated because they behave badly when tasks are scheduled after a few awaits.
Set project-wide run settingsglobal-config
// test-setup.ts, loaded by your runner's setupFiles
import fc from 'fast-check';
fc.configureGlobal({
numRuns: process.env.CI ? 500 : 50,
interruptAfterTimeLimit: 30_000,
markInterruptAsFailure: false,
});A common setup: few runs locally for speed, many in CI. interruptAfterTimeLimit stops a long property instead of hanging the build, and with markInterruptAsFailure false a timeout is reported as a pass rather than a red build.
Use test.prop with Vitestvitest-integration
import { test, fc } from '@fast-check/vitest';
test.prop([fc.string(), fc.string()])('concat contains both parts', (a, b) => {
const joined = a + b;
return joined.includes(a) && joined.includes(b);
});
test.prop({ a: fc.nat(), b: fc.nat() })('addition commutes', ({ a, b }) => {
return a + b === b + a;
});Install @fast-check/vitest separately (@fast-check/jest for Jest). It reports each property as one named test and prints the seed on failure, which reads better in CI logs than a bare fc.assert stack trace.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @fast-check/vitest | npm | You use Vitest and want test.prop syntax with proper test names instead of wrapping everything in fc.assert yourself. |
| @faker-js/faker | npm | You want realistic-looking fixture data rather than adversarial inputs, and you do not need shrinking. |
| testcheck | npm | You want a much smaller, ClojureScript-inspired property testing API and can accept a far quieter project with no TypeScript-first design. |