






















You wrote 60 unit tests for the payment reconciliation module. Every line is covered. The CI badge is green. The deploy goes out at 10 a.m. At 10:47, the on-call phone rings: a batch of refunds failed with a cryptic arithmetic error that only happens when exactly three line items share the same discount code and one was already voided.
That test case did not exist in your 60 tests. Nobody thought to generate it. You did not need more tests. You needed tests that search for counterexamples instead of validating examples you already guessed at. That is what property-based testing does.
This post is about using Fast-Check, the best property-based testing library for TypeScript and Node.js, to find bugs that example-based tests never catch. You will learn the mental model shift, the concrete patterns that work in production code, and how to graft property-based testing onto an existing test suite without rewriting everything.
Example-based testing says: “Given input X, the output should be Y.” You pick X. You assert Y. If X is well-chosen, the test passes. If X is poorly chosen or the bug lives at input Z that nobody typed, the test passes anyway.
Property-based testing says: “For all inputs that satisfy condition P, the output should satisfy condition Q.” You define P (the preconditions) and Q (the postconditions). The framework generates hundreds of random inputs that satisfy P, runs your function, and checks Q. If any input violates Q, the framework shrinks it to the smallest failing case and hands it to you.
The hard part is not the library. The hard part is figuring out what properties your code must satisfy. Once you have the property, the library does the brute-force work. The skill is learning to recognize properties in everyday business logic.
Let me show you what that looks like in practice.
Install it in one line:
npm install --save-dev fast-check
Fast-Check works with Vitest, Jest, Mocha, or any test runner. Here is the simplest possible property test:
import * as fc from 'fast-check';
import { describe, it, expect } from 'vitest';
import { formatCurrency } from './money';
describe('formatCurrency', () => {
it('never produces a negative sign for zero', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 100000 }),
(amount) => {
const result = formatCurrency(amount, 'USD');
return !result.startsWith('-');
}
)
);
});
});
This generates 100 random integers between 0 and 100,000, runs formatCurrency on each, and checks that none of them produce a string starting with -. If formatCurrency has a bug where 0 becomes -0.00 or a large number overflows into negative, the test finds it.
The fc.assert call is the key. By default it runs 100 random iterations (controlled by fc.configureGlobal or per-test options). When a failure is found, Fast-Check shrinks the input to the minimal reproduction case.
Shrinking is what separates property-based testing from “just fuzz it.” Without shrinking, you get a failing input like {items: [{price: -8372, qty: 9}, {price: 0.01, qty: 1}], taxRate: 0.07, discount: 0.15} and you have to guess which part caused the failure.
With shrinking, Fast-Check reduces the failing input to the smallest possible counterexample. It looks like this:
Error: Property failed after 42 tests
Counterexample: [{price: -1, qty: 1}]
Shrunk 17 time(s)
That is actionable. You know immediately that a negative price breaks the calculation. You write the fix, add min: 0 to the price input, and move on.
Shrinking works because Fast-Check’s arbitraries know how to reduce themselves. An integer arbitrarily knows it can shrink toward 0. An array arbitrarily knows it can remove elements. A string arbitrarily knows it can shorten. When you compose arbitraries, the shrinkers compose too.
The hardest part of adopting property-based testing is learning to see properties. Here are six categories that cover 80% of real-world code.
This is the easiest property to write and the most revealing. If your function is idempotent, running it twice should produce the same output as running it once.
import * as fc from 'fast-check';
import { sanitizeEmail } from './sanitize';
describe('sanitizeEmail', () => {
it('is idempotent', () => {
fc.assert(
fc.property(fc.emailAddress(), (email) => {
const once = sanitizeEmail(email);
const twice = sanitizeEmail(once);
return once === twice;
})
);
});
});
If sanitizeEmail is truly idempotent, the test passes. If there is a bug where the first pass misses a case that the second pass catches, the test fails with the specific email that breaks idempotency.
This property catches: incomplete normalization, state leaks, unstable sort orders, and timestamp injection bugs.
Any function pair that serializes and deserializes should survive a round trip.
import * as fc from 'fast-check';
import { encrypt, decrypt } from './crypto';
describe('encrypt/decrypt', () => {
it('round-trips any valid payload', () => {
fc.assert(
fc.property(
fc.oneof(fc.string(), fc.unicodeString(), fc.json()),
fc.constant('aes-256-gcm-key-here'),
(payload, key) => {
const encrypted = encrypt(payload, key);
const decrypted = decrypt(encrypted, key);
return decrypted === payload;
}
)
);
});
});
This test found a bug in my team’s encryption wrapper on day one: Unicode strings with emoji were getting mangled because the codec assumed UTF-8 was ASCII-compatible in the padding calculation. One Fast-Check test caught what manual testing with “hello world” never would.
Round-trip properties apply to: JSON serialization, URL encoding, encryption, compression, base64, token generation, and database model serialization.
When you transform data, certain invariants should hold across the transformation.
import * as fc from 'fast-check';
import { sortByPriority } from './scheduler';
describe('sortByPriority', () => {
it('preserves the set of items', () => {
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.uuid(),
priority: fc.integer({ min: 1, max: 5 }),
name: fc.string()
}),
{ minLength: 1, maxLength: 100 }
),
(items) => {
const sorted = sortByPriority(items);
const originalIds = new Set(items.map(i => i.id));
const sortedIds = new Set(sorted.map(i => i.id));
return originalIds.size === sortedIds.size &&
[...originalIds].every(id => sortedIds.has(id));
}
)
);
});
});
Invariant tests are great for: sorting (same items before and after), filtering (result is a subset of input), validation (errors on invalid but not valid), and aggregation (total count equals sum of parts).
If operations should commute, test that the result does not depend on order.
import * as fc from 'fast-check';
import { applyDiscounts } from './pricing';
describe('applyDiscounts', () => {
it('applies discounts commutatively', () => {
fc.assert(
fc.property(
fc.record({
subtotal: fc.float({ min: 0, max: 10000, noNaN: true }),
discounts: fc.array(
fc.float({ min: 0, max: 1, noNaN: true }),
{ maxLength: 5 }
)
}),
({ subtotal, discounts }) => {
const forward = applyDiscounts(subtotal, discounts);
const backward = applyDiscounts(subtotal, [...discounts].reverse());
return Math.abs(forward - backward) < 0.01;
}
)
);
});
});
This catches: stateful accumulation bugs, global counters, and ordering assumptions baked into business logic.
The lowest bar is also the most valuable: a function should not throw for any valid input.
import * as fc from 'fast-check';
import { parsePacket } from './protocol';
describe('parsePacket', () => {
it('never throws for valid byte sequences', () => {
fc.assert(
fc.property(
fc.uint8Array({ minLength: 4, maxLength: 65536 }),
(bytes) => {
// Mark the beginning of a valid frame
bytes[0] = 0x02; // STX
bytes[bytes.length - 1] = 0x03; // ETX
expect(() => parsePacket(bytes)).not.toThrow();
}
)
);
});
});
This is the property equivalent of “scream tests” and it finds the same kind of bugs. If your function has an if (x) { ... } else { throw new Error() } path that only activates for a rare combination of inputs, a property test will find it within a few hundred iterations.
Even though Fast-Check generates random values, you can guide it toward edge cases.
import * as fc from 'fast-check';
import { paginate } from './pagination';
describe('paginate', () => {
it('never returns more items than requested', () => {
fc.assert(
fc.property(
fc.array(fc.string()),
fc.integer({ min: 0, max: 100 }),
fc.integer({ min: 0 }),
(items, pageSize, page) => {
const result = paginate(items, pageSize, page);
return result.items.length <= pageSize;
}
)
);
});
});
The fc.integer({ min: 0, max: 100 }) for pageSize generates values that are often 0, 1, 50, 100, and everything in between. Zero page sizes and single-item pages are classic edge cases that manual tests skip.
You do not need to rewrite your entire test suite. Property-based testing complements example-based tests. Here is the strategy I use:
Start with the pure functions. Utils, validators, transformers, serializers, parsers. These have zero side effects and the strongest properties.
One property test per module. Do not write 20 property tests for one function. Start with one invariant that matters most. Add more as you find bugs.
Keep your example tests. Example tests document expected behavior for humans. Property tests verify behavior for machines. Both are needed.
Use the seed to reproduce failures. When Fast-Check finds a counterexample, it prints a seed. Re-running with the same seed reproduces the exact sequence of inputs:
fc.assert(
fc.property(arbitrary, check),
{ seed: 42, numRuns: 1000 }
);
fc.string() generates empty strings, very long strings, strings with null bytes, and Unicode control characters. If your function is not designed to handle those, constrain the arbitrary to match the domain:fc.string({ minLength: 1, maxLength: 255 })
fc.constantFrom('pending', 'confirmed', 'cancelled')
fc.integer({ min: 0 })
Here is a bug I found in production code using this technique. The function takes a list of scheduled jobs and groups them into hourly batches:
interface Job {
id: string;
scheduledAt: Date;
priority: number;
}
function groupJobsByHour(jobs: Job[]): Map<string, Job[]> {
const groups = new Map<string, Job[]>();
for (const job of jobs) {
const hour = job.scheduledAt.toISOString().slice(0, 13);
if (!groups.has(hour)) {
groups.set(hour, []);
}
groups.get(hour)!.push(job);
}
return groups;
}
Looks fine. The example test passes. The property test:
it('each job appears in exactly one group', () => {
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.uuid(),
scheduledAt: fc.date({ min: new Date('2025-01-01'), max: new Date('2025-12-31') }),
priority: fc.integer({ min: 1, max: 10 })
}),
{ minLength: 1, maxLength: 500 }
),
(jobs) => {
const groups = groupJobsByHour(jobs);
const allJobsInGroups = new Set<string>();
for (const group of groups.values()) {
for (const job of group) {
allJobsInGroups.add(job.id);
}
}
return allJobsInGroups.size === jobs.length;
}
)
);
});
After 247 iterations, Fast-Check found the bug: when two jobs have the same ID in the input, one of them is silently dropped. The function assumed callers deduplicate. The callers assumed the function deduplicates. Nobody noticed because all 20 example tests used unique IDs.
The fix was a one-line guard that throws on duplicate IDs. The property test uncovered a data quality issue that example-based testing could have missed for years.
Property-based testing is not a silver bullet. It struggles with:
Side-effect-heavy code. Database calls, HTTP requests, file I/O. You need test doubles, and the property value drops because your arbitraries generate fake data that bypasses real system behavior. Use integration tests for those instead.
UI rendering. DOM output is hard to express as properties. “The button should always be visible when the user is logged in” is a property, but writing the arbitrary that generates all valid logged-in states is often harder than writing three example tests.
Algorithms with oracle functions. If the only way to check correctness is to run a slower, authoritative implementation and compare, you have effectively written two implementations and proven they agree. That is useful, but it is closer to differential testing than property testing.
Non-deterministic code. Functions that depend on time, random numbers, or external state cannot reliably pass property tests unless you inject the dependencies.
For everything else, property-based testing catches bugs that example-based testing misses by construction. The tests are shorter, more expressive, and they force you to think about the invariants your code must satisfy rather than the specific examples you happened to type.
If you are starting from zero, here is the fastest path to value:
fast-check as a dev dependency.Do not try to convert your entire test suite in one sitting. One property test per module, added during code review when you see a bug or a close-call, compounds into a suite that finds real bugs faster than example-based tests.
The seed matters. When a property test fails in CI, log the seed so the next person can reproduce locally without iterating through randomness:
afterEach(() => {
if (expect.getState().currentTestName?.includes('property')) {
// Log seed from fc context
}
});
A better approach: use fc.configureGlobal({ seed: Number(process.env.FC_SEED) }) so you can pin the seed via environment variable when a CI run reports a failure.
Property-based testing shifts the question from “did I guess the right inputs?” to “does my code hold up under all inputs?” That is a fundamentally stronger guarantee. It does not replace example-based tests. It fills the gap they leave open: the edge case nobody thought to write.
Start with one property. Pick a function that transforms data without side effects. Write the inverse or the invariant. Watch Fast-Check generate a hundred inputs you would never have typed. When it finds something you missed, you will never look at unit tests the same way again.
Shipping code that survives rare edge cases in production is the difference between a team that fixes bugs and a team that prevents them from reaching users. The discipline of writing property-based tests alongside example tests is one of those high-leverage engineering habits that compounds over the life of a project. Yojji’s teams apply this kind of rigorous testing methodology across the Node.js and TypeScript microservices they build for clients.
Yojji is an international custom software development company founded in 2016, with offices across Europe, the US, and the UK. Their dedicated engineering teams handle full-cycle product delivery including the testing strategies that keep production reliable, working with the JavaScript ecosystem (React, Node.js, TypeScript) and cloud platforms (AWS, Azure, GCP).
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。