Property-Based Testing with fast-check in JavaScript

Property-Based Testing with fast-check in JavaScript

Property-based testing is one of those techniques that sounds academic until you actually use it — and then you wonder how you ever shipped code without it. Instead of writing individual examples ("given input X, expect output Y"), you describe the properties your code must satisfy for any valid input, and let the framework generate hundreds or thousands of test cases automatically.

In the JavaScript ecosystem, fast-check is the gold standard for property-based testing. It's actively maintained, has excellent TypeScript support, integrates cleanly with Jest and Vitest, and produces readable output when it finds a bug. This guide walks you through everything you need to get productive with fast-check.

What Is fast-check?

fast-check is a property-based testing library for JavaScript and TypeScript. It was inspired by Haskell's QuickCheck and Scala's ScalaCheck, but built specifically for the JavaScript ecosystem.

The core idea: you define a property — a statement that should be true for all valid inputs — and fast-check generates random inputs, runs your code against each one, and reports any input that violates the property. If it finds a failure, it shrinks the input to the smallest possible example that still fails, making debugging much easier.

Installation

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

fast-check works with Jest, Vitest, Mocha, and any other JavaScript test runner. You import it alongside your existing test framework.

Your First Property Test

Let's start with a classic example: a function that reverses an array. We can express several properties of array reversal:

  1. Reversing twice gives back the original array
  2. The reversed array has the same length
  3. The first element of the original is the last element of the reversed array
import fc from 'fast-check';

// The function we're testing
function reverse(arr) {
  return [...arr].reverse();
}

// Example-based test (traditional)
test('reverse [1,2,3] gives [3,2,1]', () => {
  expect(reverse([1, 2, 3])).toEqual([3, 2, 1]);
});

// Property-based tests
test('reverse twice returns original array', () => {
  fc.assert(
    fc.property(fc.array(fc.integer()), (arr) => {
      expect(reverse(reverse(arr))).toEqual(arr);
    })
  );
});

test('reverse preserves length', () => {
  fc.assert(
    fc.property(fc.array(fc.integer()), (arr) => {
      expect(reverse(arr).length).toBe(arr.length);
    })
  );
});

test('first element becomes last after reverse', () => {
  fc.assert(
    fc.property(fc.array(fc.integer(), { minLength: 1 }), (arr) => {
      const reversed = reverse(arr);
      expect(reversed[reversed.length - 1]).toBe(arr[0]);
    })
  );
});

With three property tests, fast-check will run each property hundreds of times against randomly generated arrays of varying lengths and values. This covers far more ground than any manually written example suite.

Understanding Arbitraries

Arbitraries are fast-check's generators — they describe how to generate random values of a particular type. fast-check ships with a rich standard library of arbitraries.

Primitive Arbitraries

fc.integer()              // any integer
fc.integer({ min: 0, max: 100 })  // integer in range
fc.float()                // any float
fc.boolean()              // true or false
fc.string()               // any string
fc.string({ minLength: 1, maxLength: 20 })  // constrained string
fc.char()                 // single character
fc.emailAddress()         // valid-looking email
fc.uuid()                 // UUID v4
fc.date()                 // Date object
fc.bigInt()               // BigInt

Collection Arbitraries

fc.array(fc.integer())                    // array of integers
fc.array(fc.string(), { minLength: 1 })   // non-empty array of strings
fc.set(fc.integer())                      // Set of integers
fc.tuple(fc.integer(), fc.string())       // fixed-length tuple [number, string]
fc.record({ name: fc.string(), age: fc.integer({ min: 0, max: 120 }) })  // object with shape
fc.dictionary(fc.string(), fc.integer())  // Record<string, number>

Combinator Arbitraries

fc.oneof(fc.integer(), fc.string())       // either an integer or a string
fc.option(fc.integer())                   // integer or null/undefined
fc.constant('hello')                      // always 'hello'
fc.constantFrom('a', 'b', 'c')            // one of these values
fc.mapToConstant(...)                     // map to specific constants

Custom Arbitraries with .map() and .chain()

You can transform any arbitrary into a custom one:

