fast-check-testing

Use whenever the task mentions property-based, generative or invariant tests in JavaScript or TypeScript (fast-check): fc.assert and fc.property, arbitraries and composite inputs, map, chain and filter, shrinking and seeds, with Jest, Vitest or Mocha. Has a worked example to start from.

/plugin install qa-property-based@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)

Overview

fast-check is "a property-based testing framework for JavaScript and TypeScript, inspired by QuickCheck" (fast-check-readme (opens in new window)).

It's runner-agnostic (fast-check-overview (opens in new window)): "Test Runner Agnostic: Works seamlessly with Jest, Mocha, Vitest, and other testing frameworks without special integration."

When to use

  • A JS/TS codebase has functions with non-trivial input shapes (parsers, serializers, validators, data transforms).
  • Async code where race conditions hide; fast-check supports race condition detection (fast-check-overview (opens in new window)).
  • Round-trip / metamorphic properties hold (encode(decode(x)) === x).
  • Existing example-based tests pass but production bugs keep appearing in edge cases not covered.

Install

Per fast-check-readme (opens in new window):

npm install fast-check --save-dev
# or
yarn add fast-check --dev
# or
pnpm add -D fast-check

Basic property

Per fast-check-readme (opens in new window), the canonical Mocha-style example:

import fc from 'fast-check';

const contains = (text, pattern) => text.indexOf(pattern) >= 0;

describe('properties', () => {
  it('should always contain itself', () => {
    fc.assert(fc.property(fc.string(), (text) => contains(text, text)));
  });

  it('should always contain its substrings', () => {
    fc.assert(
      fc.property(fc.string(), fc.string(), fc.string(), (a, b, c) => {
        return contains(a + b + c, b);
      })
    );
  });
});

The shape: fc.assert(fc.property(<arbitraries...>, (...inputs) => <predicate>)).

The predicate returns:

  • true → the property holds.
  • false → the property fails.
  • Throws an exception → the property fails (typical for expect(...) style assertions).

fc.assert runs the property with 100 generated cases by default; on failure, fast-check shrinks to the minimal counterexample.

Arbitraries and composite inputs

Pick arbitraries that match the input domain, build domain values with .map() / .chain(), and assemble objects with fc.record. Full catalog and combinator patterns: references/arbitraries.md. Prefer constrained arbitraries (fc.integer({ min: 1 }), fc.emailAddress()) over broad ones filtered down.

Integrate with the test runner

Per fast-check-overview (opens in new window): works "with major testing frameworks including Jest, Vitest, Mocha, Jasmine, AVA, and Tape" without special integration.

// Jest / Vitest example
import { test, expect } from 'vitest';
import fc from 'fast-check';

test('reverse is involutive', () => {
  fc.assert(
    fc.property(fc.array(fc.integer()), (arr) => {
      expect([...arr].reverse().reverse()).toEqual(arr);
    })
  );
});

The assertion library (expect, assert) is the runner's; fast-check hooks into thrown errors as failures. For async properties use await fc.assert(fc.asyncProperty(...)).

Shrinking and reproducibility

On failure fast-check prints the shrunk counterexample plus a seed; replaying that seed reproduces the failure deterministically, and a fixed CI seed keeps runs stable. Output format, replay, and CI seed: references/shrinking-and-reproducibility.md.

Stateful and async testing

For concurrent code use fc.scheduler race-condition detection; for stateful systems use command-sequence model-based testing with fc.commands / fc.modelRun. Both patterns: references/stateful-and-async.md.

