with fast-check, find edge cases instead of guessing them
Classic unit tests check individual, hand-picked examples. Property-based testing flips that around: instead of writing examples, you define properties that must hold for every valid input, then let fast-check generate thousands of random cases to uncover exactly the edge cases a human writing tests by hand tends to overlook.
Table of contents
- 1. What property-based testing actually solves
- 2. The core principle: properties instead of examples
- 3. Installing fast-check and writing your first test
- 4. Arbitraries: generating test data on purpose
- 5. Shrinking: from a random failure to a minimal example
- 6. Property patterns: finding invariants in real code
- 7. Integration with Vitest and CI pipelines
- 8. Limits: when example tests remain the better choice
- 9. Property-based testing in direct comparison
- 10. Summary
- 11. FAQ
1. What property-based testing actually solves
A classic unit test checks one concrete input against one concrete expected output: expect(add(2, 3)).toBe(5). The problem is not the assertion itself but its reach. Anyone writing tests by hand almost always tests the cases that come to mind first, usually the normal path plus one or two obvious edge cases. Precisely the inputs that actually trigger a bug, an empty array, a negative number, a Unicode character outside the Basic Multilingual Plane, are almost always missing from that list simply because the author never thinks of them while writing.
Property-based testing solves this by handing the responsibility of choosing concrete values over to a testing library. Instead of individual examples, you define a property, a rule that must be true for every valid input, and the library automatically generates hundreds or thousands of random inputs to check that rule. In JavaScript, fast-check has established itself as the most mature implementation, inspired by QuickCheck from the Haskell world but fully tailored to everyday JavaScript and TypeScript work.
The practical benefit shows especially with functions that have clear mathematical or structural properties: serialization and deserialization, sorting algorithms, parsers, validation functions and data structure transformations. Wherever a property such as reversibility, idempotence or invariance holds, property-based testing is a tool that finds bugs before they ever show up in production.
2. The core principle: properties instead of examples
The central mental shift in property-based testing is asking: which property must hold for every valid input, regardless of its concrete value? For a sort function, the property reads: the result is always ordered ascending, and it contains exactly the same elements as the input, just in a different order. That property holds for every array of numbers, whether it is empty, has one element or ten thousand.
This generalization is the core of property-based testing and simultaneously the reason it finds bugs that example tests systematically miss. A developer writing test cases by hand typically thinks of three to five scenarios. fast-check runs one hundred iterations per property by default, each with a differently chosen random input, covering an input space no human could reasonably cover by hand in the same amount of time. Property-based testing complements example tests rather than replacing them entirely, because concrete regression tests for already-found bugs still have their place.
It is also worth stressing: property-based testing does not replace domain understanding. If you cannot formulate a meaningful property because a function is too complex or too dependent on external state, do not force one artificially. The skill lies in identifying functions where an invariant is clearly recognizable and applying property-based testing precisely there.
3. Installing fast-check and writing your first test
Installing fast-check is a plain npm install and works independently of the test runner in use, whether Vitest, Jest or node:test. The library ships two central building blocks: fc.assert runs a property against many generated inputs, and fc.property defines the actual property together with its arbitraries, the generators for the test data. The first test below checks a simple but typical property: reversing an array twice must return the original array.
// npm install --save-dev fast-check vitest
import { describe, it } from 'vitest';
import fc from 'fast-check';
function reverseArray(arr) {
return [...arr].reverse();
}
describe('reverseArray property tests', () => {
it('double reverse returns the original array', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const twiceReversed = reverseArray(reverseArray(arr));
return JSON.stringify(twiceReversed) === JSON.stringify(arr);
}),
{ numRuns: 200 } // run 200 generated cases instead of the default 100
);
});
it('reversed array keeps the same length', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
return reverseArray(arr).length === arr.length;
})
);
});
});
Notice the compactness: two property-based testing statements cover an input space that would otherwise require hundreds of individual it() blocks without fast-check. fc.array(fc.integer()) is an arbitrary, a generator that produces arbitrarily long arrays of random integers, including the edge cases empty array and very large array. If a property fails, fast-check outputs not just the failing case, but also the seed with which the run can be reproduced exactly.
4. Arbitraries: generating test data on purpose
Arbitraries are the heart of property-based testing in fast-check. They describe which value range the library should draw test data from, and they combine freely. Besides primitive generators such as fc.integer(), fc.string() or fc.boolean(), there are composite arbitraries such as fc.record() for objects with fixed fields, fc.array() for lists and fc.oneof() for unions of several possible types. This composability is the reason fast-check can model even complex domain objects as test data, not just plain numbers and strings.
An often underrated feature is the set of constraints many arbitraries accept as a second argument, such as fc.integer({ min: 0, max: 100 }) or fc.array(fc.string(), { minLength: 1, maxLength: 20 }). Such constraints prevent property-based testing from generating inputs that are completely outside the domain of the function under test, without artificially shrinking the range of edge cases. fc.string({ unit: 'grapheme' }) matters too, because plain strings in JavaScript can otherwise also produce invalid UTF-16 sequences that are irrelevant for many use cases.
import fc from 'fast-check';
// Composite arbitrary matching a real domain model
const userArbitrary = fc.record({
id: fc.uuid(),
email: fc.emailAddress(),
age: fc.integer({ min: 0, max: 130 }),
tags: fc.array(fc.string({ minLength: 1, maxLength: 12 }), { maxLength: 5 }),
role: fc.constantFrom('admin', 'editor', 'viewer'),
});
fc.assert(
fc.property(userArbitrary, (user) => {
const serialized = JSON.stringify(user);
const parsed = JSON.parse(serialized);
// Property: serializing then parsing must be lossless
return parsed.id === user.id && parsed.email === user.email;
})
);
// Filtering generated values that do not fit the domain
const evenNumberArbitrary = fc.integer().filter((n) => n % 2 === 0);
// Mapping generated values into a different shape
const priceArbitrary = fc.integer({ min: 0, max: 100000 }).map((cents) => cents / 100);
The last building block is fc.pre(), which lets you formulate preconditions inside a property without building a dedicated arbitrary. If the precondition is not met, fast-check skips the generated case instead of reporting a failure. That is useful when a filtering arbitrary would be too inefficient, but it should be used sparingly, because too many discarded cases weaken the explanatory power of property-based testing.
5. Shrinking: from a random failure to a minimal example
A randomly generated counterexample rarely helps when it consists of an array with forty-seven elements and deeply nested objects. This is where shrinking comes in, one of the most important features of property-based testing overall. When fast-check finds a failing case, the library automatically tries to simplify that case step by step, shorter arrays, smaller numbers, shorter strings, checking after every simplification whether the property still fails.
This process continues until no simpler case can be found that still reproduces the failure. The result is a minimal counterexample, often just a single element or a single number, that shows the actual root cause far more clearly than the original random case. Without shrinking, property-based testing would be considerably less useful in practice, because developers would spend a lot of time filtering the relevant subset out of a large random dataset by hand.
import fc from 'fast-check';
// Intentionally buggy implementation to demonstrate shrinking output
function sumPositive(numbers) {
return numbers.filter((n) => n > 0).reduce((a, b) => a + b, 1); // bug: seed should be 0
}
fc.assert(
fc.property(fc.array(fc.integer({ min: -100, max: 100 })), (numbers) => {
const expected = numbers.filter((n) => n > 0).reduce((a, b) => a + b, 0);
return sumPositive(numbers) === expected;
})
);
/*
Console output after shrinking:
Error: Property failed after 1 tests
{ seed: 1737200011832, path: "0:0", endOnFailure: true }
Counterexample: [[]]
Shrunk 3 time(s)
Got: sumPositive([]) === 1, expected 0
The shrunk counterexample is the empty array — far more useful than the
original 40-element random array fast-check found first.
*/
In practice, you reproduce a failing property-based testing run by passing the printed seed via { seed: 1737200011832, path: "0:0" } to fc.assert. That replays exactly the same case without waiting for a new random hit, a decisive advantage over purely random, non-reproducible fuzzing.
6. Property patterns: finding invariants in real code
Anyone new to property-based testing often struggles to formulate a fitting property at all. A proven pattern is invertibility, suitable for function pairs like encoding and decoding, serialization and parsing, encryption and decryption: decode(encode(x)) === x. A second pattern is idempotence, where a repeated call must have no additional effect, for example a normalization function: normalize(normalize(x)) === normalize(x).
A third pattern is comparing against an alternative, usually slower but obviously correct implementation, the so-called oracle. For an optimized sort function, you can compare against the built-in Array.prototype.sort. A fourth pattern is metamorphic properties, where the exact result is not checked, but a relationship between two calls, for example that filtering and then counting yields the same number as counting with the same predicate directly.
Property-based testing shows its strength especially with state machines. With fc.commands() you can generate sequences of operations that run against a real system and a simplified model at the same time, allowing not just single functions but entire stateful flows like a shopping cart or a Redux store to be checked for consistency.
7. Integration with Vitest and CI pipelines
fast-check runs inside any common test runner because fc.assert internally just throws an exception when a property fails, exactly like a normal expect assertion. That means property-based testing needs no dedicated test infrastructure, no separate CI job and no separate reporting pipeline, it fits seamlessly into existing Vitest or Jest suites. An important aspect for CI is runtime: numRuns controls the number of iterations per property, often lower locally for fast feedback and higher in the CI pipeline for maximum coverage.
A second important CI consideration is determinism. Without a fixed seed, fast-check generates different random values on every run, which is intentional because it increases the chance of hitting different edge cases over time. If a property-based testing run fails in CI, the seed is printed in the error message and should be pinned as an explicit regression test with a fixed seed in the bugfix commit, so the same failure never goes unnoticed again.
// vitest.config.js — lower run count locally, higher in CI
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
env: {
FC_NUM_RUNS: process.env.CI ? '500' : '50',
},
},
});
// test file reading the environment variable
import fc from 'fast-check';
const numRuns = Number(process.env.FC_NUM_RUNS ?? 100);
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
return [...arr].sort((a, b) => a - b).length === arr.length;
}),
{ numRuns }
);
// Regression test pinning a previously found counterexample
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
return [...arr].sort((a, b) => a - b).length === arr.length;
}),
{ seed: 1737200011832, path: '0:0', endOnFailure: true }
);
8. Limits: when example tests remain the better choice
Property-based testing is not a universal tool meant to completely replace classic example tests. For functions without a clear mathematical or structural property, for instance a UI component that renders specific text at exactly defined places, a concrete example test is often more directly understandable and faster to write. Formulating an artificial property just for the sake of property-based testing produces tests that are harder to read than what they are meant to replace.
Runtime is a relevant factor too: a property with a hundred or more iterations naturally takes longer than a single example test. With very large test suites containing thousands of properties, this can noticeably affect CI runtime, which is why it makes sense to apply property-based testing deliberately to high-risk core logic rather than across every function in the project.
A third point concerns team acceptance. Property-based testing requires a different way of thinking than classic testing, and not every team member is immediately comfortable with the concept. A pragmatic entry point is starting with a few clearly recognizable invariants, for instance parsers or serialization functions, instead of rolling out property-based testing everywhere at once.
9. Property-based testing in direct comparison
The table below contrasts property-based testing with classic example-based testing and shows which approach fits which situation.
| Criterion | Example tests | Property-based testing (fast-check) |
|---|---|---|
| Input space coverage | 3 to 5 hand-picked cases | 100 to 1000+ automatically generated cases |
| Finding edge cases | Only if the author thinks of them | Automatic across the defined value range |
| Failure diagnosis | Direct, because the input is known | Direct thanks to automatic shrinking |
| Readability of domain detail | Very high, concrete values visible | Requires understanding the property |
| Runtime per test | Minimal | Higher due to many iterations |
| Regression protection for a known bug | Ideal, exact case gets pinned | Possible via fixed seed, but example test often clearer |
In practice, both approaches complement each other: property-based testing covers the input space broadly and finds unknown edge cases, while targeted example tests permanently guard against known, previously encountered bugs. Projects with mature testing cultures often use both techniques together instead of committing to a single method.
Mironsoft
JavaScript testing strategies for Magento and Hyvä projects
Property-based testing for your critical business logic?
We identify functions with clear invariants, introduce fast-check into your Vitest or Jest suites in a targeted way, and train your team to formulate resilient property-based testing properties.
Invariant analysis
Identify critical functions that benefit most from property-based testing
fast-check rollout
Set up arbitraries, shrinking and CI integration in existing test suites
Team training
Workshops on formulating properties and property patterns
10. Summary
Property-based testing fundamentally changes how you think about testing: instead of individual examples, you define properties that must hold for every valid input, and let fast-check automatically generate hundreds or thousands of random test cases. Arbitraries precisely describe which value range test data should come from, from simple integers to complex, composite domain models. Shrinking automatically reduces every found failure to its minimal example, making failure diagnosis far faster than with random, unreduced fuzzing.
Property-based testing does not replace classic example tests, it complements them exactly where clear invariants such as reversibility, idempotence or consistency with an oracle exist. Serialization, parsers, sorting algorithms and state machines are typical candidates where the effort pays off especially well. Anyone who applies property-based testing deliberately to high-risk core logic and pins the results with fixed regression tests gains test coverage that is practically unreachable with purely hand-written examples.
Property-based testing with fast-check — the essentials at a glance
Core principle
Properties instead of examples: a rule that must hold for every valid input, instead of individual concrete values.
Arbitraries
Generators like fc.integer(), fc.record() and fc.array() produce precisely controlled random test data.
Shrinking
Every found failure is automatically reduced to the smallest possible counterexample.
Use case
Serialization, parsers, sorting and state machines with clearly recognizable invariants.