// Generate sorted arrays
const sortedArray = fc.array(fc.integer()).map(arr => arr.sort((a, b) => a - b));

// Generate valid date ranges
const dateRange = fc.date().chain(start => {
  return fc.date({ min: start }).map(end => ({ start, end }));
});

// Generate valid user objects
const userArbitrary = fc.record({
  id: fc.uuid(),
  name: fc.string({ minLength: 1, maxLength: 50 }),
  email: fc.emailAddress(),
  age: fc.integer({ min: 18, max: 120 }),
  role: fc.constantFrom('admin', 'user', 'moderator'),
});

Writing Meaningful Properties

The hardest part of property-based testing is identifying good properties. Here are common patterns:

Roundtrip Properties

If you encode and decode, you should get back what you started with:

test('JSON serialization roundtrip', () => {
  fc.assert(
    fc.property(
      fc.record({
        name: fc.string(),
        value: fc.integer(),
        active: fc.boolean(),
      }),
      (obj) => {
        expect(JSON.parse(JSON.stringify(obj))).toEqual(obj);
      }
    )
  );
});

Invariant Properties

Some facts should always be true regardless of input:

test('sorted array is always non-decreasing', () => {
  fc.assert(
    fc.property(fc.array(fc.integer()), (arr) => {
      const sorted = arr.sort((a, b) => a - b);
      for (let i = 0; i < sorted.length - 1; i++) {
        expect(sorted[i]).toBeLessThanOrEqual(sorted[i + 1]);
      }
    })
  );
});

Oracle Properties (Comparing Implementations)

If you have a simple but slow implementation and a fast but complex one, they should agree:

function slowFibonacci(n) {
  if (n <= 1) return n;
  return slowFibonacci(n - 1) + slowFibonacci(n - 2);
}

function fastFibonacci(n) {
  // optimized iterative implementation
  let a = 0, b = 1;
  for (let i = 0; i < n; i++) {
    [a, b] = [b, a + b];
  }
  return a;
}

test('fast fibonacci matches slow fibonacci', () => {
  fc.assert(
    fc.property(fc.integer({ min: 0, max: 20 }), (n) => {
      expect(fastFibonacci(n)).toBe(slowFibonacci(n));
    })
  );
});

Metamorphic Properties

If you apply a transformation, the relationship between inputs and outputs should be consistent:

test('adding an element increases length by 1', () => {
  fc.assert(
    fc.property(fc.array(fc.integer()), fc.integer(), (arr, elem) => {
      const newArr = [...arr, elem];
      expect(newArr.length).toBe(arr.length + 1);
    })
  );
});

Understanding Shrinking

When fast-check finds a failing input, it doesn't just report the raw random input it generated — it shrinks it. Shrinking means fast-check tries progressively simpler versions of the failing input until it finds the simplest one that still triggers the bug.

For example, if your function fails on the array [47, -3, 102, 8, -71, 0, 55], shrinking might reduce it to just [0, -1] — the minimal array that still exposes the bug. This is enormously helpful for debugging.

fast-check's built-in arbitraries all support shrinking automatically. When you use .map() or .filter() to create custom arbitraries, shrinking still works as long as the transformation is straightforward.

// This arbitrary will shrink properly
const positiveEven = fc.integer({ min: 2 }).map(n => n * 2);

// Filtering reduces the shrink space but still works
const nonZero = fc.integer().filter(n => n !== 0);

Integration with Jest

fast-check works out of the box with Jest. The fc.assert() function throws when a property fails, which Jest picks up as a test failure:

import fc from 'fast-check';
import { myFunction } from './myModule';

describe('myFunction properties', () => {
  it('never throws for valid inputs', () => {
    fc.assert(
      fc.property(fc.string(), fc.integer({ min: 0 }), (str, num) => {
        expect(() => myFunction(str, num)).not.toThrow();
      })
    );
  });
});

You can configure the number of runs and other options:

fc.assert(
  fc.property(fc.integer(), (n) => {
    expect(n + n).toBe(n * 2);
  }),
  {
    numRuns: 1000,      // run 1000 random cases (default: 100)
    seed: 42,           // fix the seed for reproducibility
    verbose: true,      // show all generated values
  }
);

