Stop Writing Unit Tests That Miss Bugs: Property-Based Testing with fast-check

You write a unit test. You pick an input you can reason about — add(2, 3) returns 5, slugify("Hello World") returns "hello-world". Green. Ship it.

Six months later, a user pastes a string with a zero-width space into the same slugify() function. Production breaks. Nobody wrote a test for that because nobody thought of it.

That’s the core problem with example-based testing: you can only test what you imagine. Property-based testing flips the model — instead of you inventing inputs, the framework generates thousands of them, including the freaks you’d never dream up. If something breaks, it finds the smallest possible input that reproduces it.

fast-check (GitHub) is the best implementation of this idea for the JS/TS ecosystem. It’s actively maintained, has first-class TypeScript support, integrates with every test runner you already use, and has a shrinking engine that’s genuinely impressive.

This article takes you from zero to writing real property-based tests — with patterns you can copy into a production codebase today.


What "Property" Actually Means

A property is a statement that should be true for all valid inputs, not just one specific example.

Example-based:

slugify("Hello World") === "hello-world"

Property-based:

for any string s, slugify(s) should:
  - only contain lowercase letters, digits, and hyphens
  - never start or end with a hyphen
  - never contain consecutive hyphens

These rules hold for every input, not just the ones on your happy path. Write them down as code, hand fast-check a generator, and let it hammer your function with random data until it finds a violation or it’s satisfied after N runs (default: 100).


Installation

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

Works with Jest, Vitest, Mocha, and Node’s built-in test runner out of the box. No plugins, no config.


First Test in 60 Seconds

Let’s say you wrote a function that reverses an array:

// src/utils.ts
export function reverseArray<T>(arr: T[]): T[] {
  return [...arr].reverse();
}

The property you care about: reversing twice gets you back to the original.

// src/utils.test.ts
import fc from "fast-check";
import { reverseArray } from "./utils";

test("reverse twice is identity", () => {
  fc.assert(
    fc.property(
      fc.array(fc.integer()),
      (arr) => {
        expect(reverseArray(reverseArray(arr))).toEqual(arr);
      }
    )
  );
});

fc.assert runs the property. fc.property defines the shape of the input and the assertion function. fc.array(fc.integer()) is an arbitrary — fast-check’s name for a generator.

Run it — fast-check will throw 100 random arrays at your function. If it passes, you have a lot more confidence than a single [1, 2, 3] example gives you.


Arbitraries: The Building Blocks

The power comes from composing arbitraries. fast-check ships with primitives for everything you need:

fc.integer()              // any safe integer
fc.float()                // any float
fc.string()               // any unicode string (includes null bytes, surrogates, emoji)
fc.asciiString()          // ASCII only
fc.boolean()
fc.date()
fc.bigInt()
fc.uuid()
fc.emailAddress()
fc.ipV4()
fc.ipV6()
fc.url()
fc.array(arb)             // array of any length
fc.array(arb, { minLength: 1, maxLength: 10 })
fc.tuple(fc.string(), fc.integer())  // fixed-shape tuple
fc.record({ name: fc.string(), age: fc.integer({ min: 0, max: 120 }) })
fc.constantFrom("GET", "POST", "PUT", "DELETE")  // enum-like
fc.oneof(fc.string(), fc.integer())  // union types
fc.option(fc.string())   // string | null

For TypeScript, fc.record() is your best friend for generating typed objects:

interface User {
  id: string;
  email: string;
  age: number;
}

const userArb: fc.Arbitrary<User> = fc.record({
  id: fc.uuid(),
  email: fc.emailAddress(),
  age: fc.integer({ min: 0, max: 120 }),
});

Shrinking: Why fast-check Finds You the Minimal Failure

When fast-check finds a failing input, it doesn’t just report "failed on some 1200-character string". It shrinks — tries progressively smaller versions of the failing input until it finds the minimal case that still fails.

This is what separates fast-check from naive fuzz testing. You get a bug report that’s actionable, not a wall of random garbage.

Watch this in action. Suppose you have a buggy divide function that crashes when both numbers are equal:

function divide(a: number, b: number): number {
  if (a === b) throw new Error("nope"); // intentional bug
  return a / b;
}

test("divide never throws for non-zero b", () => {
  fc.assert(
    fc.property(
      fc.integer(),
      fc.integer({ min: 1 }),
      (a, b) => {
        divide(a, b); // just shouldn't throw
      }
    )
  );
});

fast-check output:

Property failed after 3 tests
{ seed: 1823761234, path: "2:0:0", endOnFailure: true }
Counterexample: [1, 1]

It found [1, 1] — the absolute minimum failing case. Not [17364, 17364] or whatever random values triggered the bug first.


Real-World Pattern 1: Round-Trip Testing

Any time you have encoding/decoding, serialization/deserialization, or parse/stringify pairs, property-based testing should be your first instinct.

import fc from "fast-check";
import { serialize, deserialize } from "./codec";

