mrkeyoor.com_
Thu 06 Aug 05:57 UTC
npmTestingupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5v4 (2025) removed a long list of helpers and changed defaults for date, record and dictionary, so major upgrades are real work; inside a major, changes are additive and deprecations get warned first, as with waitOne and waitAll giving way to waitNext, waitIdle and waitFor in 4.2.
Docs5/5fast-check.dev has a hands-on tutorial, a reference for every arbitrary, dedicated guides for race conditions and model-based testing, and a migration guide that gives replacement code for every removed function.
Maintenance4/5Pushed 5 August 2026 with 59 open issues (78 counting PRs) and a steady release cadence, but Nicolas Dubien is effectively the single maintainer, so the bus factor is the main risk.
Ecosystem4/5Around 29.8 million weekly downloads, largely transitive through projects that test with it, plus first-party runner bindings (@fast-check/vitest, @fast-check/jest, @fast-check/ava) and a worker package for isolating slow properties.

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
Skip it if

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

PackageRegistryPick it when
@fast-check/vitestnpmYou use Vitest and want test.prop syntax with proper test names instead of wrapping everything in fc.assert yourself.
@faker-js/fakernpmYou want realistic-looking fixture data rather than adversarial inputs, and you do not need shrinking.
testchecknpmYou want a much smaller, ClojureScript-inspired property testing API and can accept a far quieter project with no TypeScript-first design.