Skip to content

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.