fast-check (JavaScript): Advanced Property-Based Testing Deep Dive

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 asyncProperty for 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 vitest

Or 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 miss

For 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 ASTs
  • fc.commands() enables model-based testing for stateful systems—generates and shrinks operation sequences
  • fc.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

Read more

Start now free