Ch. 22 · Software Testing

Property-Based Testing in JavaScript with fast-check

Property tests generate hundreds of inputs and shrink failures to tiny examples: fast-check finds JavaScript's default sort and a CSV bug fast.

~7 min readbeginnerupdated Oct 11, 2026

An ordinary unit test checks one example: given this input, expect that output. You pick the examples, so the test covers the cases you thought of. Property-based testing flips that around. You describe what kind of input the code accepts and a rule that should hold for every one of them, and a library generates a few hundred inputs per run to try to break the rule.

When it finds a failing input, it doesn’t just hand you the random mess that triggered it. It shrinks the input, repeatedly trying smaller and simpler versions that still fail, until it reaches a case small enough to reason about. That combination, broad generation plus shrinking, is what makes the technique worth learning. It finds the inputs you didn’t think of and explains them in a form you can read.

The examples here use fast-check with Node’s built-in test runner. Python’s Hypothesis, Java’s jqwik and Haskell’s QuickCheck (where the idea started) work the same way.

The three parts of a property test

Every property test has the same shape:

  1. A generator (fast-check calls it an arbitrary) that describes valid inputs: fc.array(fc.integer({ min: 0, max: 100_000 })) means “arrays of integers between 0 and 100,000, of any length, including empty”.
  2. A property: a function that receives one generated input and throws, or returns false, if the rule is broken.
  3. A runner, fc.assert, that calls the property with many inputs (100 by default), shrinks any failure, and reports the seed so the run can be replayed.

The hard part is never the API. It’s writing a property that actually says something about correctness.

Sorting: two properties that pin it down

Say you need to sort a list of prices in cents. What’s true of every correctly sorted result, no matter the input?

  • Each element is less than or equal to the next.
  • It contains exactly the same values as the input, with the same counts. Nothing added, nothing lost.

Neither rule alone is enough. A function that always returns [] satisfies the first. A function that returns the input untouched satisfies the second. Together they define sorting. Here’s the test, applied to the built-in Array.prototype.sort with no comparator:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import fc from 'fast-check';

const isNonDecreasing = (xs) => xs.every((x, i) => i === 0 || xs[i - 1] <= x);

const countOf = (xs) => {
  const counts = new Map();
  for (const x of xs) counts.set(x, (counts.get(x) ?? 0) + 1);
  return counts;
};

test('sorting prices gives ascending order and keeps every value', () => {
  fc.assert(
    fc.property(fc.array(fc.integer({ min: 0, max: 100_000 })), (prices) => {
      const sorted = [...prices].sort();
      assert.ok(isNonDecreasing(sorted), `not ascending: ${JSON.stringify(sorted)}`);
      assert.deepEqual(countOf(sorted), countOf(prices));
    }),
  );
});
JavaScript
✖ sorting prices gives ascending order and keeps every value (6.477875ms)
  Error: Property failed after 6 tests
  { seed: 1741572766, path: "5:1:4:1:4:3", endOnFailure: true }
  Counterexample: [[10,2]]
  Shrunk 5 time(s)
  ...
    [cause]: AssertionError [ERR_ASSERTION]: not ascending: [10,2]
Text

The sixth generated array broke the rule, and after five shrinking steps fast-check reduced it to [10, 2]. Sorting that gives [10, 2], because sort() without a comparator converts values to strings and compares them as text: "10" comes before "2" the same way "ba" comes before "c". It’s one of the best-known traps in JavaScript, and a hand-written example test with prices like [300, 150, 999] would never hit it, because those all have three digits. Other runs shrink to equally small cases such as [2, 10000]; the seed changes each run, so the exact counterexample does too.

The fix is sort((a, b) => a - b), after which the property passes every run. Notice what the property didn’t do: it never computed the expected sorted array. It checked facts about the output that are easy to verify even when producing the output is hard.

Round trips find the inputs you forgot

When you have a pair of functions that undo each other (serialize and parse, encode and decode, compress and decompress), the round trip is a powerful property: parse what you wrote and you should get back what you started with. Here’s a CSV writer and reader that look fine for simple data:

export const toCsvLine = (fields) => fields.join(',');
export const fromCsvLine = (line) => line.split(',');
JavaScript
test('parsing a written CSV row gives back the original fields', () => {
  fc.assert(
    fc.property(fc.array(fc.string()), (fields) => {
      assert.deepEqual(fromCsvLine(toCsvLine(fields)), fields);
    }),
    { numRuns: 1000 },
  );
});
JavaScript

The first failure arrives immediately:

Counterexample: [[]]
Shrunk 0 time(s)
Text

An empty row writes as "", and splitting "" gives [""], one empty field. Is an empty row meaningful in a CSV file? Not really: RFC 4180 rows have at least one field. That’s a design decision the property forced you to make. Deciding that a row needs at least one field, the generator becomes fc.array(fc.string(), { minLength: 1 }) and the writer throws a RangeError for an empty array. Run again:

