Property-Based Testing in JavaScript mit fast-check
AI generated
JS
() =>
JavaScript · Testing Advanced · fast-check · Qualitätssicherung
Property-Based Testing in JavaScript
mit fast-check Randfälle finden, statt sie zu erraten

Klassische Unit-Tests prüfen einzelne, von Hand ausgedachte Beispiele. Property-Based Testing dreht das Prinzip um: Statt Beispiele zu schreiben, definiert man Eigenschaften, die für jede gültige Eingabe gelten müssen, und lässt fast-check tausende zufällige Fälle generieren, um genau die Randfälle zu finden, die ein Mensch beim Schreiben von Tests übersieht.

18 Min. Lesezeit fast-check · Arbitraries · Shrinking · Vitest Node.js 20+ · TypeScript optional

1. Was Property-Based Testing wirklich löst

Ein klassischer Unit-Test prüft eine konkrete Eingabe gegen eine konkrete erwartete Ausgabe: expect(add(2, 3)).toBe(5). Das Problem dabei ist nicht die Aussage selbst, sondern ihre Reichweite. Wer Tests von Hand schreibt, testet fast immer die Fälle, die einem als Erstes einfallen, meist den Normalfall und ein oder zwei offensichtliche Sonderfälle. Genau die Eingaben, die ein Bug tatsächlich auslösen, ein leeres Array, eine negative Zahl, ein Unicode-Zeichen außerhalb des Basic Multilingual Plane, fehlen in dieser Liste fast immer, weil sie dem Autor beim Schreiben schlicht nicht in den Sinn kommen.

Property-Based Testing löst dieses Problem, indem es die Verantwortung für die Auswahl konkreter Werte an eine Test-Bibliothek abgibt. Statt einzelner Beispiele definiert man eine Eigenschaft, eine Regel, die für jede gültige Eingabe wahr sein muss, und die Bibliothek generiert automatisch hunderte oder tausende zufällige Eingaben, um diese Regel zu prüfen. In JavaScript hat sich dafür fast-check als die ausgereifteste Implementierung etabliert, inspiriert von QuickCheck aus der Haskell-Welt, aber vollständig auf den JavaScript- und TypeScript-Alltag zugeschnitten.

Der praktische Nutzen zeigt sich besonders bei Funktionen mit klaren mathematischen oder strukturellen Eigenschaften: Serialisierung und Deserialisierung, Sortieralgorithmen, Parser, Validierungsfunktionen und Datenstruktur-Transformationen. Überall dort, wo eine Eigenschaft wie Umkehrbarkeit, Idempotenz oder Invarianz gilt, ist Property-Based Testing ein Werkzeug, das Bugs findet, bevor sie in Produktion auftauchen.

2. Das Grundprinzip: Eigenschaften statt Beispiele

Der zentrale Denkwechsel bei Property-Based Testing ist die Frage: Welche Eigenschaft muss für jede gültige Eingabe gelten, unabhängig davon, welchen konkreten Wert sie hat? Bei einer Sortierfunktion lautet die Eigenschaft zum Beispiel: Das Ergebnis ist immer aufsteigend geordnet, und es enthält exakt dieselben Elemente wie die Eingabe, nur in anderer Reihenfolge. Diese Eigenschaft gilt für jedes Array von Zahlen, egal ob es leer ist, ein Element enthält oder zehntausend.

Diese Verallgemeinerung ist der Kern von Property-Based Testing und gleichzeitig der Grund, warum es Fehler findet, die Beispiel-Tests systematisch übersehen. Ein Entwickler, der Testfälle von Hand schreibt, denkt in der Regel an drei bis fünf Szenarien. fast-check generiert standardmäßig hundert Durchläufe pro Property, jeder mit einer anderen, zufällig gewählten Eingabe, und deckt damit einen Eingaberaum ab, den kein Mensch in vertretbarer Zeit von Hand abdecken könnte. Property-Based Testing ergänzt Beispiel-Tests, es ersetzt sie nicht vollständig, weil konkrete Regressionstests für bereits gefundene Bugs weiterhin ihren Platz haben.

