fast-check: Property-Based Testing for JavaScript and TypeScript

fast-check: Property-Based Testing for JavaScript and TypeScript

fast-check is the leading property-based testing library for JavaScript and TypeScript. It generates test inputs automatically, shrinks failures, and integrates with Jest, Vitest, and Mocha with zero configuration changes. If you write JavaScript and want to find bugs that hand-written tests miss, fast-check is the tool.

Installation

npm install --save-dev fast-check

Works with Jest, Vitest, Mocha, and any other test runner — fast-check doesn't depend on or replace your runner.

Basic Usage

import fc from 'fast-check';

test('string concatenation length', () => {
  fc.assert(
    fc.property(fc.string(), fc.string(), (a, b) => {
      return (a + b).length === a.length + b.length;
    })
  );
});

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

fc.assert runs the property with 100 randomly generated examples by default. If any fail, it shrinks the input to the smallest failing case and throws with a clear error.

Core Arbitraries

Arbitraries describe the space of generated values:

// Primitives
fc.integer()                    // any integer
fc.integer({ min: 0, max: 100 }) // bounded
fc.float()                      // any float
fc.float({ min: 0, max: 1, noNaN: true })
fc.boolean()
fc.string()
fc.string({ minLength: 1, maxLength: 50 })
fc.constant('fixed value')      // always returns this

// Collections
fc.array(fc.integer())
fc.array(fc.integer(), { minLength: 1, maxLength: 10 })
fc.set(fc.integer())            // unique values
fc.dictionary(fc.string(), fc.integer())
fc.tuple(fc.integer(), fc.string())  // fixed-length, typed

// Specialized
fc.uuid()
fc.emailAddress()
fc.webUrl()
fc.ipV4()
fc.date()
fc.fullUnicodeString()
fc.base64String()

// Union types
fc.oneof(fc.integer(), fc.string())
fc.option(fc.integer())  // integer | null

TypeScript-First Design

fast-check is written in TypeScript and provides full type inference:

// Types flow through automatically
const userArbitrary = fc.record({
  id: fc.uuid(),
  name: fc.string({ minLength: 1, maxLength: 100 }),
  age: fc.integer({ min: 0, max: 150 }),
  email: fc.emailAddress(),
  active: fc.boolean(),
});

// TypeScript knows the type: { id: string, name: string, age: number, ... }
fc.assert(
  fc.property(userArbitrary, (user) => {
    const serialized = JSON.stringify(user);
    const deserialized = JSON.parse(serialized);
    expect(deserialized.id).toBe(user.id);
    expect(deserialized.name).toBe(user.name);
  })
);

Composing Arbitraries

fc.record

Build object arbitraries with typed fields:

const orderItemArbitrary = fc.record({
  productId: fc.string({ minLength: 1 }),
  quantity: fc.integer({ min: 1, max: 99 }),
  unitPrice: fc.float({ min: 0.01, max: 9999.99, noNaN: true }),
});

fc.chain (dependent arbitraries)

Generate values that depend on previously generated values:

// Generate a list and a valid index
const listAndIndex = fc.array(fc.integer(), { minLength: 1 }).chain(
  (arr) => fc.tuple(fc.constant(arr), fc.integer({ min: 0, max: arr.length - 1 }))
);

fc.assert(
  fc.property(listAndIndex, ([arr, idx]) => {
    expect(arr[idx]).toBeDefined();  // index is always valid
  })
);

fc.filter

Constrain the generated space:

const positiveEven = fc.integer({ min: 1 }).filter((n) => n % 2 === 0);

// Prefer bounded arbitraries over heavy filtering
const positiveEven2 = fc.integer({ min: 1 }).map((n) => n * 2);  // faster

fc.map

Transform generated values:

const sortedArrays = fc.array(fc.integer()).map((arr) => [...arr].sort((a, b) => a - b));

const isoDateStrings = fc.date().map((d) => d.toISOString());

Properties and Assertions

Three ways to express properties:

// Return boolean (classic style)
fc.assert(
  fc.property(fc.integer(), (n) => {
    return n * 2 === n + n;  // true = pass, false/throw = fail
  })
);

// Use expect (Jest/Vitest style)
fc.assert(
  fc.property(fc.string(), (s) => {
    expect(s.toLowerCase().toUpperCase()).toBe(s.toUpperCase());
  })
);

// Throw on failure
fc.assert(
  fc.property(fc.array(fc.integer(), { minLength: 1 }), (arr) => {
    const max = Math.max(...arr);
    if (!arr.includes(max)) throw new Error('Max not in array');
  })
);

Async Properties

test('API round trip', async () => {
  await fc.assert(
    fc.asyncProperty(
      fc.record({
        title: fc.string({ minLength: 1, maxLength: 200 }),
        body: fc.string({ maxLength: 5000 }),
      }),
      async (post) => {
        const created = await api.createPost(post);
        const retrieved = await api.getPost(created.id);
        expect(retrieved.title).toBe(post.title);
        expect(retrieved.body).toBe(post.body);
      }
    ),
    { numRuns: 20 } // fewer runs for integration tests
  );
});