Integration with Vitest

Vitest works identically — just import fast-check and use it inside test() or it() blocks:

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

test('property holds for all inputs', () => {
  fc.assert(
    fc.property(fc.string(), (s) => {
      expect(s.trim().length).toBeLessThanOrEqual(s.length);
    })
  );
});

Replaying Failures

When fast-check finds a bug, it prints the seed that was used to generate the failing case. You can replay the exact same sequence of random inputs by passing that seed back:

// fast-check output on failure:
// Property failed after 47 tests
// { seed: 1234567890, path: "47:2:1", endOnFailure: true }
// Counterexample: [-3, 0]

// Replay with the exact same seed:
fc.assert(
  fc.property(fc.array(fc.integer()), (arr) => {
    // your property
  }),
  { seed: 1234567890, path: '47:2:1', endOnFailure: true }
);

This makes CI failures fully reproducible — paste the seed from the CI log into your local test and you'll reproduce the exact failure.

Real-World Example: Testing a Shopping Cart

Let's test a shopping cart implementation with properties:

import fc from 'fast-check';
import { Cart } from './cart';

const itemArbitrary = fc.record({
  id: fc.uuid(),
  name: fc.string({ minLength: 1 }),
  price: fc.float({ min: 0.01, max: 9999.99, noNaN: true }),
  quantity: fc.integer({ min: 1, max: 100 }),
});

test('cart total equals sum of item totals', () => {
  fc.assert(
    fc.property(fc.array(itemArbitrary, { minLength: 1 }), (items) => {
      const cart = new Cart();
      items.forEach(item => cart.addItem(item));

      const expectedTotal = items.reduce(
        (sum, item) => sum + item.price * item.quantity,
        0
      );
      expect(cart.total()).toBeCloseTo(expectedTotal, 2);
    })
  );
});

test('removing all items empties the cart', () => {
  fc.assert(
    fc.property(fc.array(itemArbitrary, { minLength: 1 }), (items) => {
      const cart = new Cart();
      items.forEach(item => cart.addItem(item));
      items.forEach(item => cart.removeItem(item.id));
      expect(cart.total()).toBe(0);
      expect(cart.itemCount()).toBe(0);
    })
  );
});

test('applying discount never increases total', () => {
  fc.assert(
    fc.property(
      fc.array(itemArbitrary, { minLength: 1 }),
      fc.float({ min: 0, max: 1, noNaN: true }),
      (items, discountRate) => {
        const cart = new Cart();
        items.forEach(item => cart.addItem(item));
        const originalTotal = cart.total();
        cart.applyDiscount(discountRate);
        expect(cart.total()).toBeLessThanOrEqual(originalTotal);
      }
    )
  );
});

Combining Property-Based and Example-Based Tests

Property-based testing doesn't replace example-based tests — it complements them. Use example-based tests to document specific known behaviors and edge cases you've already thought of. Use property-based tests to catch the cases you haven't thought of.

A mature test suite uses both: a handful of carefully chosen examples plus a set of properties that cover the space between them.

How HelpMeTest Supports Property-Based Testing

If you're running property-based tests as part of a larger test suite, HelpMeTest can help you manage and monitor them at scale. HelpMeTest's AI-powered test generation can identify properties worth testing in your codebase, while the Robot Framework and Playwright integration lets you run property tests as part of your end-to-end suite. With usage-based pricing at $0.003/run, it's a practical addition to any team's testing infrastructure.

Conclusion

fast-check brings the power of property-based testing to JavaScript without requiring any changes to your existing test setup. Start by identifying one or two properties for your most critical functions — roundtrip tests and invariant tests are good starting points — and run fast-check alongside your existing Jest or Vitest suite.

The investment pays off quickly: fast-check will find bugs in corner cases that you'd never think to test manually, and the shrinking output makes those bugs easy to diagnose. Once you get comfortable writing properties, you'll find yourself thinking differently about your code — describing what it should always do rather than what it does for specific inputs.

Read more

Start now free