Wichtig ist außerdem: Property-Based Testing ersetzt kein Verständnis der Domäne. Wer keine sinnvolle Eigenschaft formulieren kann, weil die Funktion zu komplex oder zu sehr von externem Zustand abhängig ist, sollte nicht krampfhaft eine erzwingen. Die Kunst liegt darin, Funktionen zu identifizieren, bei denen eine Invariante klar erkennbar ist, und genau dort Property-Based Testing gezielt einzusetzen.

3. fast-check installieren und den ersten Test schreiben

Die Installation von fast-check erfolgt über npm und funktioniert unabhängig vom verwendeten Test-Runner, ob Vitest, Jest oder node:test. Die Bibliothek liefert zwei zentrale Bausteine: fc.assert führt eine Property gegen viele generierte Eingaben aus, und fc.property definiert die eigentliche Eigenschaft samt ihrer Arbitraries, also den Generatoren für die Testdaten. Der folgende erste Test prüft eine einfache, aber typische Eigenschaft: Das Umkehren eines Arrays und erneutes Umkehren muss das Ausgangsarray ergeben.


// npm install --save-dev fast-check vitest
import { describe, it } from 'vitest';
import fc from 'fast-check';

function reverseArray(arr) {
  return [...arr].reverse();
}

describe('reverseArray property tests', () => {
  it('double reverse returns the original array', () => {
    fc.assert(
      fc.property(fc.array(fc.integer()), (arr) => {
        const twiceReversed = reverseArray(reverseArray(arr));
        return JSON.stringify(twiceReversed) === JSON.stringify(arr);
      }),
      { numRuns: 200 } // run 200 generated cases instead of the default 100
    );
  });

  it('reversed array keeps the same length', () => {
    fc.assert(
      fc.property(fc.array(fc.integer()), (arr) => {
        return reverseArray(arr).length === arr.length;
      })
    );
  });
});

Bemerkenswert ist die Kompaktheit: Zwei Property-Based Testing Aussagen decken einen Eingaberaum ab, der ohne fast-check hunderte einzelne it()-Blöcke erfordern würde. Die Funktion fc.array(fc.integer()) ist ein Arbitrary, ein Generator, der beliebig lange Arrays aus zufälligen Ganzzahlen erzeugt, inklusive der Randfälle leeres Array und sehr großes Array. Schlägt eine Property fehl, gibt fast-check nicht nur den fehlgeschlagenen Fall aus, sondern auch den Seed, mit dem der Lauf exakt reproduziert werden kann.

4. Arbitraries: Testdaten gezielt generieren

Arbitraries sind das Herzstück von Property-Based Testing in fast-check. Sie beschreiben, aus welchem Wertebereich die Bibliothek Testdaten ziehen soll, und lassen sich beliebig kombinieren. Neben primitiven Generatoren wie fc.integer(), fc.string() oder fc.boolean() gibt es zusammengesetzte Arbitraries wie fc.record() für Objekte mit festen Feldern, fc.array() für Listen und fc.oneof() für Vereinigungen mehrerer möglicher Typen. Diese Kombinierbarkeit ist der Grund, warum sich mit fast-check auch komplexe Domänenmodelle als Testdaten abbilden lassen, nicht nur einfache Zahlen und Strings.

Ein oft unterschätztes Feature ist fc.constrainedArray() beziehungsweise die Constraints, die viele Arbitraries als zweites Argument akzeptieren, etwa fc.integer({ min: 0, max: 100 }) oder fc.array(fc.string(), { minLength: 1, maxLength: 20 }). Solche Constraints verhindern, dass Property-Based Testing völlig sinnlose Eingaben generiert, die außerhalb der Domäne der zu testenden Funktion liegen, ohne dabei die Bandbreite der Randfälle künstlich einzuschränken. Auch fc.string({ unit: 'grapheme' }) ist relevant, weil Standard-Strings in JavaScript sonst auch invalide UTF-16-Sequenzen erzeugen können, die für viele Anwendungsfälle irrelevant sind.