Model-Based Testing

Test stateful systems by modeling expected behavior alongside the real implementation:

import fc from 'fast-check';

// Model (simple reference implementation)
class CartModel {
  items: Map<string, number> = new Map();
  
  addItem(id: string, qty: number) {
    this.items.set(id, (this.items.get(id) ?? 0) + qty);
  }
  
  removeItem(id: string) {
    this.items.delete(id);
  }
  
  total(prices: Map<string, number>) {
    let sum = 0;
    for (const [id, qty] of this.items) {
      sum += (prices.get(id) ?? 0) * qty;
    }
    return sum;
  }
}

// Commands
class AddItemCommand implements fc.Command<CartModel, Cart> {
  constructor(readonly id: string, readonly qty: number) {}
  
  check() { return this.qty > 0; }
  
  run(model: CartModel, real: Cart) {
    model.addItem(this.id, this.qty);
    real.addItem(this.id, this.qty);
    expect(real.itemCount()).toBe(model.items.size);
  }
  
  toString() { return `AddItem(${this.id}, ${this.qty})`; }
}

test('cart model matches implementation', () => {
  const idArb = fc.string({ minLength: 1, maxLength: 10 });
  const qtyArb = fc.integer({ min: 1, max: 10 });
  
  const commandsArb = fc.array(
    fc.oneof(
      fc.record({ id: idArb, qty: qtyArb }).map(({ id, qty }) => new AddItemCommand(id, qty)),
    )
  );

  fc.assert(
    fc.property(commandsArb, (commands) => {
      const model = new CartModel();
      const real = new Cart();
      fc.modelRun(() => ({ model, real }), commands);
    })
  );
});

Shrinking in Action

When fast-check finds a failure with a large input, it automatically reduces it:

test('no integer overflow', () => {
  fc.assert(
    fc.property(fc.integer(), fc.integer(), (a, b) => {
      return safeAdd(a, b) === a + b;  // will fail for large numbers
    })
  );
});

// Output when it fails:
// Property failed after 1 tests
// { seed: 12345, path: "0", endOnFailure: true }
// Counterexample: [2147483647, 1]  ← shrunk to minimal case
// Shrunk 18 time(s)

Instead of a random large number pair, you get the exact boundary that breaks the code.

Configuration

// Per-test configuration
fc.assert(
  fc.property(fc.string(), (s) => { ... }),
  {
    numRuns: 1000,       // generate 1000 examples (default: 100)
    seed: 42,            // fixed seed for reproducibility
    verbose: true,       // show all generated examples
    endOnFailure: false, // continue after first failure
  }
);

// Global configuration
fc.configureGlobal({
  numRuns: 50,  // CI: fewer runs
});

Reproducing Failures

When a test fails, fast-check prints a seed and path:

Property failed after 1 tests
{ seed: -123456789, path: "0", endOnFailure: true }
Counterexample: ["hello\uD800world"]

Reproduce it exactly:

fc.assert(
  fc.property(fc.string(), (s) => { ... }),
  { seed: -123456789, path: "0" }
);

Jest/Vitest Integration

No special setup needed. Use fc.assert inside test() or it():

// Jest
describe('math properties', () => {
  test('addition is commutative', () => {
    fc.assert(
      fc.property(fc.integer(), fc.integer(), (a, b) => {
        expect(a + b).toBe(b + a);
      })
    );
  });
});

Real-World Example: Form Validation

const validFormInputs = fc.record({
  username: fc.string({ minLength: 3, maxLength: 20 }).filter(/^[a-zA-Z0-9_]+$/.test.bind(/^[a-zA-Z0-9_]+$/)),
  email: fc.emailAddress(),
  password: fc.string({ minLength: 8, maxLength: 128 }),
  age: fc.integer({ min: 13, max: 120 }),
});

const invalidEmails = fc.string().filter((s) => !s.includes('@'));

test('valid inputs always pass validation', () => {
  fc.assert(
    fc.property(validFormInputs, (form) => {
      const result = validateForm(form);
      expect(result.valid).toBe(true);
      expect(result.errors).toHaveLength(0);
    })
  );
});

test('invalid email always fails validation', () => {
  fc.assert(
    fc.property(invalidEmails, (email) => {
      const result = validateForm({ ...validBase, email });
      expect(result.valid).toBe(false);
      expect(result.errors).toContain('Invalid email');
    })
  );
});

Pair with Functional Testing

fast-check finds bugs in your JavaScript/TypeScript logic by exploring input space. For continuous end-to-end monitoring of your production application — verifying real user flows work correctly 24/7 — HelpMeTest provides AI-powered functional test automation without requiring code.

Start free with HelpMeTest — 10 tests, no code required, monitoring every 5 minutes.

Read more