How to use

  1. Install fast-check as a dev dependency (npm install fast-check --save-dev).
  2. Identify a property that holds for all inputs (round-trip, invariant, metamorphic) rather than a single example.
  3. Pick arbitraries matching the input domain from references/arbitraries.md; prefer constrained arbitraries over .filter().
  4. Wire fc.assert(fc.property(<arbitraries...>, (...inputs) => <predicate>)); the predicate returns false or throws on violation.
  5. Run under the existing runner (Jest / Vitest / Mocha / ...); for async use await fc.assert(fc.asyncProperty(...)).
  6. On failure, read the shrunk counterexample and replay its seed to reproduce - see references/shrinking-and-reproducibility.md.
  7. Verify: after fixing the cause, re-run the property and confirm it passes all 100 generated cases, and replay the failing seed to confirm the counterexample is gone; if it still fails, the fix is incomplete or the property itself is wrong - correct and re-run.
  8. For stateful or concurrent code, escalate to model-based testing or fc.scheduler - references/stateful-and-async.md.

Worked example

A codec exposes encode(obj) and decode(str) and claims decode(encode(x)) === x for every payload.

  1. Model the input with a composite arbitrary (references/arbitraries.md):
import fc from 'fast-check';
import { encode, decode } from './codec';

const payload = fc.record({
  id: fc.uuid(),
  count: fc.integer({ min: 0 }),
  tags: fc.uniqueArray(fc.string()),
});
  1. Assert the round-trip under Vitest:
test('decode reverses encode', () => {
  fc.assert(
    fc.property(payload, (x) => {
      expect(decode(encode(x))).toEqual(x);
    })
  );
});
  1. encode silently drops empty-string tags, so the run fails and fast-check shrinks to the minimal offending input:
Property failed after 12 tests
{ seed: 42, path: "11:0", endOnFailure: true }
Counterexample: [{"id":"...","count":0,"tags":[""]}]
  1. Replaying { seed: 42, path: "11:0" } reproduces it every time (references/shrinking-and-reproducibility.md). Fix encode to preserve empty tags; the property then passes all 100 cases.

Anti-patterns

Anti-patternWhy it failsFix
Random CI seedProperty fails on CI, passes locally; hard to reproduce.Fixed seed in CI (shrinking-and-reproducibility.md).
Heavy .filter() on broad arbitrariesGeneration slow; many cases discarded.Constrained arbitraries (arbitraries.md).
Asserting on specific values inside the propertyDefeats PBT; that's an example test.Properties assert relationships; examples go elsewhere.
Mocking dependencies inside the propertyMocks don't satisfy properties.Test pure functions; integration tests for the rest.
fc.assert(fc.property(...)) without await for async propsTest passes incorrectly (Promise rejected silently).await fc.assert(fc.asyncProperty(...)).
Generating an fc.string() for an email fieldWastes generation budget; mostly invalid.fc.emailAddress() (arbitraries.md).
One mega-property that asserts 5 thingsWhen it fails, hard to know which thing.One property per logical assertion.

Limitations

  • Shrinking time. Complex arbitraries (deeply nested records) can take 30+ seconds to shrink. Use endOnFailure: true to short-circuit when the unshrunk case is sufficient.
  • Type inference for composite records. TypeScript's inference on fc.record({ ... }) can produce unhelpful types; explicit type annotations help.
  • Race-condition detection has cost. fc.scheduler explores interleavings exhaustively; for many-task tests, runtime grows fast.
  • No formal proof. 100 cases is convention; non-trivial domains may need more.

References

Reference

fast-check arbitraries, combinators, and composite inputs

View source (opens in a new tab) (opens in new window)

How to build the generators passed to fc.property(<arbitraries...>, predicate).

Arbitraries catalog

Per fast-check-overview (opens in new window):

ArbitraryGenerates
fc.string()Strings
fc.integer()Integers
fc.float() / fc.double()Floats
fc.boolean()Booleans
fc.array(item)Arrays of item
fc.tuple(a, b, ...)Fixed-length tuples
fc.record({ key: ... })Objects with specified properties
fc.option(item)item or null
fc.constantFrom(...)One of fixed values
fc.oneof(a, b, ...)One of multiple arbitraries
fc.uuid() / fc.ipV4() / fc.emailAddress() / fc.webUrl()Format-specific
fc.date()Dates
fc.uniqueArray(item)Arrays without duplicates
fc.dictionary(key, value)Map / Record types

Combinators (.map / .chain / .filter)

Per fast-check-overview (opens in new window): "Extensible via map() and chain() combinators."

