Skip to content

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.

5 min read
Comparison between example-based testing with a few fixed inputs and property-based testing generating hundreds of random inputs to discover edge cases

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."

tstypescript
// ❌ 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?
});
tstypescript
// ✅ 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.

tstypescript
// 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.

tstypescript
// 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 filter

The 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.

tstypescript
// 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.

tstypescript
// ❌ 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.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX