← Recipes

阅读中文版本

Write Custom Arbitraries for Business Rules

Move property tests beyond primitives by composing fast-check arbitraries for money, date ranges, and entity graphs that respect invariants.

~40 mintestingproperty-basedfast-check

Task

Build custom arbitraries that generate domain-valid values so properties test business rules instead of parser edge cases.

When to use this

Property tests generate any integer for quantity, and 90 percent of runs fail on validation the domain would never accept, hiding the real invariant bugs.

Prerequisites

  • fast-check installed in the test suite
  • A domain with documented invariants such as quantity >= 1 and startDate < endDate
  • A failing invariant to prove the tests work

Steps

1. Constrain primitives into domain ranges.

const quantity = fc.integer({ min: 1, max: 999 });
const price = fc.integer({ min: 0, max: 1_000_000 }).map(cents => ({ cents }));

Expected output: Generated values always respect the documented bounds, so failures point at logic, not at impossible inputs.

2. Compose a date-range arbitrary that preserves ordering.

const dateRange = fc.record({
  start: fc.date({ min: new Date("2026-01-01"), max: new Date("2026-12-31") }),
  days: fc.integer({ min: 1, max: 30 }),
}).map(({ start, days }) => ({
  start,
  end: new Date(start.getTime() + days * 86400_000),
}));

Expected output: Every generated range satisfies start < end by construction, removing an entire class of vacuous failures.

3. Build entity graphs with referential integrity.

const lineItems = fc.array(quantity, { minLength: 1, maxLength: 20 });
const order = fc.record({ items: lineItems }).map(({ items }) => ({
  items,
  totalCents: items.reduce((a, q) => a + q * 100, 0),
}));

Expected output: Generated orders always have internally consistent totals, letting properties focus on order processing rules.

4. State the property over the domain arbitrary.

fc.assert(fc.property(order, fc.constantMethod(order => order.items.length >= 1), () => {}));

fc.assert(fc.property(order, (o) => {
  const receipt = checkout(o);
  expect(receipt.totalCents).toBe(o.totalCents);
  expect(receipt.lines).toHaveLength(o.items.length);
}));

Expected output: The property expresses a business invariant: checkout preserves totals and line counts for every valid order.

5. Run with a fixed seed when a failure appears.

npx vitest run --property-seed=1763328000 2>/dev/null || fc.assert(prop, { seed: 1763328000 })

Expected output: The failing case reproduces deterministically, and fast-check shrinks it to the minimal counterexample.

Troubleshooting

Problem

Properties pass but ship bugs anyway

Solution

The arbitraries are too narrow; deliberately generate boundary and invalid inputs in separate properties to test rejection paths.

Problem

Shrinking stalls on complex records

Solution

Use fc.record and array length bounds rather than deep random structures; constrained generation shrinks fast.

Problem

Random dates break timezone-sensitive code

Solution

Generate UTC instants and convert deliberately in the test; never let local timezone leak into generated data.

Limitations

  • Custom arbitraries encode your understanding of the domain; a misunderstood invariant generates away the bug.
  • Property tests complement example tests for acceptance criteria; they do not replace them.

Generate the domain, not the noise

Default generators produce values your domain would reject, so properties mostly re-test validation. Custom arbitraries flip the ratio: every generated value is a legal business object, and any failure is a genuine logic violation. The combinators are cheap to write once and make every downstream property sharper.

const dateRange = fc.record({
  start: fc.date({ min: new Date("2026-01-01"), max: new Date("2026-12-31") }),
  days: fc.integer({ min: 1, max: 30 }),
}).map(({ start, days }) => ({ start, end: new Date(start.getTime() + days * 86400_000) }));

Invariant by construction beats invariant by filter

Filtering a broad generator for valid values wastes runs and biases the distribution toward the filter edges. Building the invariant into construction, like deriving end from start plus a positive duration, is faster and guarantees the property sees a representative spread of the legal domain.

Shrinking is the debugging UX

A property failure is only actionable if the counterexample is minimal. Keep arbitraries shallow and bounded so the shrinker can converge: records with few fields, arrays with maxLength, numbers with tight ranges. A five-field record shrinks in seconds; a deep random JSON tree may never finish.