// .map: transform a generated value
const evenInteger = fc.integer().map(n => n * 2);

// .chain: dependent generation (later value depends on earlier)
const stringWithKnownLength = fc.integer({ min: 1, max: 100 })
  .chain(len => fc.string({ minLength: len, maxLength: len }));

// .filter: reject (use sparingly - slow when filter rejects most)
const positiveInteger = fc.integer().filter(n => n > 0);
// Better:
const positiveInteger = fc.integer({ min: 1 });

.filter() discards rejections; .map() transforms. Prefer .map() and constrained arbitraries over .filter() when possible.

Composite arbitraries via fc.record

const user = fc.record({
  id: fc.uuid(),
  email: fc.emailAddress(),
  age: fc.integer({ min: 18, max: 100 }),
  tags: fc.uniqueArray(fc.constantFrom('admin', 'beta', 'churn-risk')),
  createdAt: fc.date({ min: new Date('2020-01-01'), max: new Date() }),
});

it('serializes user to JSON and back', () => {
  fc.assert(
    fc.property(user, (u) => {
      expect(JSON.parse(JSON.stringify(u))).toEqual({
        ...u,
        createdAt: u.createdAt.toISOString(),
      });
    })
  );
});

fc.record produces objects with the specified shape; each field is sampled per its arbitrary.

Reference

fast-check shrinking and reproducibility

View source (opens in a new tab) (opens in new window)

When a property fails, fast-check prints the falsifying input + a shrunk minimal version + a seed:

Property failed after 47 tests
{ seed: 1234567890, path: "12:1:0", endOnFailure: true }
Counterexample: [{"id": "abc", "age": -1}]
Shrunk 8 time(s)
Got error: Expected age to be >= 18, got -1

To reproduce, replay with the seed:

fc.assert(
  fc.property(...),
  { seed: 1234567890, path: "12:1:0", endOnFailure: true }
);

The seed/path is the deterministic recipe to re-derive the failure.

For CI, set a fixed seed:

import fc from 'fast-check';
fc.configureGlobal({ seed: process.env.CI ? 42 : Date.now() });
Reference

fast-check stateful and async testing

View source (opens in a new tab) (opens in new window)

Advanced fast-check for concurrent and stateful systems.

Race condition detection

Per fast-check-overview (opens in new window): "Race condition detection for async code."

import { test } from 'vitest';
import fc from 'fast-check';

test('concurrent counter increments are atomic', async () => {
  await fc.assert(
    fc.asyncProperty(fc.scheduler(), async (s) => {
      const counter = new AsyncCounter();
      const tasks = [
        s.schedule(counter.increment()),
        s.schedule(counter.increment()),
        s.schedule(counter.increment()),
      ];
      await s.waitAll();
      await Promise.all(tasks);
      expect(counter.value).toBe(3);
    })
  );
});

fc.scheduler exhaustively explores task interleavings; s.schedule queues an async operation; s.waitAll() advances. fast-check finds interleavings that cause the property to fail - the canonical race-condition catcher.

Model-based testing

Per fast-check-overview (opens in new window): "Model-based testing for stateful systems."

class CounterModel {
  count = 0;
  increment() { this.count++; }
  decrement() { this.count--; }
}

const allCommands = [
  fc.constant({ run: (c, real) => { c.increment(); real.increment(); expect(real.value).toBe(c.count); } }),
  fc.constant({ run: (c, real) => { c.decrement(); real.decrement(); expect(real.value).toBe(c.count); } }),
];

it('counter behaves per model', () => {
  fc.assert(
    fc.property(fc.commands(allCommands), (cmds) => {
      const model = new CounterModel();
      const real = new RealCounter();
      fc.modelRun(() => ({ model, real }), cmds);
    })
  );
});

fast-check generates random sequences of commands; the model stays in sync with the real implementation; any divergence is a bug in the real implementation.

  • Async memo: properties over generated keys
  • CSV escaping is tested with the six strings someone thought of
  • Email normalisation needs generated addresses