Property-Based Testing
Generate hundreds of random inputs automatically — fast-check finds edge cases your example tests miss.
Real-World Analogy
Hiring a professional adversarial tester vs writing a checklist: you write tests for cases you can think of. Property-based testing is the adversarial tester who tries every weird combination you’d never imagine — empty strings, negative numbers, Unicode edge cases, maximum values — until something breaks.
What Property-Based Testing Catches
Example tests check specific cases you thought of. Property tests check invariants that must hold for any valid input:
// Example test — you thought of 3 cases
test('sort works', () => {
expect(sort([3, 1, 2])).toEqual([1, 2, 3]);
expect(sort([])).toEqual([]);
expect(sort([1])).toEqual([1]);
});
// Property test — checks invariants across 1000 random arrays:
// 1. Output is same length as input
// 2. Each element in output appears in input (no data added)
// 3. Output is non-decreasing
// 4. First element ≤ last element (for non-empty arrays) You didn’t think about: [NaN, Infinity, -0], [2^53, 2^53 + 1], arrays with 10,000 elements.
Setup: fast-check
npm install -D fast-check import { describe, it } from 'vitest';
import fc from 'fast-check';
import { sort } from './sort';
describe('sort', () => {
it('returns array of same length', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
expect(sort([...arr])).toHaveLength(arr.length);
})
);
});
it('output is non-decreasing', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const result = sort([...arr]);
for (let i = 1; i < result.length; i++) {
expect(result[i]).toBeGreaterThanOrEqual(result[i - 1]);
}
})
);
});
it('contains same elements as input', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const result = sort([...arr]);
expect(result.sort()).toEqual([...arr].sort());
})
);
});
}); fast-check runs 100 random inputs by default. When it finds a failure, it shrinks — finds the minimal reproducing case:
Error: Property failed after 14 tests
Counterexample: [[-2147483648, 2147483647]]
Shrunk 4 times Not [1, -5, 3, 8, -2147483648, 2147483647] — just [-2147483648, 2147483647].
Arbitraries: Generating Test Data
import fc from 'fast-check';
// Primitives
fc.integer() // any integer
fc.integer({ min: 0, max: 100 }) // bounded
fc.float() // any float (including NaN, Infinity)
fc.float({ noNaN: true, noDefaultInfinity: true })
fc.string() // any string (including Unicode, empty)
fc.string({ minLength: 1 }) // non-empty string
fc.boolean()
fc.date()
// Collections
fc.array(fc.integer()) // array of integers (length 0–10 by default)
fc.array(fc.string(), { minLength: 1, maxLength: 100 })
fc.set(fc.integer()) // unique values
fc.record({ // object with specific shape
id: fc.uuid(),
name: fc.string({ minLength: 1 }),
age: fc.integer({ min: 0, max: 120 }),
})
// Combinators
fc.oneof(fc.integer(), fc.string()) // either type
fc.option(fc.integer()) // integer or null
fc.tuple(fc.string(), fc.integer()) // fixed-length tuple
fc.constantFrom('admin', 'standard', 'premium') // enum-like Domain-Specific Generators
Build generators for your domain types:
const arbEmail = fc.string({ minLength: 1 }).map(s =>
`${s.replace(/[^a-z0-9]/gi, 'x')}@example.com`
);
const arbMoney = fc.integer({ min: 0, max: 1_000_000_00 }); // cents, no negatives
const arbUser = fc.record({
id: fc.uuid(),
email: arbEmail,
role: fc.constantFrom('admin', 'standard', 'premium'),
balanceCents: arbMoney,
});
const arbOrder = fc.record({
id: fc.uuid(),
userId: fc.uuid(),
items: fc.array(
fc.record({
productId: fc.uuid(),
quantity: fc.integer({ min: 1, max: 100 }),
priceCents: fc.integer({ min: 1, max: 100_000_00 }),
}),
{ minLength: 1, maxLength: 20 }
),
}); Testing Business Logic Properties
import fc from 'fast-check';
import { calculateOrderTotal, applyDiscount } from './pricing';
describe('pricing properties', () => {
it('total is sum of (quantity × price) for all items', () => {
fc.assert(
fc.property(arbOrder, (order) => {
const expected = order.items.reduce(
(sum, item) => sum + item.quantity * item.priceCents,
0
);
expect(calculateOrderTotal(order)).toBe(expected);
})
);
});
it('discount never increases price', () => {
fc.assert(
fc.property(
arbMoney,
fc.integer({ min: 0, max: 100 }),
(price, discountPercent) => {
const discounted = applyDiscount(price, discountPercent);
expect(discounted).toBeLessThanOrEqual(price);
expect(discounted).toBeGreaterThanOrEqual(0);
}
)
);
});
it('applying 0% discount returns original price', () => {
fc.assert(
fc.property(arbMoney, (price) => {
expect(applyDiscount(price, 0)).toBe(price);
})
);
});
it('applying 100% discount returns 0', () => {
fc.assert(
fc.property(arbMoney, (price) => {
expect(applyDiscount(price, 100)).toBe(0);
})
);
});
}); Stateful Property Testing
Test sequences of operations — model-based testing:
// Test a shopping cart: any sequence of add/remove operations
// should maintain invariants
import fc from 'fast-check';
import { Cart } from './cart';
const cartCommands = [
// Add item command
fc.record({
type: fc.constant('add'),
productId: fc.uuid(),
quantity: fc.integer({ min: 1, max: 10 }),
price: fc.integer({ min: 1, max: 10_000_00 }),
}),
// Remove item command
fc.record({
type: fc.constant('remove'),
productId: fc.uuid(),
}),
];
it('cart invariants hold for any sequence of operations', () => {
fc.assert(
fc.property(
fc.array(fc.oneof(...cartCommands), { maxLength: 50 }),
(commands) => {
const cart = new Cart();
for (const cmd of commands) {
if (cmd.type === 'add') {
cart.add(cmd.productId, cmd.quantity, cmd.price);
} else {
cart.remove(cmd.productId);
}
}
// Invariants that must hold regardless of operation sequence:
// 1. Total matches sum of items
const expectedTotal = [...cart.items()].reduce(
(sum, [, item]) => sum + item.quantity * item.price,
0
);
expect(cart.total()).toBe(expectedTotal);
// 2. Total is never negative
expect(cart.total()).toBeGreaterThanOrEqual(0);
// 3. Item count matches items map size
expect(cart.itemCount()).toBe([...cart.items()].length);
}
)
);
}); Round-Trip Properties
Especially useful for serialization, encoding, and data transformations:
import { serialize, deserialize } from './serializer';
import { encode, decode } from './base64url';
it('serialize → deserialize is identity', () => {
fc.assert(
fc.property(arbUser, (user) => {
expect(deserialize(serialize(user))).toEqual(user);
})
);
});
it('encode → decode is identity', () => {
fc.assert(
fc.property(fc.uint8Array(), (bytes) => {
expect(decode(encode(bytes))).toEqual(bytes);
})
);
});
it('parseDate → formatDate is identity for valid dates', () => {
fc.assert(
fc.property(fc.date({ min: new Date(1970, 0, 1), max: new Date(2100, 11, 31) }), (date) => {
const formatted = formatDate(date);
const parsed = parseDate(formatted);
expect(parsed.getTime()).toBe(date.getTime());
})
);
}); Configuring Runs
// More samples for critical code
fc.assert(
fc.property(fc.integer(), fn),
{ numRuns: 1000 } // default is 100
);
// Reproduce a specific failure (from the seed/path in the error output)
fc.assert(
fc.property(fc.integer(), fn),
{ seed: 1234567890, path: '3:1' }
);
// Verbose output for debugging
fc.assert(
fc.property(fc.integer(), fn),
{ verbose: true }
); When to Use Property Tests
Great fit:
- Pure functions with mathematical properties (sort, encode/decode, arithmetic)
- Parsers and serializers (round-trip property)
- Data transformation pipelines
- Domain logic with clear invariants (pricing, discounts, scoring)
- Protocol implementations
Poor fit:
- UI interactions (use e2e tests)
- Operations with side effects (DB writes, HTTP calls)
- Logic where “correct” output is hard to define without re-implementing the function
- Simple CRUD (example tests are clearer)
Mix property and example tests: property tests catch the edge cases you can’t imagine, example tests document the specific cases that matter for your domain.