fast-check property-based testing for TypeScript and JavaScript
fast-check generates many random inputs against a property and shrinks any
failing case to a minimal counter-example. It is the cheapest verification
adversary for functions whose input space is too large to enumerate, and its
command runner extends the same idea to stateful systems and race conditions.
Where a property must hold for all inputs rather than all sampled inputs,
escalate to formal proof with the lemmascript skill instead.
When to apply
Apply when a function has an algebraic property (round-trip, idempotence, ordering, conservation, commutativity), when a parser or codec must round-trip across all valid inputs, when an oracle is available (reference implementation, invariant predicate, prior version), when a stateful API must stay consistent with a simplified model across arbitrary operation sequences, or when async code may harbour race conditions that only surface under adversarial interleaving.
Do not apply when the input space is small enough to enumerate exhaustively (write a table-driven test), when the property must hold unconditionally and the cost of a missed case is severe (use LemmaScript to prove it), or when each generated input performs heavy real I/O — slow runs starve the generator and the test finds nothing.
Installation
npm install --save-dev fast-check
The current major is 4.x (latest: 4.9.0). It requires Node ≥ 12.17 and, for
TypeScript users, TS ≥ 5.0. fast-check is runner-agnostic: it works under
Vitest, Jest, and the Node test runner unchanged. Optional ergonomic bindings
exist as @fast-check/vitest, @fast-check/jest, and @fast-check/ava.
Core concepts
A property test pairs an arbitrary (how to generate values) with a predicate:
import fc from "fast-check";
test("decode round-trips encode", () => {
fc.assert(
fc.property(fc.string(), (s) => {
expect(decode(encode(s))).toBe(s);
}),
);
});
Key pieces:
- An arbitrary is an
Arbitrary<T>.fc.integer,fc.string,fc.array,fc.record,fc.oneof, andfc.constantFromare the everyday starters;fc.letrecbuilds recursive structures. - The predicate either returns a boolean or throws (any assertion
library works).
fc.assertcatches the failure and shrinks. fc.pre(cond)rejects the current case as uninteresting; use it sparingly (see the filtering trap below).fc.asyncPropertyis the async twin;fc.assertreturns a promise for it — remember toawait.fc.assert(..., { numRuns, seed, path, verbose })tunes per-test.
A catalogue of arbitraries, composition patterns (map, chain, record,
letrec), and the filtering-trap fix live in
references/arbitraries.md.
The filtering trap
fc.pre(cond) and .filter(pred) use rejection sampling. Heavy rejection
wastes the run budget and degrades shrinking: a shrunk candidate that gets
rejected cannot guide the shrinker, so the minimized counter-example is larger
than it needs to be.
The fix is to construct only valid values from the seed. Replace "draw an int,
require it even" with "draw an int and double it"; replace "draw two values,
require a < b" with "draw b, then map a into [0, b)". Before-and-after
examples live in references/arbitraries.md.
Model-based (stateful) testing
For stateful systems, define one command class per operation, each with
check(model) (is the operation valid now?), run(model, real) (execute
against both, assert consistency), and toString(). Feed them to
fc.commands(...) and execute with fc.modelRun (sync), fc.asyncModelRun
(async), or fc.scheduledModelRun (async with adversarial interleaving). The
model must be a simplified representation — a carbon copy of the system tests
the code against itself.
The full worked example, replay mechanics (replayPath), and guidance on model
design live in
references/model-based-testing.md.
Race-condition detection
fc.scheduler() produces a scheduler arbitrary that reorders scheduled
promises to explore interleavings. Wrap the async operations under test with
s.schedule(...) (or s.scheduleFunction), then drive the run with
s.waitFor(promise), s.waitNext(n), or s.waitIdle(). waitOne/waitAll
are deprecated since 4.2 — prefer the newer trio, which behave predictably when
tasks are scheduled after intermediate awaits. Worked examples live in
references/model-based-testing.md.
fast-check 4.x versus 3.x
Watch for stale 3.x idioms in existing suites and in generated code:
- Character-set string arbitraries are gone.
fc.asciiString(),fc.unicodeString(),fc.hexaString(),fc.stringOf(arb)all becomefc.string({ unit: ... })— e.g.fc.string({ unit: "binary" })for full Unicode,fc.string({ unit: fc.constantFrom("a", "b") })for custom units. .noBias()/.noShrink()methods becomefc.noBias(arb)/fc.noShrink(arb).fc.uuidV(4)becomesfc.uuid({ version: 4 });fc.bigUintNand friends collapse intofc.bigInt({ min, max }).fc.date()now generates Invalid Date by default — pass{ noInvalidDate: true }if the code under test cannot take one.fc.record(model, { withDeletedKeys: true })becomes{ requiredKeys: [] };withDeletedKeys: falseretains the default required-key behaviour.recordanddictionarymay now generate null-prototype objects unless{ noNullPrototype: true }.- Failures now attach the original error as
Error.causeinstead of splicing messages; pass{ includeErrorInReport: true }to restore the old report text.
Anti-patterns
- Swallowing the failure. An
expectinside atry/catchthat ignores the error hides the counter-example from the runner. - Asserting only "does not throw". Pair every no-throw property with a real check (round-trip, oracle, invariant).
- Re-implementing the function under test as the oracle. Use a structurally different reference: brute force, prior version, property predicate.
- Forgetting to
await fc.asserton async properties. The test passes vacuously; enablerequire-await-style lint rules or use@fast-check/vitestwhich handles it. - Lowering
numRunsto make a flake go away. If the property fails at 1000 runs but not 100, the test has found a bug. - A model that mirrors the system. Model-based testing compares the system to a simpler model; a clone proves nothing.
Project integration
- Promote shrunk failures to named unit tests with the input
pinned and a comment explaining the bug. fast-check has no failure database;
the report's
{ seed, path }(plusreplayPathfor commands) is the only replay handle, so capture it immediately. - Tier runs. Default
numRuns(100) in the inner loop and CI; a nightly job withnumRuns: 10000widens the search. Configure globally withfc.configureGlobal({ numRuns }). - Keep generation deterministic per failure. Replay with
fc.assert(prop, { seed, path, endOnFailure: true })before attempting a fix, and re-run without the pin afterwards. - Validate each property with a deliberate mutation. Break the production code, confirm the property fails with a useful shrunk input, then restore. StrykerJS automates this across the suite.
Hard-won lessons
- Arbitraries decide which cases are tested. A weak generator makes a
strong property look strong. Audit the arbitrary first — e.g.
fc.string()alone never exercises surrogate pairs; add{ unit: "binary" }coverage for codec properties. - Shrinking is sacred. Never swallow failures, never tune
numRunsto hide a flake, never filter when composition is possible. - The report is not a regression test. Promote each failure to a named unit test; seeds rot as arbitraries evolve.
- Sampled is not proved. When the property is load-bearing enough
to prove, escalate to the
lemmascriptskill and keep the fast-check property as a fast regression net beneath the proof.
References
- fast-check documentation and GitHub repository.
- Arbitraries reference.
- Model-based testing and race conditions.
- Migration guide 3.x → 4.x.
references/arbitraries.mdfor worked generators and the filtering-trap fix.references/model-based-testing.mdfor commands, replay, and the scheduler.- Escalation from sampled properties to machine-checked proof lives in
../lemmascript/SKILL.md.