import fc from 'fast-check';

// Composite arbitrary matching a real domain model
const userArbitrary = fc.record({
  id: fc.uuid(),
  email: fc.emailAddress(),
  age: fc.integer({ min: 0, max: 130 }),
  tags: fc.array(fc.string({ minLength: 1, maxLength: 12 }), { maxLength: 5 }),
  role: fc.constantFrom('admin', 'editor', 'viewer'),
});

fc.assert(
  fc.property(userArbitrary, (user) => {
    const serialized = JSON.stringify(user);
    const parsed = JSON.parse(serialized);
    // Property: serializing then parsing must be lossless
    return parsed.id === user.id && parsed.email === user.email;
  })
);

// Filtering generated values that do not fit the domain
const evenNumberArbitrary = fc.integer().filter((n) => n % 2 === 0);

// Mapping generated values into a different shape
const priceArbitrary = fc.integer({ min: 0, max: 100000 }).map((cents) => cents / 100);

Der letzte Baustein ist fc.pre(), mit dem sich innerhalb einer Property Vorbedingungen formulieren lassen, ohne einen eigenen Arbitrary bauen zu müssen. Wird die Vorbedingung nicht erfüllt, überspringt fast-check den generierten Fall, statt einen Fehlschlag zu melden. Das ist nützlich, wenn ein Filter-Arbitrary zu ineffizient wäre, sollte aber sparsam eingesetzt werden, weil zu viele verworfene Fälle die Aussagekraft von Property-Based Testing schwächen.

5. Shrinking: Vom Zufallsfehler zum Minimalbeispiel

Ein zufällig generiertes Gegenbeispiel ist selten hilfreich, wenn es aus einem Array mit siebenundvierzig Elementen und tief verschachtelten Objekten besteht. Genau hier setzt Shrinking an, eine der wichtigsten Eigenschaften von Property-Based Testing überhaupt. Findet fast-check einen fehlschlagenden Fall, versucht die Bibliothek automatisch, diesen Fall schrittweise zu vereinfachen, kürzere Arrays, kleinere Zahlen, kürzere Strings, und prüft nach jeder Vereinfachung erneut, ob die Property immer noch fehlschlägt.

Dieser Prozess läuft so lange, bis kein einfacherer Fall mehr gefunden werden kann, der den Fehler noch reproduziert. Das Ergebnis ist ein minimales Gegenbeispiel, oft nur ein einzelnes Element oder eine einzige Zahl, das die eigentliche Fehlerursache viel klarer zeigt als der ursprüngliche, zufällige Fall. Ohne Shrinking wäre Property-Based Testing in der Praxis deutlich weniger nützlich, weil Entwickler viel Zeit damit verbringen würden, aus einem großen zufälligen Datensatz die relevante Teilmenge herauszufiltern.


import fc from 'fast-check';

// Intentionally buggy implementation to demonstrate shrinking output
function sumPositive(numbers) {
  return numbers.filter((n) => n > 0).reduce((a, b) => a + b, 1); // bug: seed should be 0
}

fc.assert(
  fc.property(fc.array(fc.integer({ min: -100, max: 100 })), (numbers) => {
    const expected = numbers.filter((n) => n > 0).reduce((a, b) => a + b, 0);
    return sumPositive(numbers) === expected;
  })
);

/*
Console output after shrinking:
Error: Property failed after 1 tests
{ seed: 1737200011832, path: "0:0", endOnFailure: true }
Counterexample: [[]]
Shrunk 3 time(s)
Got: sumPositive([]) === 1, expected 0

The shrunk counterexample is the empty array — far more useful than the
original 40-element random array fast-check found first.
*/

