Property-based Testing in TypeScript mit Fast-Check
Wie Property-based Testing Grenzfälle findet, die Unit-Tests übersehen: eine praktische Anleitung zu fast-check für Eingaben und Invarianten.

Klassische Unit-Tests prüfen das Verhalten für konkrete Eingaben, die man selbst auswählt. Property-based Testing dreht das um: Man beschreibt die Eigenschaften, die der Code erfüllen muss, und das Framework generiert Hunderte oder Tausende zufällige Eingaben, um Verletzungen zu finden. Findet es eine, wird die Eingabe automatisch auf den kleinsten reproduzierbaren Fall reduziert (Shrinking).
Das Ergebnis: Die Tests decken Grenzfälle auf, an die man nie gedacht hätte. Off-by-one-Fehler, leere Eingaben, Unicode-Grenzfälle, Integer-Überläufe und Kombinationen von Grenzwerten, die Tausende von Hand geschriebener Testfälle erfordern würden, um sie abzudecken.
Warum beispielbasierte Tests Bugs übersehen
Stellen wir uns eine Funktion vor, die ein Array sortiert. Man könnte drei beispielbasierte Tests schreiben: leeres Array, bereits sortiert und umgekehrt sortiert. Das deckt drei Fälle von unendlich vielen Möglichkeiten ab.
// ❌ Example-based tests — cover only cases you think of
import { describe, it, expect } from 'vitest';
import { customSort } from './sort';
describe('customSort', () => {
it('handles empty arrays', () => {
expect(customSort([])).toEqual([]);
});
it('sorts already sorted arrays', () => {
expect(customSort([1, 2, 3])).toEqual([1, 2, 3]);
});
it('sorts reverse arrays', () => {
expect(customSort([3, 2, 1])).toEqual([1, 2, 3]);
});
});
// These pass, but what about [NaN, -0, Infinity]?
// What about arrays with 10,000 duplicate elements?
// What about negative numbers mixed with zero?// ✅ Property-based tests — describe what MUST be true for ALL inputs
import { describe, it } from 'vitest';
import * as fc from 'fast-check';
import { customSort } from './sort';
describe('customSort properties', () => {
it('output length equals input length', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = customSort(arr);
return sorted.length === arr.length;
})
);
});
it('output is sorted in ascending order', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = customSort(arr);
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] < sorted[i - 1]) return false;
}
return true;
})
);
});
it('output contains the same elements as input', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = customSort(arr);
const inputCounts = countElements(arr);
const outputCounts = countElements(sorted);
return mapsEqual(inputCounts, outputCounts);
})
);
});
});Drei Eigenschaften — Erhalt der Länge, Sortierreihenfolge und Erhalt der Elemente — decken den vollständigen Verhaltensvertrag einer Sortierfunktion für jedes beliebige Integer-Array ab, nicht nur die drei Beispiele, die man zufällig geschrieben hat.
Erste Schritte mit Fast-Check
Fast-check ist die beliebteste Property-based-Testing-Bibliothek für TypeScript. Es lässt sich in jeden Testrunner integrieren — Vitest, Jest, Mocha.
npm install -D fast-checkimport * as fc from 'fast-check';
// Basic arbitraries — generators for random values
fc.integer(); // Random integers
fc.string(); // Random strings (including Unicode)
fc.boolean(); // true or false
fc.float(); // Random floating-point numbers
fc.array(fc.integer()); // Arrays of random integers
fc.date(); // Random Date objects
// Constrained arbitraries
fc.integer({ min: 1, max: 100 }); // Integers between 1 and 100
fc.string({ minLength: 1, maxLength: 50 }); // Non-empty strings up to 50 chars
fc.array(fc.integer(), { minLength: 1 }); // Non-empty integer arrays
// Composing custom arbitraries for domain objects
const userArbitrary = fc.record({
id: fc.uuid(),
name: fc.string({ minLength: 1, maxLength: 100 }),
email: fc.emailAddress(),
age: fc.integer({ min: 18, max: 120 }),
isActive: fc.boolean(),
});
// Generates random User objects with valid structureReale Geschäftslogik testen
Property-based Testing spielt seine Stärken besonders bei Geschäftslogik mit komplexen Invarianten aus. Hier ein Beispiel, das einen Rabattrechner testet:
interface CartItem {
productId: string;
price: number;
quantity: number;
}
interface DiscountResult {
subtotal: number;
discount: number;
total: number;
}
function calculateDiscount(items: CartItem[], couponPercent: number): DiscountResult {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const discount = subtotal * (couponPercent / 100);
const total = subtotal - discount;
return { subtotal, discount, total };
}
// Property-based tests for the discount calculator
describe('calculateDiscount', () => {
const cartItemArb = fc.record({
productId: fc.uuid(),
price: fc.float({ min: 0.01, max: 10000, noNaN: true }),
quantity: fc.integer({ min: 1, max: 100 }),
});
const cartArb = fc.array(cartItemArb, { minLength: 1, maxLength: 20 });
const couponArb = fc.float({ min: 0, max: 100, noNaN: true });
it('total is never negative', () => {
fc.assert(
fc.property(cartArb, couponArb, (items, coupon) => {
const result = calculateDiscount(items, coupon);
return result.total >= 0;
})
);
});
it('total + discount equals subtotal', () => {
fc.assert(
fc.property(cartArb, couponArb, (items, coupon) => {
const result = calculateDiscount(items, coupon);
return Math.abs(result.total + result.discount - result.subtotal) < 0.01;
})
);
});
it('zero coupon means no discount', () => {
fc.assert(
fc.property(cartArb, (items) => {
const result = calculateDiscount(items, 0);
return result.discount === 0 && result.total === result.subtotal;
})
);
});
it('100% coupon means zero total', () => {
fc.assert(
fc.property(cartArb, (items) => {
const result = calculateDiscount(items, 100);
return Math.abs(result.total) < 0.01;
})
);
});
});Shrinking: minimale Fehlerfälle finden
Wenn fast-check eine fehlschlagende Eingabe findet, verkleinert es sie automatisch (Shrinking) auf die kleinste Eingabe, die den Fehler noch auslöst. Das macht das Debugging erheblich einfacher.
// Imagine a bug in a string processing function
function processTitle(input: string): string {
// Bug: crashes on strings containing null characters
return input.trim().toLowerCase().replace(/\s+/g, '-');
}
// Fast-check might initially find failure with:
// "a\u0000bCdEf\u0000gh..." (a long, complex string)
// After shrinking, it reports the minimal case:
// "\u0000" (single null character)
// The shrunk case makes the bug obvious:
// .trim() doesn't handle null characters as expected
// You can configure shrinking behavior:
fc.assert(
fc.property(fc.string(), (input) => {
const result = processTitle(input);
return typeof result === 'string' && result.length >= 0;
}),
{
numRuns: 1000, // Run 1000 random inputs
seed: 42, // Reproducible randomness
endOnFailure: true // Stop at first failure
}
);// ❌ No seed — failures may not reproduce
fc.assert(fc.property(fc.string(), validator));
// If this fails, you might not be able to reproduce it
// ✅ Use seeds for reproducibility — fast-check logs the seed on failure
fc.assert(
fc.property(fc.string(), validator),
{ seed: 12345 }
);
// Failure output includes: "Seed: 12345"
// Re-run with same seed → same sequence of inputs → same failureKombination mit beispielbasierten Tests
Property-based Tests ersetzen beispielbasierte Tests nicht — sie ergänzen sie. Beispiele nutzt man zur Dokumentation und für bekannte Grenzfälle. Properties nutzt man, um unbekannte Grenzfälle aufzudecken.
describe('parseEmail', () => {
// Example-based: documents known behavior and specific edge cases
it('parses standard email', () => {
expect(parseEmail('user@example.com')).toEqual({
local: 'user',
domain: 'example.com',
});
});
it('rejects email without @', () => {
expect(parseEmail('invalid')).toBeNull();
});
// Property-based: finds unknown edge cases
it('round-trips valid emails', () => {
fc.assert(
fc.property(fc.emailAddress(), (email) => {
const parsed = parseEmail(email);
if (parsed === null) return false; // Valid email should parse
return `${parsed.local}@${parsed.domain}` === email.toLowerCase();
})
);
});
it('never throws on any string input', () => {
fc.assert(
fc.property(fc.string(), (input) => {
// Should return result or null, never throw
const result = parseEmail(input);
return result === null || (typeof result.local === 'string' && typeof result.domain === 'string');
})
);
});
});Die wichtigsten Erkenntnisse
- Property-based Tests beschreiben Invarianten — das, was für alle Eingaben gelten muss, nicht nur für die selbst gewählten Eingaben
- Fast-check generiert Hunderte zufällige Eingaben und reduziert Fehlschläge automatisch (Shrinking) auf minimale, reproduzierbare Fälle
- Grundlegende Eigenschaften zum Testen: Idempotenz, Round-Tripping, Erhalt von Invarianten, Kommutativität und "wirft nie eine Exception"
- Verwende eingeschränkte Arbitraries, um fachlich gültige Objekte zu erzeugen —
fc.record,fc.integer({ min, max }),fc.emailAddress() - Kombiniere mit beispielbasierten Tests — Beispiele dokumentieren bekanntes Verhalten, Properties decken unbekannte Grenzfälle auf
- Setze einen Seed für Reproduzierbarkeit — fast-check protokolliert den Seed bei jedem Fehlschlag, sodass sich die exakte Sequenz erneut abspielen lässt


