Add Property-Based Tests for Business Rules
Generate inputs with fast-check, assert invariants that must always hold, and let shrinking reduce a failure to the smallest reproducer.
Task
Find edge cases in a money-splitting function by testing invariants over generated inputs instead of hand-picked examples.
When to use this
Example tests cover totals of 10, 20, and 100, but the split function loses a cent when the total does not divide evenly across three people.
Prerequisites
- Node.js 22 with TypeScript or JavaScript
- fast-check 3.22 or newer
- A test runner such as Vitest or node:test
Steps
1. Install fast-check as a development dependency.
pnpm add -D fast-checkExpected output: fast-check appears in devDependencies and the lockfile is updated.
2. Write the invariant first and run it against the current implementation to confirm it fails.
import fc from 'fast-check';
function splitMoney(totalCents, people) {
const share = Math.round(totalCents / people); // current implementation
return Array(people).fill(share);
}
test('shares always sum to the total', () => {
fc.assert(fc.property(
fc.integer({ min: 0, max: 1_000_000 }),
fc.integer({ min: 1, max: 20 }),
(total, people) =>
splitMoney(total, people).reduce((a, b) => a + b, 0) === total,
), { numRuns: 500 });
});
Expected output: Falsified with total=100 and people=3: [33, 33, 33] sums to 99, and shrinking reports the smallest failing pair.
3. Fix the implementation by distributing the remainder one cent at a time.
function splitMoney(totalCents, people) {
const base = Math.floor(totalCents / people);
const remainder = totalCents % people;
return Array.from({ length: people }, (_, i) => base + (i < remainder ? 1 : 0));
}
Expected output: All 500 generated cases pass, including 100 cents across 3 people as [34, 33, 33].
4. Add a second property that bounds the difference between shares.
fc.assert(fc.property(
fc.integer({ min: 0, max: 1_000_000 }),
fc.integer({ min: 1, max: 20 }),
(total, people) => {
const shares = splitMoney(total, people);
return Math.max(...shares) - Math.min(...shares) <= 1
&& shares.every((s) => s >= 0);
},
));
Expected output: The property holds for every generated case, catching future changes that round instead of distributing.
5. Pin the discovered counterexample as an explicit example so the regression runs even if generation changes.
const property = fc.property(
fc.integer({ min: 0, max: 1_000_000 }),
fc.integer({ min: 1, max: 20 }),
(total, people) => splitMoney(total, people).reduce((a, b) => a + b, 0) === total,
);
fc.assert(property, { examples: [[100, 3]], numRuns: 500 });
Expected output: The 100-cents/3-people case executes on every run, regardless of the random seed.
Troubleshooting
Problem
The property fails intermittently
Solution
The generator produces floating-point values or the code depends on time. Generate integer cents, inject a clock, and log the seed so failures are reproducible.
Problem
Shrinking produces a case that is hard to understand
Solution
Build a custom arbitrary that generates a valid domain object with readable fields, so the reported counterexample is meaningful.
Problem
The suite became slow
Solution
Keep numRuns modest locally and raise it in a nightly job. Small, focused properties are more useful than one huge property.
Limitations
- A property only proves what it states; a wrong invariant can pass forever.
- Generators must respect domain constraints, such as people >= 1, or failures may come from invalid input.
- High numRuns is CPU-heavy and does not replace example tests that document intended behaviour.
Examples test what you thought of
Hand-written tests are documentation: they encode the cases the author considered. They cannot cover the input space of a function that takes a total and a number of people. Property-based testing flips the approach: you state a rule that must hold for every valid input, and a generator searches for a counterexample. The first counterexample is often a case nobody would have written down, such as 100 cents split three ways.
State invariants, not outputs
The strongest properties describe conservation and bounds: the sum of shares equals the total, no share is negative, the largest and smallest differ by at most one cent, and the result length equals the number of people. These hold regardless of implementation details and survive refactors. Avoid restating the implementation in the test, which would pass even when the implementation is wrong in the same way.
fc.assert(fc.property(
fc.integer({ min: 0, max: 1_000_000 }),
fc.integer({ min: 1, max: 20 }),
(total, people) => splitMoney(total, people).reduce((a, b) => a + b, 0) === total,
));
Let shrinking do the debugging
When a property fails, fast-check reduces the counterexample to the smallest input that still fails. That is the real value: instead of a random 1,000,000-cent case, you get 100 cents and 3 people. Read the shrunk case, add it as an explicit example, and fix the code. The seed is printed too, so any failure can be replayed exactly.
Keep generators honest
A generator that produces people = 0 will fail with a division by zero, which is a test bug, not a product bug. Constrain generators to the domain, and add custom arbitraries for structured inputs. For money, always use integer cents; floating point turns every property into a discussion about rounding.
Choose properties where bugs hide
Property-based tests shine on pure functions with arithmetic, parsing, serialization, sorting, and state machines. They are a poor fit for endpoints whose main risk is a missing database constraint. Use them to complement, not replace, integration tests, and keep the expensive high-run-count execution in a nightly job so pull requests stay fast.