In der Praxis reproduziert man einen fehlgeschlagenen Property-Based Testing Lauf, indem man den ausgegebenen Seed über die Option { seed: 1737200011832, path: "0:0" } an fc.assert übergibt. So läuft exakt derselbe Fall erneut, ohne dass man auf einen neuen Zufallstreffer warten muss, ein entscheidender Vorteil gegenüber rein zufälligem, nicht reproduzierbarem Fuzzing.

6. Property-Patterns: Invarianten in echtem Code finden

Wer neu mit Property-Based Testing beginnt, tut sich oft schwer, überhaupt eine passende Eigenschaft zu formulieren. Ein bewährtes Muster ist die Invertierbarkeit, geeignet für Funktionspaare wie Kodierung und Dekodierung, Serialisierung und Parsing, Verschlüsselung und Entschlüsselung: decode(encode(x)) === x. Ein zweites Muster ist Idempotenz, bei der ein wiederholter Aufruf keinen zusätzlichen Effekt haben darf, etwa bei einer Normalisierungsfunktion: normalize(normalize(x)) === normalize(x).

Ein drittes Muster ist der Vergleich mit einer alternativen, meist langsameren, aber offensichtlich korrekten Implementierung, dem sogenannten Oracle. Bei einer optimierten Sortierfunktion kann man gegen die eingebaute Array.prototype.sort vergleichen. Ein viertes Muster sind Metamorphic Properties, bei denen nicht das exakte Ergebnis geprüft wird, sondern eine Beziehung zwischen zwei Aufrufen, etwa dass Filtern und dann Zählen dieselbe Anzahl liefert wie Zählen mit demselben Prädikat direkt.

Property-Based Testing zeigt seine Stärke besonders bei Zustandsmaschinen. Mit fc.commands() lassen sich Sequenzen von Operationen generieren, die gegen ein reales System und ein vereinfachtes Modell gleichzeitig ausgeführt werden, wodurch nicht nur einzelne Funktionen, sondern ganze zustandsbehaftete Abläufe wie ein Warenkorb oder ein Redux-Store auf Konsistenz geprüft werden können.

7. Integration mit Vitest und CI-Pipelines

fast-check läuft innerhalb jedes gängigen Test-Runners, weil fc.assert intern lediglich eine Exception wirft, wenn eine Property fehlschlägt, genau wie eine normale expect-Zusicherung. Das bedeutet: Property-Based Testing benötigt keine gesonderte Testinfrastruktur, keinen eigenen CI-Job und keine separate Reporting-Pipeline, sondern fügt sich nahtlos in bestehende Vitest- oder Jest-Suiten ein. Ein wichtiger Aspekt für CI ist die Laufzeit: Mit numRuns lässt sich die Anzahl der Durchläufe pro Property steuern, in der lokalen Entwicklung oft niedriger für schnelles Feedback, in der CI-Pipeline höher für maximale Abdeckung.

Ein zweiter wichtiger CI-Aspekt ist die Determinismus-Frage. Ohne festen Seed generiert fast-check bei jedem Lauf andere Zufallswerte, was gewollt ist, weil es die Wahrscheinlichkeit erhöht, über die Zeit unterschiedliche Randfälle zu treffen. Schlägt ein Property-Based Testing Lauf in CI fehl, wird der Seed in der Fehlermeldung ausgegeben und sollte in einem Bugfix-Commit als expliziter Regressionstest mit fixem Seed festgehalten werden, damit derselbe Fehler nie wieder unentdeckt bleibt.


// vitest.config.js — lower run count locally, higher in CI
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    env: {
      FC_NUM_RUNS: process.env.CI ? '500' : '50',
    },
  },
});

// test file reading the environment variable
import fc from 'fast-check';

const numRuns = Number(process.env.FC_NUM_RUNS ?? 100);

fc.assert(
  fc.property(fc.array(fc.integer()), (arr) => {
    return [...arr].sort((a, b) => a - b).length === arr.length;
  }),
  { numRuns }
);

