Property-based Testing: Bugs finden, die Unit-Tests übersehen
Wie Property-based Testing hunderte zufällige Eingaben erzeugt, Invarianten prüft und Grenzfälle findet, die Beispieltests übersehen — mit fast-check.

Unit-Tests prüfen, ob bestimmte Eingaben bestimmte Ausgaben liefern. Man wählt drei oder vier Beispiele aus, prüft die Ergebnisse und hält den Code für getestet. Doch die Bugs, die es bis in die Produktion schaffen, stecken nicht in den Beispielen, die man sich ausgedacht hat, sondern in den Eingaben, an die man nicht gedacht hat: leere Strings, negative Zahlen, Unicode-Sonderfälle, Arrays mit nur einem Element, Objekte mit unerwarteten Kombinationen von Eigenschaften.
Property-based Testing dreht den Ansatz um. Statt Beispiele anzugeben, definiert man Eigenschaften (Properties) – Invarianten, die für jede gültige Eingabe gelten müssen –, und das Test-Framework generiert Hunderte zufällige Eingaben, um genau diese Eigenschaften zu widerlegen.
Von Beispielen zu Eigenschaften
Der zentrale Wechsel führt von „diese bestimmte Eingabe erzeugt diese bestimmte Ausgabe" zu „für alle gültigen Eingaben gilt diese Eigenschaft".
// ❌ 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));
})
);
});
});Diese vier Eigenschaften – Erhalt der Länge, Sortierreihenfolge, Erhalt der Elemente und Idempotenz – legen zusammen vollständig fest, was eine Sortierfunktion leisten muss. Das Framework erzeugt Arrays unterschiedlicher Länge, mit negativen Zahlen, Duplikaten, Nullen und großen Werten. Bricht eine Kombination eine Eigenschaft, meldet das Framework den minimalen fehlschlagenden Fall.
Benutzerdefinierte Arbitraries für Domänentypen
Reale Anwendungen arbeiten nicht mit rohen Ganzzahlen und Strings. Property-based Testing zeigt seine Stärke, wenn man benutzerdefinierte Generatoren baut (in fast-check „Arbitraries" genannt), die realistische Domänenobjekte erzeugen.
// 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: die minimalen fehlschlagenden Fälle finden
Schlägt eine Eigenschaft fehl, ist die roh generierte Eingabe oft komplex – etwa ein 50-elementiges Array mit großen Zahlen. Fast-check „schrumpft" die fehlschlagende Eingabe automatisch, um den kleinsten Fall zu finden, der noch fehlschlägt.
// 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 filterDas geschrumpfte Gegenbeispiel [NaN, NaN] ist weitaus nützlicher als das zufällige Array, das den Fehler ursprünglich ausgelöst hat. Shrinking macht aus „dein Code schlägt bei diesem 47-elementigen Array fehl" ein „dein Code schlägt bei [NaN, NaN] fehl" – und zeigt so direkt auf den Bug.
Zustandsbehaftetes Property-Testing
Über reine Funktionen hinaus kann Property-based Testing auch zustandsbehaftete Systeme verifizieren, indem es Sequenzen von Operationen erzeugt und nach jedem Schritt prüft, ob die Invarianten weiterhin gelten.
// 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);
})
);
});
});Das erzeugt zufällige Sequenzen aus Einzahlungen und Abhebungen, führt jede davon sowohl gegen das einfache Modell als auch gegen die reale Implementierung aus und prüft, ob beide synchron bleiben. Hat die reale BankAccount einen Rundungsfehler oder einen Off-by-One-Bug, der erst nach einer bestimmten Abfolge von Operationen auftritt, findet dieser Test ihn und reduziert die Sequenz auf den minimal reproduzierenden Fall.
Einbindung in bestehende Testsuiten
Property-based Tests ergänzen beispielbasierte Tests, sie ersetzen sie nicht: Man braucht weiterhin konkrete Beispiele, die das erwartete Verhalten dokumentieren und als Regressionstests für bekannte Bugs dienen.
// ❌ 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();
})
);
});
});Das Wichtigste in Kürze
Property-based Testing verlagert den Fokus von konkreten Beispielen auf universelle Invarianten: Statt zu fragen „erzeugt diese Eingabe diese Ausgabe?", fragt man „gilt diese Eigenschaft für alle gültigen Eingaben?". Fast-check erzeugt pro Testlauf Hunderte zufällige Eingaben und schrumpft fehlschlagende Fälle automatisch auf das minimale Gegenbeispiel – aus „schlägt bei einem 47-elementigen Array fehl" wird „schlägt bei [NaN, NaN] fehl". Mit benutzerdefinierten Arbitraries lassen sich realistische Domänenobjekte erzeugen – Bestellungen, Nutzer, Transaktionen –, sodass Property-Tests dieselben Datenformen durchspielen, die die Anwendung auch in der Produktion verarbeitet. Zustandsbehaftetes, modellbasiertes Testing prüft, ob Operationsfolgen auf einer realen Implementierung mit einem vereinfachten Modell übereinstimmen, und deckt so Bugs auf, die erst durch bestimmte Reihenfolgen von Operationen entstehen. Property-Tests ergänzen beispielbasierte Tests, statt sie zu ersetzen: Beispiele dokumentieren das erwartete Verhalten und dienen als Anker für Regressionstests, während Eigenschaften den riesigen Raum an Eingaben erkunden, an die man von Hand gar nicht gedacht hätte.


