Property-Based Testing: Finding Bugs Your Unit Tests Miss
How property-based testing generates hundreds of random inputs to verify invariants and catch edge cases example tests miss, using fast-check in TypeScript.

Unit tests check that specific inputs produce specific outputs. You pick three or four examples, assert the results, and call it tested. But the bugs that reach production aren't in the examples you imagined—they're in the inputs you didn't think of: empty strings, negative numbers, Unicode edge cases, arrays with a single element, objects with unexpected property combinations.
Property-based testing flips the approach. Instead of specifying examples, you define properties—invariants that should hold for any valid input—and the testing framework generates hundreds of random inputs to try to break them.
From Examples to Properties
The core shift is from "this specific input produces this specific output" to "for all valid inputs, this property holds."
// ❌ Example-based test — picks a few known inputs
describe("sort", () => {
it("sorts numbers ascending", () => {
expect(sort([3, 1, 2])).toEqual([1, 2, 3]);
});
it("handles empty arrays", () => {
expect(sort([])).toEqual([]);
});
it("handles single element", () => {
expect(sort([5])).toEqual([5]);
});
// What about negative numbers? Duplicates?
// Very large arrays? NaN? Infinity?
});// ✅ Property-based test — verifies invariants for any input
import * as fc from "fast-check";
describe("sort", () => {
it("output length equals input length", () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
expect(sort(arr)).toHaveLength(arr.length);
})
);
});
it("output is ordered", () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = sort(arr);
for (let i = 1; i < sorted.length; i++) {
expect(sorted[i]).toBeGreaterThanOrEqual(
sorted[i - 1]
);
}
})
);
});
it("output contains the same elements", () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = sort(arr);
expect([...sorted].sort()).toEqual(
[...arr].sort()
);
})
);
});
it("is idempotent", () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
expect(sort(sort(arr))).toEqual(sort(arr));
})
);
});
});Those four properties—length preservation, ordering, element preservation, and idempotency—together completely specify what a sort function should do. The framework generates arrays of varying lengths, with negative numbers, duplicates, zeros, and large values. If any combination breaks a property, it reports the minimal failing case.
Custom Arbitraries for Domain Types
Real applications don't operate on raw integers and strings. Property-based testing shines when you build custom generators (called "arbitraries" in fast-check) that produce realistic domain objects.
// Build arbitraries that match your domain types
interface Money {
amount: number;
currency: "USD" | "EUR" | "GBP";
}
const moneyArb: fc.Arbitrary<Money> = fc.record({
amount: fc.integer({ min: 0, max: 1_000_000 }),
currency: fc.constantFrom("USD", "EUR", "GBP"),
});
interface OrderItem {
name: string;
price: Money;
quantity: number;
}
const orderItemArb: fc.Arbitrary<OrderItem> = fc.record({
name: fc.string({ minLength: 1, maxLength: 100 }),
price: moneyArb,
quantity: fc.integer({ min: 1, max: 99 }),
});
interface Order {
items: OrderItem[];
discount: number;
}
const orderArb: fc.Arbitrary<Order> = fc.record({
items: fc.array(orderItemArb, {
minLength: 1,
maxLength: 20,
}),
discount: fc.integer({ min: 0, max: 100 }),
});
// Now test order total calculation with realistic inputs
describe("calculateOrderTotal", () => {
it("total is never negative", () => {
fc.assert(
fc.property(orderArb, (order) => {
const total = calculateOrderTotal(order);
expect(total.amount).toBeGreaterThanOrEqual(0);
})
);
});
it("total without discount >= total with discount", () => {
fc.assert(
fc.property(orderArb, (order) => {
const withDiscount = calculateOrderTotal(order);
const withoutDiscount = calculateOrderTotal({
...order,
discount: 0,
});
expect(withoutDiscount.amount).toBeGreaterThanOrEqual(
withDiscount.amount
);
})
);
});
});Shrinking: Finding Minimal Failing Cases
When a property fails, the raw generated input is often complex—a 50-element array with large numbers. Fast-check automatically "shrinks" the failing input to find the smallest case that still fails.
// A buggy function
function removeDuplicates<T>(arr: T[]): T[] {
// Bug: uses indexOf which fails with NaN
return arr.filter(
(item, index) => arr.indexOf(item) === index
);
}
describe("removeDuplicates", () => {
it("output has no duplicates", () => {
fc.assert(
fc.property(
fc.array(fc.oneof(fc.integer(), fc.constant(NaN))),
(arr) => {
const result = removeDuplicates(arr);
const uniqueSet = new Set(result);
// NaN !== NaN, so Set handles it correctly
// but our function doesn't
expect(result.length).toBe(uniqueSet.size);
}
)
);
});
});
// fast-check output after shrinking:
// Property failed after 12 tests
// Shrunk 5 time(s)
// Counterexample: [[NaN, NaN]]
// Because indexOf(NaN) is always -1, NaN is never
// "found" so every NaN passes the filterThe shrunk counterexample [NaN, NaN] is far more useful than whatever random array initially triggered the failure. Shrinking transforms "your code fails on this 47-element array" into "your code fails on [NaN, NaN]"—immediately pointing to the bug.
Stateful Property Testing
Beyond pure functions, property-based testing can verify stateful systems by generating sequences of operations and checking that invariants hold after each step.
// Model-based testing: compare implementation against
// a simple model
class AccountModel {
balance = 0;
deposit(amount: number) {
this.balance += amount;
}
withdraw(amount: number) {
if (amount <= this.balance) {
this.balance -= amount;
}
}
}
// Command pattern for stateful testing
class DepositCommand implements fc.Command<
AccountModel,
BankAccount
> {
constructor(readonly amount: number) {}
check() {
return true;
}
run(model: AccountModel, real: BankAccount) {
model.deposit(this.amount);
real.deposit(this.amount);
expect(real.getBalance()).toBe(model.balance);
}
toString() {
return `deposit(${this.amount})`;
}
}
class WithdrawCommand implements fc.Command<
AccountModel,
BankAccount
> {
constructor(readonly amount: number) {}
check(model: AccountModel) {
return this.amount <= model.balance;
}
run(model: AccountModel, real: BankAccount) {
model.withdraw(this.amount);
real.withdraw(this.amount);
expect(real.getBalance()).toBe(model.balance);
}
toString() {
return `withdraw(${this.amount})`;
}
}
// Generate random command sequences
const commandArb = fc.commands([
fc.integer({ min: 1, max: 10000 }).map(
(n) => new DepositCommand(n)
),
fc.integer({ min: 1, max: 10000 }).map(
(n) => new WithdrawCommand(n)
),
]);
describe("BankAccount", () => {
it("matches model after any operation sequence", () => {
fc.assert(
fc.property(commandArb, (cmds) => {
const setup = () => ({
model: new AccountModel(),
real: new BankAccount(),
});
fc.modelRun(setup, cmds);
})
);
});
});This generates random sequences of deposits and withdrawals, executing each against both the simple model and the real implementation, verifying they stay in sync. If the real BankAccount has a rounding error or off-by-one bug that only appears after a specific sequence of operations, this test will find it and shrink the sequence to the minimal reproducing case.
Integrating into Existing Test Suites
Property-based tests complement example-based tests. They don't replace them—you still want specific examples that document expected behavior and serve as regression tests for known bugs.
// ❌ Replacing all tests with property tests
// Loses documentation value and regression specificity
// ✅ Add property tests alongside examples
describe("parseEmail", () => {
// Example tests: document behavior, serve as regression
it("parses standard email", () => {
expect(parseEmail("user@example.com")).toEqual({
local: "user",
domain: "example.com",
});
});
it("rejects email without @", () => {
expect(parseEmail("invalid")).toBeNull();
});
// Property tests: find edge cases you didn't imagine
it("round-trips valid emails", () => {
const emailArb = fc
.tuple(
fc.stringMatching(/^[a-z][a-z0-9.]{0,20}$/),
fc.stringMatching(/^[a-z][a-z0-9]{1,10}$/),
fc.stringMatching(/^[a-z]{2,6}$/)
)
.map(
([local, domain, tld]) =>
`${local}@${domain}.${tld}`
);
fc.assert(
fc.property(emailArb, (email) => {
const parsed = parseEmail(email);
if (parsed) {
expect(`${parsed.local}@${parsed.domain}`).toBe(
email
);
}
})
);
});
it("never throws on any string input", () => {
fc.assert(
fc.property(fc.string(), (input) => {
// Should return result or null, never throw
expect(() => parseEmail(input)).not.toThrow();
})
);
});
});Key Takeaways
Property-based testing shifts focus from specific examples to universal invariants—instead of asking "does this input produce this output?" you ask "does this property hold for all valid inputs?" Fast-check generates hundreds of random inputs per test run and automatically shrinks failing cases to the minimal counterexample, turning "fails on a 47-element array" into "fails on [NaN, NaN]." Custom arbitraries let you generate realistic domain objects—orders, users, transactions—so property tests exercise the same shapes your application handles in production. Stateful model-based testing verifies that sequences of operations on a real implementation match a simplified model, catching bugs that only emerge from specific operation orderings. Property tests complement rather than replace example tests: examples document expected behavior and serve as regression anchors, while properties explore the vast space of inputs you didn't think to check manually.