// Regression test pinning a previously found counterexample
fc.assert(
  fc.property(fc.array(fc.integer()), (arr) => {
    return [...arr].sort((a, b) => a - b).length === arr.length;
  }),
  { seed: 1737200011832, path: '0:0', endOnFailure: true }
);

8. Grenzen: Wann Beispiel-Tests die bessere Wahl bleiben

Property-Based Testing ist kein universelles Werkzeug, das klassische Beispiel-Tests vollständig ablösen sollte. Für Funktionen ohne klare mathematische oder strukturelle Eigenschaft, etwa eine UI-Komponente, die spezifische Texte an genau definierten Stellen rendert, ist ein konkreter Beispiel-Test oft direkter verständlich und schneller geschrieben. Die Formulierung einer künstlichen Property nur um des Property-Based Testing willen führt zu Tests, die schwerer zu lesen sind als das, was sie ersetzen sollen.

Auch die Laufzeit ist ein relevanter Faktor: Eine Property mit hundert oder mehr Durchläufen dauert naturgemäß länger als ein einzelner Beispiel-Test. Bei sehr großen Testsuiten mit tausenden Properties kann sich das in der CI-Laufzeit deutlich bemerkbar machen, weshalb es sinnvoll ist, Property-Based Testing gezielt für Kernlogik mit hohem Risiko einzusetzen und nicht pauschal für jede Funktion im Projekt.

Ein dritter Punkt betrifft die Team-Akzeptanz. Property-Based Testing erfordert ein anderes Denken als klassisches Testen, und nicht jedes Teammitglied ist mit dem Konzept sofort vertraut. Ein pragmatischer Einstieg gelingt, indem man mit wenigen, klar erkennbaren Invarianten beginnt, etwa bei Parsern oder Serialisierungsfunktionen, statt Property-Based Testing sofort flächendeckend einzuführen.

9. Property-Based Testing im direkten Vergleich

Die folgende Tabelle stellt Property-Based Testing klassischem Beispiel-basiertem Testen gegenüber und zeigt, für welche Situationen sich welcher Ansatz eignet.

Kriterium Beispiel-Tests Property-Based Testing (fast-check)
Abdeckung des Eingaberaums 3 bis 5 von Hand gewählte Fälle 100 bis 1000+ automatisch generierte Fälle
Randfälle finden Nur wenn der Autor daran denkt Automatisch über den definierten Wertebereich
Fehlerdiagnose Direkt, weil Eingabe bekannt ist Direkt dank automatischem Shrinking
Lesbarkeit für Domänen-Details Sehr hoch, konkrete Werte sichtbar Erfordert Verständnis der Eigenschaft
Laufzeit pro Test Minimal Höher durch viele Durchläufe
Regressionsschutz für bekannten Bug Ideal, exakter Fall wird fixiert Möglich via fixem Seed, aber Beispiel-Test oft klarer

In der Praxis ergänzen sich beide Ansätze: Property-Based Testing deckt breit den Eingaberaum ab und findet unbekannte Randfälle, während gezielte Beispiel-Tests bekannte, bereits einmal aufgetretene Fehler dauerhaft absichern. Projekte mit ausgereifter Testkultur setzen häufig beide Techniken gemeinsam ein, statt sich für eine einzige Methode zu entscheiden.

Mironsoft

JavaScript-Teststrategien für Magento- und Hyvä-Projekte

Property-Based Testing für eure kritische Geschäftslogik?

Wir identifizieren Funktionen mit klaren Invarianten, führen fast-check gezielt in eure Vitest- oder Jest-Suiten ein und schulen euer Team im Formulieren belastbarer Property-Based Testing Eigenschaften.

Invarianten-Analyse

Kritische Funktionen identifizieren, die von Property-Based Testing am meisten profitieren

fast-check Einführung

Arbitraries, Shrinking und CI-Integration in bestehenden Testsuiten aufsetzen

Team-Schulung

Workshops zum Formulieren von Eigenschaften und Property-Patterns

10. Zusammenfassung