Counterexample: [[",",""]]
Shrunk 6 time(s)
Text

A field containing a comma splits into two fields on the way back. (Shrinking finds a small example, not always the smallest; [","] alone fails too.) Following the property leads straight to the rules any CSV library implements: quote fields that contain commas, quotes or line breaks, and double any quote inside a quoted field. With a writer that quotes and a reader that understands quotes, the property passes all 1,000 runs.

A pass is only as good as the generator

Before trusting that green run, check what fc.string() produces. In fast-check 4 it generates printable ASCII only, character codes 32 to 126: commas and quotes, yes, but never a line break and never anything outside ASCII. So the passing run says nothing about fields containing newlines.

The obvious widening, fc.string({ unit: 'binary' }), draws from every Unicode code point. Against the naive join/split version, that property passed 1,000 runs, five runs in a row. A comma is one character in over a million possibilities, so it almost never appears, and the broken code looks correct. Broad isn’t the same as thorough.

What works is a generator that’s broad and biased toward the characters that matter:

// Mostly any Unicode character, but with CSV's special characters deliberately common.
const csvChar = fc.oneof(
  fc.constantFrom(',', '"', '\r', '\n'),
  fc.string({ unit: 'grapheme', minLength: 1, maxLength: 1 }),
);
const field = fc.string({ unit: csvChar });
JavaScript

With fc.array(field, { minLength: 1 }), the naive version fails on the very first input every time, shrunk to [","], and the quoting version passes 1,000 runs that now include line breaks, quotes and characters from many scripts. Half the skill in property testing is knowing your domain’s dangerous inputs and making sure the generator produces them often.

Properties that aren’t the implementation again

The common beginner mistake is writing a property that recomputes the answer the same way the code does: assert.equal(total(items), items.reduce(...)) when total is that same reduce. It passes forever and tests nothing. Properties should come from somewhere independent:

  • Invariants: facts about the output, like “sorted”, “same elements”, “balance never negative”, “result length equals input length”.
  • Round trips: decode(encode(x)) equals x.
  • A reference implementation: compare a fast, clever version against a slow, obviously correct one. An optimised search against a linear scan, a hand-rolled date parser against the standard library.
  • Idempotence: doing it twice equals doing it once. Formatting, normalising and deduplicating should all have this.
  • Metamorphic relations: how the output should change when the input changes. Adding an item to a cart never lowers the total; filtering a search further never returns more results.

Shape the generator to the input the code accepts. If it only takes positive amounts, generate positive amounts rather than generating everything and discarding most of it with fc.pre, which wastes runs and can make fast-check give up.

Making failures reproducible

A random test that fails once and never again is worse than useless, so every property runner prints a seed. In fast-check you replay the exact failing run by passing it back: fc.assert(property, { seed: 1741572766, path: "5:1:4:1:4:3" }). Put that in the test temporarily while you debug.

Once the bug is fixed, copy the shrunk counterexample into a normal example-based test: assert.deepEqual([10, 2].sort((a, b) => a - b), [2, 10]). The property keeps exploring new inputs on every run; the example guarantees that this particular regression is checked every time, whatever seed comes up. The two kinds of test work best side by side.

Interview practice

You’re testing a function splitBill(totalCents, people) that divides a restaurant bill as evenly as possible, in whole cents. What properties would you check, and what does a naive implementation get wrong?

Answer

Three properties, all checkable without knowing the exact expected shares: the result has exactly people entries; the shares add up to totalCents, so no cent is lost or invented; and the largest and smallest shares differ by at most one cent, which is what “as evenly as possible” means for whole cents. I’d generate totals from 0 to 1,000,000 cents and between 1 and 20 people, and treat zero people as a separate example test for the error case.

A naive version that gives everyone Math.round(totalCents / people) fails the sum property immediately. Running it through fast-check, the counterexample was [1, 2]: one cent split between two people gives each Math.round(0.5), which is 1, so the shares add up to two cents. A correct version gives everyone Math.floor(total / people) and hands the remainder out one cent at a time to the first few people. That passes all three properties. The sum property is the one that matters most here, because rounding errors in money are exactly the kind of bug example tests with friendly numbers like 10,000 cents among 4 people never reveal.

More in Software Testing

read ✓Software Testing · hard

Testing Time with an Injected Clock

Pass a clock into time-dependent code, test expiry boundaries to the millisecond with node:test, and see why sleep-based tests flake.

~7 min readread →
read ✓Software Testing · mid

Code Coverage and Its Limits

See a 100% covered function ship a boundary bug, read line and branch numbers from Node's coverage report, and set thresholds that help.

~6 min readread →
read ✓Software Testing · mid

Unit and Integration Test Balance

Watch fast unit tests pass while a real SQLite test catches a swapped column and an untranslated unique-constraint error, then split the work.

~7 min readread →
esc