test("serialize → deserialize is identity", () => {
  fc.assert(
    fc.property(
      fc.record({
        id: fc.uuid(),
        name: fc.unicodeString({ minLength: 1 }),
        tags: fc.array(fc.asciiString()),
        createdAt: fc.date(),
      }),
      (original) => {
        const roundTripped = deserialize(serialize(original));
        expect(roundTripped).toEqual(original);
      }
    )
  );
});

This catches: unescaped characters, date precision loss, empty-array vs null confusion, Unicode normalization bugs. You won’t think to write examples for all of these. fast-check will find them.


Real-World Pattern 2: Invariants on Data Structures

If you build or transform a data structure, you can assert structural invariants:

import { buildSortedIndex } from "./index";

test("index maintains sorted order after insertions", () => {
  fc.assert(
    fc.property(
      fc.array(fc.string(), { minLength: 1 }),
      (keys) => {
        const index = buildSortedIndex(keys);
        const entries = index.toArray();

        // Every consecutive pair must be in order
        for (let i = 0; i < entries.length - 1; i++) {
          expect(entries[i] <= entries[i + 1]).toBe(true);
        }
      }
    )
  );
});

Real-World Pattern 3: Model-Based Testing

This one is advanced and powerful. You define a simple, obviously-correct "model" of your system, then assert that the real implementation behaves identically.

Say you have a custom LRUCache class. Your model is just a Map with eviction logic. You generate random sequences of get and set commands and verify both behave the same:

import fc from "fast-check";
import { LRUCache } from "./lru-cache";

// Model: a trivial Map (no LRU logic — just tracks state for comparison)
class NaiveCache {
  private store = new Map<string, number>();
  constructor(private capacity: number) {}

  set(key: string, value: number) {
    if (this.store.size >= this.capacity && !this.store.has(key)) {
      // evict the "oldest" — first inserted
      this.store.delete(this.store.keys().next().value);
    }
    this.store.set(key, value);
  }

  get(key: string): number | undefined {
    return this.store.get(key);
  }
}

const CacheCommand = fc.oneof(
  fc.record({ type: fc.constant("set"), key: fc.string({ maxLength: 5 }), value: fc.integer() }),
  fc.record({ type: fc.constant("get"), key: fc.string({ maxLength: 5 }), value: fc.constant(0) })
);

test("LRUCache matches naive model behavior", () => {
  fc.assert(
    fc.property(
      fc.array(CacheCommand, { maxLength: 30 }),
      (commands) => {
        const real = new LRUCache<string, number>(5);
        const model = new NaiveCache(5);

        for (const cmd of commands) {
          if (cmd.type === "set") {
            real.set(cmd.key, cmd.value);
            model.set(cmd.key, cmd.value);
          } else {
            expect(real.get(cmd.key)).toEqual(model.get(cmd.key));
          }
        }
      }
    )
  );
});

This pattern exposes race conditions, eviction order bugs, and edge cases in complex stateful classes that you’d never manually enumerate.


TypeScript Generics and Custom Arbitraries

For reuse, wrap your arbitraries in factory functions:

// test/arbitraries.ts
import fc from "fast-check";

export interface Paginated<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
}

export function paginatedArb<T>(itemArb: fc.Arbitrary<T>): fc.Arbitrary<Paginated<T>> {
  return fc.record({
    pageSize: fc.integer({ min: 1, max: 100 }),
    page: fc.integer({ min: 1, max: 1000 }),
    total: fc.integer({ min: 0, max: 100000 }),
    items: fc.array(itemArb, { maxLength: 100 }),
  });
}

Then in tests:

import { paginatedArb } from "../test/arbitraries";

test("pagination serializer handles any page shape", () => {
  fc.assert(
    fc.property(
      paginatedArb(fc.record({ id: fc.uuid(), title: fc.string() })),
      (page) => {
        const json = serializePaginated(page);
        expect(deserializePaginated(json)).toEqual(page);
      }
    )
  );
});

Configuration You Should Know

Seed for reproducibility — when a test fails in CI, fast-check prints the seed. Replay the exact same sequence:

fc.assert(property, { seed: 1823761234 });

Number of runs — default is 100. Crank it up for critical paths, lower it for slow operations:

fc.assert(property, { numRuns: 1000 });

Verbose mode — see every generated input, not just the failure:

fc.assert(property, { verbose: true });

Timeout — useful for async properties:

fc.assert(
  fc.asyncProperty(fc.string(), async (s) => {
    await someAsyncFn(s);
  }),
  { timeout: 2000 }
);

Gotchas

Gotcha 1: Impure functions break everything.

fast-check generates inputs. If your function has side effects (writes to a DB, mutates global state), two things happen: tests are slow, and failures become non-reproducible because the side effects of earlier runs affect later ones. Isolate side effects. Test pure logic with fast-check, use integration tests for the I/O layer.