Property-Based Testing verändert die Denkweise beim Testen grundlegend: Statt einzelner Beispiele definiert man Eigenschaften, die für jede gültige Eingabe gelten müssen, und lässt fast-check automatisch hunderte oder tausende zufällige Testfälle generieren. Arbitraries beschreiben präzise, aus welchem Wertebereich Testdaten stammen sollen, von einfachen Ganzzahlen bis zu komplexen, zusammengesetzten Domänenmodellen. Shrinking reduziert jeden gefundenen Fehlerfall automatisch auf sein Minimalbeispiel und macht Fehlerdiagnose damit deutlich schneller als bei zufälligem, nicht reduziertem Fuzzing.

Property-Based Testing ersetzt klassische Beispiel-Tests nicht, sondern ergänzt sie gezielt dort, wo klare Invarianten wie Umkehrbarkeit, Idempotenz oder Konsistenz mit einem Oracle existieren. Serialisierung, Parser, Sortieralgorithmen und Zustandsmaschinen sind typische Kandidaten, bei denen sich der Einsatz besonders lohnt. Wer Property-Based Testing gezielt für Kernlogik mit hohem Risiko einsetzt und die Ergebnisse mit fixierten Regressionstests absichert, gewinnt eine Testabdeckung, die mit rein manuell geschriebenen Beispielen praktisch nicht erreichbar ist.

Property-Based Testing mit fast-check — Das Wichtigste auf einen Blick

Grundprinzip

Eigenschaften statt Beispiele: eine Regel, die für jede gültige Eingabe gelten muss, statt einzelner konkreter Werte.

Arbitraries

Generatoren wie fc.integer(), fc.record() und fc.array() erzeugen präzise kontrollierte, zufällige Testdaten.

Shrinking

Jeder gefundene Fehlerfall wird automatisch auf das kleinstmögliche Gegenbeispiel reduziert.

Einsatzgebiet

Serialisierung, Parser, Sortierung und Zustandsmaschinen mit klar erkennbaren Invarianten.

11. FAQ: Property-Based Testing in JavaScript

1Was ist Property-Based Testing?
Eigenschaften statt Beispiele definieren, dann automatisch viele zufällige Fälle gegen diese Regel prüfen lassen.
2Nur für TypeScript geeignet?
Nein, funktioniert vollständig mit JavaScript. TypeScript bringt zusätzlich vollständige Typinferenz für Arbitraries.
3Wie viele Durchläufe standardmäßig?
Einhundert pro Property, anpassbar über die Option numRuns, lokal niedriger, in CI höher.
4Was ist Shrinking?
Automatische Reduktion eines gefundenen Fehlerfalls auf das kleinstmögliche Gegenbeispiel, das den Fehler noch reproduziert.
5Ersetzt es klassische Unit-Tests?
Nein, es ergänzt sie. Regressionstests für bekannte Bugs bleiben wichtig, Property-Based Testing deckt zusätzlich den breiten Eingaberaum ab.
6Fehlgeschlagenen Lauf reproduzieren?
Den von fast-check ausgegebenen seed und path als Option an fc.assert übergeben, reproduziert exakt denselben Lauf.
7Wofür eignet es sich am besten?
Serialisierung, Parser, Sortierung, Zustandsmaschinen. Überall dort, wo Invertierbarkeit, Idempotenz oder Konsistenz mit einem Oracle gelten.
8Verlangsamt es die CI-Pipeline?
Bei vielen Properties mit hohem numRuns ja. Lokal niedrig halten, in CI erhöhen, gezielt für risikoreiche Kernlogik einsetzen.
9Was sind Arbitraries?
Generatoren, die den Wertebereich für Testdaten festlegen, kombinierbar mit map, filter und fc.record() für komplexe Domänenmodelle.
10Zustandsbehaftete Abläufe testbar?
Ja, über fc.commands(). Damit generierte Operationssequenzen laufen gegen reales System und Modell parallel, um Konsistenz zu prüfen.