Property-Based Testing in JavaScript 2026
TL;DR
Property-based testing finds bugs that example-based tests miss. Instead of writing expect(add(2, 3)).toBe(5), you declare properties that must hold for all inputs — then fast-check generates hundreds of random cases and finds the smallest failing input automatically. fast-check has 10M+ weekly npm downloads, ships TypeScript types out of the box, and integrates with Vitest, Jest, and the Node.js test runner. If you're only writing example-based tests, you're leaving bugs on the table.
Key Takeaways
- fast-check: ~10.4M weekly downloads (npm, March 2026) — dominant in the JS property-based testing space
- Finds edge cases automatically — empty strings, negative numbers, Unicode, boundary values
- Shrinking is built in — when a test fails, fast-check minimizes the input to the simplest failing case
- Works with any test runner — dedicated plugins for Vitest (
@fast-check/vitest), Jest (@fast-check/jest), and Node.js test runner - TypeScript-first — full type inference for generated values
What Is Property-Based Testing?
Every JavaScript developer writes tests the same way: pick specific inputs, compute the expected output by hand, and assert they match. This is example-based testing. You write expect(sort([3, 1, 2])).toEqual([1, 2, 3]) and move on. The problem is that you're doing two jobs — choosing interesting inputs and verifying correctness — and humans are bad at the first one.
Consider a sorting function. A typical test suite might check an already-sorted array, a reversed array, an empty array, and maybe an array with one element. That covers four scenarios. But what about arrays with duplicate values? Arrays where every element is the same? Arrays with negative numbers mixed with positive? Arrays with Number.MAX_SAFE_INTEGER? A thorough developer might add a few of these, but the space of possible inputs is effectively infinite. No matter how many examples you write, you're sampling a fraction of a fraction.
Property-based testing approaches the problem from the other direction. Instead of specifying inputs, you describe properties — statements about your function that should hold true for every possible input. The testing framework then generates random inputs automatically and checks whether the property holds. If it finds a violation, it reports the failing input.
Here is the shift in practice. An example-based sort test checks three hardcoded arrays:
test('sorts numbers ascending', () => {
expect(sort([3, 1, 2])).toEqual([1, 2, 3]);
expect(sort([5, -1, 0])).toEqual([-1, 0, 5]);
expect(sort([])).toEqual([]);
});
A property-based sort test describes two invariants — the output length matches the input, and every element is greater than or equal to the one before it — then lets the framework generate hundreds of arrays to verify both:
import fc from 'fast-check';
test('sort output has same length as input', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
expect(sort(arr)).toHaveLength(arr.length);
})
);
});
test('sort output is ordered', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = sort(arr);
for (let i = 1; i < sorted.length; i++) {
expect(sorted[i]).toBeGreaterThanOrEqual(sorted[i - 1]);
}
})
);
});
By default, fast-check generates 100 random arrays per property. Each array varies in length and content — including empty arrays, single-element arrays, arrays with negative numbers, arrays with duplicates, and arrays with extreme values. If any generated input violates the property, fast-check kicks in its shrinking algorithm to reduce the failing input to the smallest possible reproduction case, often turning a 50-element array into [1, 0].
The idea isn't new. Property-based testing originated with QuickCheck, a Haskell library released in 1999 by Koen Claessen and John Hughes. It spread to Scala (ScalaCheck), Python (Hypothesis), Erlang (PropEr), and eventually JavaScript. In the JavaScript ecosystem, jsverify was an early option but lost momentum. fast-check, created by Nicolas Dubien in 2017, became the standard — largely because it ships integrated shrinking, comprehensive TypeScript support, and a composable API that feels natural in JavaScript.
Why fast-check?
fast-check dominates the JavaScript property-based testing space for good reason. With over 10 million weekly downloads on npm, it's not a niche tool — it's a mainstream testing dependency used by projects like Jest, Jasmine, fp-ts, io-ts, Ramda, and js-yaml. Several of these projects discovered real bugs through fast-check that their hand-written test suites had missed for years.
The library is written in TypeScript and provides full type inference. When you write fc.record({ name: fc.string(), age: fc.integer() }), the generated value is typed as { name: string; age: number } without annotation. This matters because property-based tests involve custom generators, and losing type safety in generators defeats the purpose of using TypeScript.
| Feature | fast-check |
|---|---|
| Weekly downloads | ~10.4M |
| Latest version | 4.5.x |
| TypeScript | Native (written in TS) |
| Shrinking | Built-in, automatic |
| Test runners | Vitest, Jest, Node.js, Ava |
| Async support | Yes (fc.asyncProperty) |
| Replay | Yes (seed-based) |
Compared to alternatives, fast-check has a decisive edge. jsverify is effectively unmaintained. Hypothesis (Python) is excellent but not available for JavaScript. The @effect/schema library offers some property-based testing capabilities through its Effect ecosystem integration, but it targets a narrower audience. For the vast majority of JavaScript and TypeScript developers, fast-check is the right choice — it has the community, the documentation, the integrations, and the track record.
The library provides over 80 built-in arbitraries (generators) covering primitives, arrays, objects, dates, URLs, UUIDs, and more. These compose naturally through .map(), .chain(), and .filter(), letting you build generators for any domain type your application uses.
Getting Started
Install fast-check alongside your test runner. The core package works standalone, but the runner-specific packages provide cleaner integration:
# With Vitest (recommended)
npm install -D fast-check @fast-check/vitest
# With Jest
npm install -D fast-check @fast-check/jest
# Standalone (any runner)
npm install -D fast-check
Basic Example with Vitest
The @fast-check/vitest package adds a test.prop method that handles assertion and shrinking automatically. You pass an array of arbitraries and a function that receives the generated values:
import { test } from '@fast-check/vitest';
import fc from 'fast-check';
test.prop([fc.string(), fc.string()])('string concat length', (a, b) => {
expect(a.length + b.length).toBe((a + b).length);
});
This is the cleanest integration and the one most teams should use. The test.prop call generates two random strings per iteration, runs 100 iterations by default, and reports any failure with full shrinking.
Basic Example with Plain fast-check
If you prefer the standalone API — or use a test runner without a dedicated plugin — the pattern is fc.assert(fc.property(...)). This works identically across Vitest, Jest, Mocha, and the Node.js test runner:
import fc from 'fast-check';
import { describe, it, expect } from 'vitest';
describe('JSON round-trip', () => {
it('parse(stringify(x)) === x for any JSON-safe value', () => {
fc.assert(
fc.property(fc.jsonValue(), (value) => {
const roundTripped = JSON.parse(JSON.stringify(value));
expect(roundTripped).toEqual(value);
})
);
});
});
Both approaches are equivalent. The test.prop sugar reduces boilerplate; the explicit fc.assert approach gives you more control over configuration options.
Core Concepts
Arbitraries: Generating Test Data
Arbitraries are the building blocks of property-based testing. Each arbitrary is a typed generator that knows how to produce random values and how to shrink them when a test fails. fast-check ships over 80 built-in arbitraries, and you can compose them to generate any shape of data your application uses.
The primitive arbitraries cover all JavaScript value types:
fc.integer() // any 32-bit integer
fc.integer({ min: 0, max: 100 }) // constrained range
fc.float() // IEEE 754 float (including edge cases)
fc.string() // Unicode strings
fc.boolean() // true or false
Structural arbitraries combine primitives into arrays, tuples, records, and nested objects:
fc.array(fc.integer()) // number[]
fc.array(fc.string(), { minLength: 1, maxLength: 10 })
fc.tuple(fc.string(), fc.integer()) // [string, number]
fc.record({ name: fc.string(), age: fc.nat() }) // { name: string, age: number }
Domain-specific arbitraries generate realistic values for web and application development. These are particularly useful because they generate syntactically valid but randomly varied values, catching bugs that hardcoded test emails and URLs would miss:
fc.uuid() // valid UUID v4
fc.emailAddress() // syntactically valid email
fc.webUrl() // valid URL
fc.date() // Date object
fc.jsonValue() // any JSON-serializable value
Custom Arbitraries with .map() and .chain()
Production code rarely consumes raw integers and strings. You need generators that match your domain types. fast-check provides two composition primitives for this: .map() for transforming values and .chain() for dependent generation.
The .map() method transforms a generated value after creation. This is the most common way to build custom arbitraries:
const userArb = 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', 'editor', 'viewer'),
createdAt: fc.date({ min: new Date('2020-01-01'), max: new Date() }),
});
const sortedArrayArb = fc.array(fc.integer()).map(
(arr) => arr.sort((a, b) => a - b)
);
The .chain() method handles cases where one generated value must depend on another. For example, generating an array and then a valid index into that array requires .chain() because the index range depends on the array length:
const arrayWithIndexArb = fc
.array(fc.integer(), { minLength: 1 })
.chain((arr) =>
fc.tuple(fc.constant(arr), fc.integer({ min: 0, max: arr.length - 1 }))
);
This pattern is invaluable for testing functions that take related inputs — a collection and an element from that collection, a tree and a valid path through it, or a database schema and a query that targets one of its tables.
Shrinking: Finding Minimal Failures
Shrinking is the feature that makes property-based testing practical. When fast-check discovers a failing input, it doesn't just hand you a random 200-character string or a 50-element array. Instead, it systematically reduces the input while still maintaining the failure, converging on the smallest possible counterexample.
This is the difference between a test failure that says "your function fails for [847, -3291, 0, 42, -1, 999, 13, -7]" and one that says "your function fails for [0, 0]". The shrunk output usually points directly at the bug class — off-by-one errors surface as arrays of length 1 or 2, encoding bugs surface as empty strings or single Unicode characters, and boundary errors surface as values like 0, -1, or Number.MAX_SAFE_INTEGER.
// Output when a test fails:
// Property failed after 23 tests
// Shrunk 5 time(s)
// Counterexample: [[0, 0]]
// Seed: 1847293651
Every failure includes a seed. Pass this seed back to reproduce the exact sequence of generation and shrinking — essential for debugging and for CI reproducibility:
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
return mySort(arr).length === arr.length;
}),
{ seed: 1847293651 }
);
Shrinking in fast-check is built into every arbitrary, including custom ones created with .map() and .chain(). You don't need to implement shrinking logic yourself — the framework handles it automatically by composing the shrink strategies of the underlying arbitraries.
Real-World Patterns
Knowing that property-based testing exists is one thing. Knowing which properties to test is the skill. Over years of community usage, five patterns have emerged that cover the vast majority of practical use cases.
Pattern 1: Round-Trip Testing (Encode/Decode)
Round-trip testing is the most immediately useful pattern and the one you should reach for first. The property is simple: if you encode a value and then decode it, you should get the original value back. This applies to any pair of inverse functions.
import fc from 'fast-check';
import { encode, decode } from './codec';
test.prop([fc.string()])('encode/decode round-trip', (input) => {
expect(decode(encode(input))).toBe(input);
});
The power of this pattern is in the edge cases fast-check generates. For string encoding, it will test empty strings, strings with null bytes, strings with emoji and combining characters, strings with RTL markers, and extremely long strings. These are exactly the inputs that cause real encoding bugs — and exactly the inputs that developers forget to include in hand-written tests.
Round-trip properties apply to a surprising number of codebases: JSON serialization, URL encoding, base64, compression, database serialization, API request/response mapping, form state serialization, query string parsing, and any function pair where parse(format(x)) === x.
Pattern 2: Oracle Testing (Compare Against Reference)
Oracle testing compares your implementation against a trusted reference. The reference can be slower, less efficient, or use a different algorithm — as long as it produces correct results. This is particularly useful when you're optimizing existing code or implementing a standard algorithm.
test.prop([fc.array(fc.integer())])('custom sort matches native sort', (arr) => {
const expected = [...arr].sort((a, b) => a - b);
const actual = myQuickSort([...arr]);
expect(actual).toEqual(expected);
});
This pattern works well for custom parsers tested against regex, hand-written algorithms tested against library implementations, and optimized database queries tested against brute-force computation. It's especially valuable during performance optimization work, where you change the implementation but the behavior must remain identical.
Pattern 3: Invariant Testing
Invariant testing asserts properties that must always hold regardless of input. These are universal truths about your data structures and operations.
test.prop([fc.array(fc.string())])('Set removes duplicates', (arr) => {
const set = new Set(arr);
const uniqueArr = [...set];
expect(uniqueArr.length).toBe(new Set(uniqueArr).size);
});
test.prop([fc.string(), fc.jsonValue()])('Map get/set consistency', (key, value) => {
const map = new Map();
map.set(key, value);
expect(map.get(key)).toEqual(value);
});
Invariant testing shines when testing state machines, caches, and data structures. The properties describe what must always be true — the cache size never exceeds the maximum, the queue is always ordered by priority, the balance sheet always sums to zero — and fast-check explores the input space to find violations.
Pattern 4: Idempotency
An idempotent operation produces the same result whether applied once or multiple times. Many real-world operations should be idempotent — database upserts, API PUT requests, data normalization, and formatting functions. Testing this property catches bugs where repeated application introduces drift or side effects.
test.prop([fc.string()])('trim is idempotent', (s) => {
expect(s.trim().trim()).toBe(s.trim());
});
test.prop([fc.array(fc.integer())])('dedup is idempotent', (arr) => {
const once = dedup(arr);
const twice = dedup(dedup(arr));
expect(twice).toEqual(once);
});
Pattern 5: Commutativity and Associativity
Mathematical properties often underpin business logic, even when the code doesn't look mathematical. If your merge function should produce the same result regardless of argument order, that's commutativity. If your pipeline steps can be grouped differently without changing the outcome, that's associativity. Testing these properties catches ordering bugs and accumulation errors.
test.prop([fc.integer(), fc.integer()])('add is commutative', (a, b) => {
expect(a + b).toBe(b + a);
});
Async Property Testing
Modern JavaScript code is heavily asynchronous, and fast-check supports this natively with fc.asyncProperty. The API mirrors the synchronous version but wraps everything in promises. This lets you test database operations, API calls, file I/O, and any other async workflow with generated inputs.
import fc from 'fast-check';
test('async database operations maintain consistency', async () => {
await fc.assert(
fc.asyncProperty(
fc.record({ name: fc.string({ minLength: 1 }), email: fc.emailAddress() }),
async (userData) => {
const created = await db.users.create(userData);
const fetched = await db.users.findById(created.id);
expect(fetched).toMatchObject(userData);
await db.users.delete(created.id);
}
),
{ numRuns: 20 }
);
});
One practical consideration: async property tests are slower because each iteration involves I/O. Reduce numRuns to 10-20 for integration-level properties. The default of 100 is designed for in-memory pure-function tests that complete in microseconds. For database tests, 20 well-chosen iterations with fast-check's edge-case-biased generation catches more bugs than 100 iterations with hand-picked data.
Configuring fast-check
fast-check's behavior is configurable per assertion. The most commonly adjusted settings are numRuns (how many iterations), seed (for reproducibility), and verbose (for debugging):
fc.assert(
fc.property(fc.string(), (s) => {
// your assertion
}),
{
numRuns: 1000, // Run 1000 iterations (default: 100)
seed: 42, // Reproducible runs
verbose: 2, // Show all generated values on failure
endOnFailure: true, // Stop at first failure
timeout: 5000, // Per-run timeout in ms
}
);
With @fast-check/vitest, you can pass configuration as the second argument to test.prop:
import { test } from '@fast-check/vitest';
import fc from 'fast-check';
test.prop([fc.string()], { numRuns: 500 })('runs 500 times', (s) => {
expect(typeof s).toBe('string');
});
For CI environments, consider setting a fixed seed derived from the build number or date. This gives you deterministic test runs while still exploring the input space differently across builds. If a property test fails in CI, the seed in the output lets any developer reproduce the exact failure locally.
Common Mistakes and How to Avoid Them
The most frequent mistake developers make with property-based testing is re-implementing the function under test inside the property. If your property for double(x) asserts that double(x) === x * 2, you haven't tested a property — you've just re-derived the implementation. Instead, test an observable consequence: double(x) is always even, or double(x) > x when x > 0.
// Bad: re-implements the function
test.prop([fc.integer()])('double returns x * 2', (x) => {
expect(double(x)).toBe(x * 2);
});
// Good: tests an observable property
test.prop([fc.integer()])('double is always even', (x) => {
expect(double(x) % 2).toBe(0);
});
The second mistake is over-constraining arbitraries. If you restrict fc.integer({ min: 1, max: 10 }) when your function actually accepts any integer, you're voluntarily reducing coverage. Start with the broadest possible arbitrary and only constrain it when the function's contract genuinely excludes certain inputs.
Third, developers often ignore the shrunk counterexample in favor of debugging the original failing input. The original might be a 30-element array with values in the thousands — hard to reason about. The shrunk version is usually 1-3 elements with small values, pointing directly at the bug. Always read the shrunk output first.
Finally, many teams forget to preserve seeds in CI. fast-check outputs a seed with every failure. If you don't capture and persist this seed, a flaky-looking property test becomes impossible to reproduce. Record the seed in your CI logs, and consider setting a deterministic seed per-run so that any failure is trivially reproducible.
When to Use Property-Based Testing
Property-based testing complements example-based tests. It does not replace them. Example-based tests serve as documentation — they show specific use cases and expected behaviors that humans read to understand the code. Property-based tests serve as a safety net — they explore the input space more thoroughly than any human would.
| Scenario | Why PBT helps |
|---|---|
| Serialization/parsing code | Round-trip property catches encoding bugs |
| Mathematical/financial logic | Invariants like commutativity catch rounding errors |
| Data structure operations | Consistency properties catch state corruption |
| Sorting/filtering | Oracle testing against reference implementation |
| Input validation | Generates edge cases (empty, Unicode, boundary values) |
| Refactoring | Properties survive implementation changes, examples break |
Skip property-based testing for UI snapshot tests (the output space is visual, not logical), integration tests with slow external APIs (the iteration count makes them too slow), and trivial accessor functions where no interesting property exists.
The highest-value starting point is serialization code. Almost every codebase has functions that encode and decode data — JSON serialization, URL parameter encoding, form state management, or API payload formatting. These always have a round-trip property, and fast-check almost always finds an edge case (usually involving empty strings, Unicode, or special characters) that the hand-written test suite missed.
fast-check vs Fuzzing
Property-based testing and fuzzing both generate random inputs, but they serve different purposes and operate at different scales. Understanding the distinction helps you choose the right tool.
Property-based testing runs 100-1000 iterations per test, integrates with your test runner, and verifies logical correctness through explicit properties. Fuzzing runs millions of iterations with coverage-guided mutation, typically outside a test framework, looking for crashes, hangs, and memory errors. fast-check is for correctness; a fuzzer is for robustness and security.
| Property-based testing | Fuzzing | |
|---|---|---|
| Goal | Verify correctness properties | Find crashes and vulnerabilities |
| Shrinking | Built-in (finds minimal case) | Usually not available |
| Integration | Test runner (Vitest, Jest) | Standalone tools |
| Speed | 100-1000 runs per test | Millions of runs |
| Use case | Logic correctness | Security, crash resistance |
For most JavaScript application development, fast-check provides the right balance of coverage and development speed. Reserve fuzzing for libraries that parse untrusted input, cryptographic code, or security-sensitive components.
Adding PBT to an Existing Test Suite
You don't need to rewrite your test suite to adopt property-based testing. The migration is incremental. Start with one module that has pure functions — a parser, a validator, a data transformer, or a serialization layer. Identify one property from the five patterns above. Write one test.prop call. Run it.
Most teams find their first real bug within the first hour of adoption. The typical discovery is an encoding issue with empty strings, a boundary error with zero or negative numbers, or a Unicode handling bug with multi-byte characters. These are bugs that existed for months or years, undetected by example-based tests, because no developer thought to include those specific inputs.
Once you've found and fixed that first bug, the value proposition is concrete. Expand coverage to more modules, build a library of domain-specific arbitraries, and add property tests alongside example tests in your standard workflow. Within a few weeks, property-based testing becomes a natural part of how your team writes tests.
Further Reading
- fast-check documentation — official docs with tutorials and API reference
- Compare fast-check with other testing tools on PkgPulse
- Related: Vitest 3 vs Jest 30 — pick your test runner first
- Related: Best API Mocking Libraries 2026 — pair PBT with proper mocking
- Related: Bun Test vs Vitest vs Jest — benchmark your test runner
- Related: Sinon vs Jest Mock vs vi.fn — mocking alongside property tests
See the live comparison
View fast check vs. example testing on PkgPulse →