Gotcha 2: fc.string() is brutal.

fc.string() generates full Unicode — null bytes, RTL marks, zero-width joiners, surrogates. Your regex will break. Your length comparisons will break (because .length counts code units, not code points). That’s the point — but if you know your function only handles ASCII, use fc.asciiString() or fc.stringMatching(/^[a-z0-9]+$/).

Gotcha 3: Floating-point arithmetic is a trap.

fc.float() generates NaN, Infinity, -0, and denormalized numbers. If your property uses === equality on floats, it will fail on -0 === 0 (which is true in JS, but Object.is(-0, 0) is false). Either use Number.isFinite() guards or constrain the arbitrary: fc.float({ noNaN: true, noDefaultInfinity: true }).

Gotcha 4: Slow properties are a CI problem.

If each run of your property takes 50ms and you have 100 runs, that’s 5 seconds per test. Multiply by 20 properties and you’ve added 100 seconds to your suite. Set numRuns intentionally. 100 is the default but not always the right number.

Gotcha 5: Seed ≠ determinism across versions.

fast-check may change its generation algorithm between major versions. A failing seed from version 2 may not reproduce in version 3. Keep seeds in comments alongside the version they were captured on, or reproduce by pinning the package version in your lock file.


Production-Ready Patterns

Pattern: Regression file for known failures.

When fast-check finds a bug and you fix it, save the counterexample as an explicit example-based test in addition to the property test. This prevents regression and documents the edge case:

// Regression: fast-check seed 98273, v3.15 found empty string breaks slugify
test("slugify handles empty string", () => {
  expect(slugify("")).toBe("");
});

Pattern: Separate slow property suites.

Keep 1000-run property tests in a properties/ folder and run them in a separate CI step or nightly. Your main test script stays fast.

// package.json
{
  "scripts": {
    "test": "vitest run",
    "test:properties": "vitest run --dir properties --reporter verbose"
  }
}

Pattern: Use fc.scheduler() for async concurrency bugs.

This is fast-check’s killer feature for async code. It controls the order in which promises resolve, letting it find race conditions:

test("concurrent updates don't corrupt state", async () => {
  await fc.assert(
    fc.asyncProperty(fc.scheduler(), async (scheduler) => {
      const store = new ConcurrentStore();

      // Schedule concurrent operations
      scheduler.schedule(Promise.resolve(store.increment()));
      scheduler.schedule(Promise.resolve(store.increment()));

      await scheduler.waitAll();

      expect(store.value).toBe(2);
    })
  );
});

The scheduler will try every possible interleaving. Mutex bugs that pass under normal timing will fail here.


When Not to Use Property-Based Testing

It’s not a silver bullet. Don’t reach for it when:

  • The function has a small, finite input space. Testing all combinations explicitly is cleaner.
  • The logic is inherently about specific business examples (vatRate("DE") === 0.19). These are specifications, not properties.
  • The property you’d write is basically a restatement of the implementation. If the property and the code are structurally identical, you’re not testing anything.
  • You need a fast, readable test that communicates intent. A well-named example test often communicates more than a property.

Use both. Property tests find what you can’t imagine. Example tests document what you intended.


Putting It Together

Here’s a realistic test file for a URL slug utility, using both styles together:

import fc from "fast-check";
import { slugify } from "./slugify";

// Example tests: document intent and known edge cases
describe("slugify examples", () => {
  test.each([
    ["Hello World", "hello-world"],
    ["  trim me  ", "trim-me"],
    ["múltiple  spaces", "multiple-spaces"],
    ["", ""],
    ["---", ""],
  ])('slugify("%s") === "%s"', (input, expected) => {
    expect(slugify(input)).toBe(expected);
  });
});

// Property tests: structural invariants
describe("slugify properties", () => {
  const slugPattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$|^$/;

  test("output always matches slug pattern", () => {
    fc.assert(
      fc.property(fc.unicodeString({ maxLength: 200 }), (s) => {
        expect(slugify(s)).toMatch(slugPattern);
      })
    );
  });

  test("idempotent: slugify(slugify(s)) === slugify(s)", () => {
    fc.assert(
      fc.property(fc.unicodeString({ maxLength: 200 }), (s) => {
        expect(slugify(slugify(s))).toBe(slugify(s));
      })
    );
  });

  test("never longer than the input", () => {
    fc.assert(
      fc.property(fc.asciiString({ maxLength: 200 }), (s) => {
        expect(slugify(s).length).toBeLessThanOrEqual(s.length);
      })
    );
  });
});

This is how production test files should look. Examples for documentation, properties for confidence.


The moment fast-check finds a bug you’d never have written a test for — and it shrinks the input to three characters — you’ll understand why property-based testing belongs in your standard toolkit. It’s not a replacement for example tests. It’s the thing that runs in the dark and finds the cases you didn’t know to look for.

Leave a comment

👁 Views: 9,438 · Unique visitors: 14,506