Property-Based Testing als Ergänzung zu Beispiel-Tests
PASS
expect()
Property-Based Testing · Eris · PHPUnit · Edge Cases
Property-Based Testing als Ergänzung zu Beispiel-Tests
Wie generative Tests Edge Cases finden, die niemand von Hand schreibt

Wer Rabatt- und Steuerberechnungen nur mit ein paar handverlesenen Beispielwerten testet, prüft lediglich die Fälle, die einem beim Schreiben eingefallen sind. Property-Based Testing lässt eine Bibliothek hunderte zufällige Eingaben erzeugen, eine allgemeine Eigenschaft wie niemals negativ prüfen, automatisch Randfälle finden und jeden Fehlschlag per Shrinking auf das kleinstmögliche Gegenbeispiel reduzieren.

13 Min. Lesezeit Eris · PHPUnit · fast-check Magento 2.4.8 · PHP 8.4 · Property-Based Testing

1. Beispiel-Tests vs. Property-Based Tests: der Kern des Unterschieds

Ein klassischer PHPUnit-Test für eine Preisberechnung wählt einen einzelnen konkreten Input, etwa 100 Euro mit 10 Prozent Rabatt, und prüft, dass exakt 90 Euro herauskommt. Das ist ein Beispiel-Test: er dokumentiert genau ein Ein-/Ausgabe-Paar und schlägt fehl, sobald sich das Verhalten für diesen einen Fall ändert. Seine Stärke liegt in der Lesbarkeit, jeder im Team versteht sofort, welches Verhalten für welchen Input erwartet wird, ohne die Implementierung lesen zu müssen.

Property-Based Tests verschieben die Frage von "was kommt bei diesem konkreten Input heraus" zu "welche Eigenschaft muss für jeden gültigen Input gelten". Statt 100 Euro und 10 Prozent hart zu codieren, beschreibt der Test eine Regel wie das Ergebnis ist niemals negativ oder das Ergebnis übersteigt niemals den Ursprungspreis. Eine Bibliothek wie eris/eris erzeugt dafür automatisch hunderte zufällige Preis- und Rabattkombinationen und prüft die Regel gegen jede einzelne.

Beide Testarten schließen sich nicht gegenseitig aus. Ein Beispiel-Test dokumentiert das erwartete Verhalten für den wichtigsten Geschäftsfall und dient als lesbare Spezifikation, während ein Property-Test die Lücken zwischen den handverlesenen Beispielen systematisch durchsucht. In einer gesunden Testsuite stehen beide nebeneinander, nicht als Konkurrenz, sondern als zwei unterschiedliche Werkzeuge für zwei unterschiedliche Fragestellungen.

2. Wie eine Property-Based-Testing-Bibliothek arbeitet

Eine Property-Based-Testing-Bibliothek wie Eris arbeitet nach einem festen Muster: Für jeden deklarierten Generator wird zunächst ein Wertebereich beschrieben, zum Beispiel eine Ganzzahl zwischen 0 und 1.000.000 für einen Preis in Cent oder ein Prozentsatz zwischen 0 und 100. Die Bibliothek erzeugt daraus bei jedem Testlauf standardmäßig 100 zufällige Kombinationen dieser Werte und führt die als then()-Callback übergebene Prüfung für jede einzelne Kombination aus.

Schlägt die Prüfung bei einer der generierten Kombinationen fehl, stoppt Eris sofort, markiert den Test als fehlgeschlagen und beginnt den Shrinking-Prozess, der in Abschnitt 7 im Detail beschrieben wird. Läuft der Test dagegen für alle generierten Werte erfolgreich durch, gilt die Property als bestätigt, allerdings nur für den durchsuchten Wertebereich, nicht als mathematischer Beweis für alle denkbaren Eingaben. Die Anzahl der Iterationen lässt sich über limitTo() erhöhen, wenn ein kritischer Berechnungspfad besonders gründlich abgesichert werden soll.

Die Einbindung in ein bestehendes Magento-Projekt ist unaufwändig: Eris baut auf PHPUnit auf und wird über Composer als Dev-Dependency installiert, Property-Tests laufen im selben vendor/bin/phpunit-Aufruf wie klassische Tests und lassen sich über die normale PHPUnit-Testsuite-Konfiguration in bestehende CI-Pipelines integrieren, ohne ein zweites Test-Framework parallel pflegen zu müssen.


{
  "require-dev": {
    "eris/eris": "^0.14.0",
    "phpunit/phpunit": "^10.5"
  }
}

3. Was eine gute Property ausmacht

Nicht jede Aussage über eine Funktion eignet sich als Property. Eine gute Property beschreibt eine Invariante, die für jeden gültigen Input gelten muss, unabhängig von den konkreten Werten. Typische Beispiele aus dem E-Commerce-Kontext: eine Preisberechnung liefert niemals einen negativen Wert, das Sortieren einer Produktliste ist idempotent, ein zweites Sortieren derselben Liste verändert die Reihenfolge nicht mehr, und das Verschlüsseln gefolgt vom Entschlüsseln eines Wertes liefert wieder exakt den Ursprungswert zurück, ein klassisches Encode/Decode-Rundtrip-Muster.

Ein weiteres nützliches Muster ist die Monotonie: Das Hinzufügen eines Artikels zu einem Warenkorb darf die Warenkorbsumme niemals verringern, unabhängig davon, welcher Artikel mit welchem Preis hinzugefügt wird. Solche Eigenschaften lassen sich oft aus der fachlichen Spezifikation direkt ableiten, ohne die konkrete Implementierung zu kennen, was Property-Tests widerstandsfähiger gegen Refactorings macht als Beispiel-Tests, die häufig unbewusst Implementierungsdetails mittesten.

Schwierig wird es bei Funktionen, deren korrektes Ergebnis sich nicht einfach als Regel formulieren lässt, etwa ein komplexer Ranking-Algorithmus für Suchergebnisse. Hier hilft oft ein Vergleich gegen eine bewusst langsame, aber offensichtlich korrekte Referenzimplementierung, ein sogenanntes Model-Based Testing, oder man beschränkt sich auf schwächere, aber trotzdem wertvolle Eigenschaften wie "die Anzahl der Ergebnisse ändert sich durch Sortieren nicht".

4. Beispiel-Test: Rabattberechnung mit PHPUnit

Als durchgängiges Beispiel dient eine DiscountCalculator-Klasse, wie sie in einem Magento-Projekt für Rabattaktionen im Checkout zum Einsatz kommen könnte. Die Methode applyDiscount(float $price, float $discountPercent): float nimmt einen Bruttopreis und einen Rabattprozentsatz entgegen und liefert den reduzierten Preis zurück. Ein klassischer PHPUnit-Test prüft dafür einige handverlesene Fälle: 10 Prozent auf 100 Euro ergibt 90 Euro, 0 Prozent lässt den Preis unverändert, 100 Prozent reduziert ihn auf 0.

Diese drei Fälle decken die offensichtlichen Szenarien ab und dokumentieren gleichzeitig das erwartete Verhalten für neue Teammitglieder. Was sie nicht abdecken, ist der gesamte Raum möglicher Kombinationen: Was passiert bei einem Rabatt von 150 Prozent, bei einem negativen Preis durch einen fehlerhaften vorherigen Berechnungsschritt, oder bei einem Preis mit vielen Nachkommastellen durch eine Währungsumrechnung? Diese Lücke schließt der Property-Test im nächsten Abschnitt.


<?php
declare(strict_types=1);

namespace Mironsoft\Pricing\Test\Unit\Model;

use Mironsoft\Pricing\Model\DiscountCalculator;
use PHPUnit\Framework\TestCase;

/**
 * Example-based test: fixed, hand-picked inputs and expected outputs.
 */
final class DiscountCalculatorTest extends TestCase
{
    private DiscountCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new DiscountCalculator();
    }

    public function testTenPercentDiscountOnHundredEuros(): void
    {
        // Single hand-picked input/output pair
        $result = $this->calculator->applyDiscount(100.0, 10.0);

        $this->assertSame(90.0, $result);
    }

    public function testZeroPercentDiscountReturnsOriginalPrice(): void
    {
        $result = $this->calculator->applyDiscount(49.99, 0.0);

        $this->assertSame(49.99, $result);
    }

    public function testHundredPercentDiscountReturnsZero(): void
    {
        $result = $this->calculator->applyDiscount(75.0, 100.0);

        $this->assertSame(0.0, $result);
    }
}

5. Dieselbe Rabattberechnung als Property mit Eris

Der Property-Test für dieselbe DiscountCalculator-Klasse formuliert keine konkreten Zahlen mehr, sondern die Invariante direkt: Das Ergebnis von applyDiscount() ist niemals kleiner als 0 und niemals größer als der Ursprungspreis. Über Generator\choose() definiert der Test den Wertebereich für Preis und Rabattprozentsatz, Eris übernimmt die eigentliche Erzeugung der Testdaten und führt die Prüfung standardmäßig 100 Mal mit unterschiedlichen, zufälligen Kombinationen aus.

Der entscheidende Unterschied zum Beispiel-Test zeigt sich in der Praxis fast immer an derselben Stelle: Sobald der Generator einen Rabattprozentsatz über 100 erzeugt, etwa 104 Prozent, liefert die ungeprüfte Implementierung einen negativen Preis zurück, ein Fehler, den drei handverlesene Beispiele nie gefunden hätten, weil niemand auf die Idee kommt, testweise einen Rabatt über 100 Prozent einzugeben. Genau diese Klasse von Fehlern, plausible aber ungetestete Wertebereiche, ist die eigentliche Stärke von Property-Based Testing.


<?php
declare(strict_types=1);

namespace Mironsoft\Pricing\Test\Unit\Model;

use Eris\Generator;
use Eris\TestTrait;
use Mironsoft\Pricing\Model\DiscountCalculator;
use PHPUnit\Framework\TestCase;

/**
 * Property-based test: the same discount logic checked as a general invariant.
 */
final class DiscountCalculatorPropertyTest extends TestCase
{
    use TestTrait;

    private DiscountCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new DiscountCalculator();
    }

    public function testDiscountNeverProducesANegativeResult(): void
    {
        $this
            ->forAll(
                Generator\choose(0, 1000000),   // price in cents, wide range
                Generator\choose(0, 100)        // discount percent
            )
            ->then(function (int $priceCents, int $discountPercent): void {
                $result = $this->calculator->applyDiscount(
                    $priceCents / 100,
                    (float) $discountPercent
                );

                $this->assertGreaterThanOrEqual(0.0, $result);
                $this->assertLessThanOrEqual($priceCents / 100, $result);
            });
    }
}

6. Wie Property-Based Testing automatisch Edge Cases findet

Generatoren in Eris und vergleichbaren Bibliotheken sind bewusst so konstruiert, dass sie nicht gleichverteilt zufällige Werte liefern, sondern gezielt Grenzfälle bevorzugt aufsuchen: 0, negative Zahlen, sehr große Zahlen nahe der Integer-Grenze, leere Arrays und Strings sowie Unicode-Zeichen inklusive mehrbyteiger Zeichen und Emojis. Für eine Warenkorbberechnung bedeutet das, dass der Generator automatisch einen leeren Warenkorb, einen Artikel mit Preis 0, eine Menge von minus 1 und einen Produktnamen mit japanischen Schriftzeichen erzeugt, ganz ohne dass ein Entwickler diese Fälle explizit aufschreiben musste.

Diese automatische Grenzfallsuche trifft genau die Stelle, an der handgeschriebene Beispiel-Tests systematisch blinde Flecken haben: Entwickler testen typischerweise die Fälle, die ihnen beim Schreiben des Codes präsent waren, nicht die Fälle, die sie beim Schreiben vergessen haben. Ein Property-Test für die Rabattberechnung findet zuverlässig, dass ein Preis von genau 0,005 Euro durch Rundung auf einen negativen Cent-Betrag fallen kann, ein Randfall, der bei der Implementierung schlicht nicht bedacht wurde und in keinem manuell geschriebenen Testfall auftauchen würde.

Wichtig ist dabei, dass diese Grenzfälle nicht rein zufällig gefunden werden, sondern die Generatoren bekannte Problemzonen wie Nullwerte, Vorzeichenwechsel und Typumwandlungsgrenzen statistisch überproportional häufig erzeugen. Das erhöht die Trefferquote gegenüber echtem Zufall erheblich und macht bereits 100 Iterationen pro Property in der Praxis überraschend schlagkräftig.

7. Shrinking: vom Fehlschlag zum minimalen Gegenbeispiel

Findet Eris eine fehlschlagende Kombination, meldet die Bibliothek nicht einfach die ursprünglich generierten, oft unhandlichen Zufallswerte, etwa einen Preis von 5834,21 Euro mit 104 Prozent Rabatt. Stattdessen startet automatisch ein Shrinking-Prozess: Eris probiert systematisch kleinere, einfachere Varianten des fehlschlagenden Inputs aus, prüft, ob die Property auch dafür noch fehlschlägt, und wiederholt das Verfahren, bis kein kleinerer fehlschlagender Wert mehr gefunden wird.

Am Ende bleibt ein minimales Gegenbeispiel übrig, im Beispiel aus Abschnitt 5 reduziert sich der Fehlschlag auf einen Preis von einem Cent mit 101 Prozent Rabatt. Diese Reduktion ist kein kosmetisches Detail, sie ist der eigentliche praktische Nutzen von Property-Based Testing: Ein Entwickler, der einen fünfstelligen zufälligen Fehlschlag debuggen müsste, verliert Zeit mit der Frage, welche Zahlen überhaupt relevant sind. Ein minimales Gegenbeispiel mit einem Cent und 101 Prozent zeigt den Bug sofort und eindeutig, die Rabattgrenze wurde nicht auf 100 Prozent begrenzt.

In der Terminal-Ausgabe protokolliert Eris jeden Shrinking-Schritt, sodass nachvollziehbar bleibt, wie die Bibliothek vom ursprünglichen Zufallswert zum minimalen Fall gelangt ist. Das schafft Vertrauen in das reduzierte Beispiel und erspart die manuelle Nachprüfung, ob der vereinfachte Fall wirklich denselben Fehler auslöst wie der ursprüngliche.


$ vendor/bin/phpunit --filter testDiscountNeverProducesANegativeResult

1) Mironsoft\Pricing\Test\Unit\Model\DiscountCalculatorPropertyTest::testDiscountNeverProducesANegativeResult
Failed asserting that -0.01 is greater than or equal to 0.

Eris\Listener\ExceptionListener:
  Falsifiable after 47 tries, shrinking...
  Original counterexample: [priceCents => 583421, discountPercent => 104]
  Shrinking step 1: [priceCents => 100, discountPercent => 104]
  Shrinking step 2: [priceCents => 1, discountPercent => 101]
  Minimal counterexample: [priceCents => 1, discountPercent => 101]

  The library reduced a six-digit random failure to a single-cent
  price with a 101 percent discount, revealing the actual bug:
  discountPercent was never capped at 100.

8. JS/TS-Perspektive: fast-check und CI-Integration

Da Cypress- und Playwright-Projekte durchgehend in JavaScript oder TypeScript geschrieben sind, lohnt sich für reine Business-Logik auf der Frontend-Seite, etwa eine clientseitige Preisvorschau oder eine Formularvalidierung, ein Blick auf fast-check, das PHP-Pendant zu Eris für das JS/TS-Ökosystem. Die Grundidee ist identisch: fc.property() beschreibt eine Invariante, Generatoren wie fc.float() oder fc.integer() erzeugen die Testdaten, und fc.assert() führt die Prüfung standardmäßig 100 Mal aus, inklusive automatischem Shrinking bei Fehlschlägen.

Für ein Magento-Projekt mit Hyvä-Theme bedeutet das in der Praxis: Server-seitige Preis-, Steuer- und Rabattlogik in PHP wird mit Eris abgesichert, während clientseitige Berechnungen, etwa eine Live-Vorschau der Versandkosten in Alpine.js, mit fast-check in Jest oder Vitest getestet werden können, unabhängig von den Cypress-E2E-Tests, die den kompletten Checkout-Flow im Browser prüfen. Beide Bibliotheken lassen sich in bestehende CI-Pipelines integrieren, ohne dass ein separates Test-Runner-Setup nötig wird.


// test/discount.property.test.ts
import fc from 'fast-check';
import { applyDiscount } from '../src/discount';

describe('applyDiscount (property-based)', () => {
  it('never returns a negative result', () => {
    fc.assert(
      fc.property(
        fc.float({ min: 0, max: 1_000_000, noNaN: true }),
        fc.integer({ min: 0, max: 100 }),
        (price, discountPercent) => {
          const result = applyDiscount(price, discountPercent);
          return result >= 0 && result <= price;
        }
      ),
      { numRuns: 500 }
    );
  });
});

9. Wo Property-Based Testing in die Teststrategie passt

Property-Based Testing ist kein Ersatz für E2E-Tests mit Cypress oder Playwright, sondern eine gezielte Ergänzung für einen bestimmten Ausschnitt der Testpyramide: pure, deterministische Business-Logik ohne Seiteneffekte, wie Preis-, Steuer- und Rabattberechnungen, Sortier- und Filterfunktionen oder Serialisierung. E2E-Tests validieren, dass ein echter Nutzer im Browser tatsächlich einen Artikel in den Warenkorb legen und zur Kasse gehen kann, inklusive UI-Zustand, Netzwerkaufrufen und Third-Party-Integrationen, Fragen, die eine Property niemals beantworten kann, weil sie keinen Browser startet.

Die sinnvolle Kombination sieht in der Praxis so aus: Ein einzelner Cypress-Test bestätigt den End-to-End-Checkout-Flow für einen realistischen Warenkorb, während Dutzende Property-Test-Iterationen im Hintergrund die zugrunde liegende Rabattberechnung gegen den gesamten Eingaberaum absichern. Diese Arbeitsteilung nutzt die Stärken beider Ansätze: schnelle, gründliche Absicherung der Berechnungslogik auf Unit-Ebene und realistische Validierung des Nutzerflusses auf E2E-Ebene, ohne dass eine der beiden Testarten die Aufgaben der anderen übernehmen müsste.

Kriterium Beispiel-Test Property-Based Test Einordnung
Edge-Case-Abdeckung Nur von Entwicklern erdachte Fälle Automatisch generierte Zufalls- und Grenzfälle Property-Test findet vergessene Randfälle
Lesbarkeit / Dokumentationswert Konkrete Zahlen, sofort verständlich Erfordert Verständnis der Invariante Beispiel-Test als lesbare Spezifikation nutzen
Ausführungsgeschwindigkeit Wenige Millisekunden pro Test Hunderte Iterationen, spürbar langsamer Property-Tests gezielt für kritische Logik einsetzen
Regressionsschutz für UI-Flows Deckt konkrete User-Journey ab Kein Ersatz für echte Browser-Interaktion E2E-Tests bleiben für UI-Flows zuständig
Eignung für pure Business-Logik Deckt nur die getesteten Kombinationen ab Prüft die Invariante über den gesamten Eingaberaum Ideal für Preis-, Steuer- und Rabattlogik

Mironsoft

Property-Based Testing und robuste Testsuiten für Magento- und Hyvä-Shops

Vertraut eure Preis- und Rabattlogik wirklich jedem Eingabewert?

Wir bauen Property-Based Tests mit Eris für eure PHP-Businesslogik, ergänzen bestehende PHPUnit- und Cypress-Suiten gezielt und finden Randfälle in Preis-, Steuer- und Rabattberechnungen, bevor eure Kunden es tun.

Testsuite-Audit

Bestehende PHPUnit-Tests auf Lücken für Property-Based Testing prüfen

Eris-Einführung

Eris für PHPUnit einrichten und erste Properties für Preislogik schreiben

CI-Integration

Property-Tests sauber in bestehende PHPUnit- und CI-Pipelines einbinden

10. Zusammenfassung

Property-Based Testing ergänzt klassische Beispiel-Tests, indem es die Frage von konkreten Ein-/Ausgabe-Paaren zu allgemeingültigen Invarianten verschiebt. Statt einzelner handverlesener Werte generiert eine Bibliothek wie eris/eris für PHP oder fast-check für JavaScript automatisch hunderte zufällige Eingaben und prüft, ob eine Regel wie das Ergebnis ist niemals negativ für jede davon gilt. Gute Properties beschreiben Invarianten wie Nichtnegativität, Idempotenz von Sortierfunktionen oder Encode/Decode-Rundtrips, formuliert aus der fachlichen Spezifikation statt aus der konkreten Implementierung.

Der praktische Mehrwert zeigt sich in zwei Aspekten: automatisch gefundene Edge Cases wie Nullwerte, extreme Zahlen oder Unicode-Strings, die in handgeschriebenen Tests regelmäßig fehlen, und Shrinking, das jeden Fehlschlag automatisch auf ein minimales, sofort verständliches Gegenbeispiel reduziert. Property-Based Testing ersetzt dabei weder Unit- noch E2E-Tests, sondern ergänzt beide gezielt für pure Business-Logik wie Preis-, Steuer- und Rabattberechnungen, während echte Nutzerflüsse weiterhin Aufgabe von Cypress- oder Playwright-Tests bleiben.

Property-Based Testing, Das Wichtigste auf einen Blick

Beispiel- vs. Property-Test

Beispiel-Tests prüfen konkrete Ein-/Ausgabe-Paare, Property-Tests prüfen allgemeingültige Invarianten über generierte Zufallseingaben.

Gute Properties

Nichtnegativität, Idempotenz, Encode/Decode-Rundtrips und Monotonie lassen sich direkt aus der Fachspezifikation ableiten.

Shrinking

Eris reduziert jeden Fehlschlag automatisch auf das kleinstmögliche Gegenbeispiel, ganz ohne manuelles Debugging.

Einordnung

Ergänzung für pure Business-Logik wie Preisberechnungen, kein Ersatz für E2E-Tests mit Cypress oder Playwright.

11. FAQ: Property-Based Testing

1Was ist der Unterschied zwischen Beispiel-Tests und Property-Based Tests?
Ein Beispiel-Test prüft ein einzelnes, hart codiertes Ein-/Ausgabe-Paar. Ein Property-Based Test prüft eine allgemeingültige Eigenschaft gegen hunderte automatisch generierte, zufällige Eingaben.
2Was macht eine gute Property für einen Test aus?
Eine Invariante, die für jeden gültigen Input gilt, unabhängig von konkreten Werten, etwa Nichtnegativität, Idempotenz oder ein Encode/Decode-Rundtrip.
3Welche PHP-Bibliothek eignet sich für Property-Based Testing?
Eris (eris/eris) baut auf PHPUnit auf und läuft im selben vendor/bin/phpunit-Aufruf wie klassische Tests, installierbar per Composer als Dev-Dependency.
4Wie viele zufällige Testfälle generiert Eris pro Property?
Standardmäßig 100 Iterationen pro forAll()-Property, erhöhbar über limitTo() für besonders kritische Berechnungspfade.
5Was ist Shrinking und warum ist es wichtig?
Automatische Reduktion eines Fehlschlags auf den kleinstmöglichen reproduzierbaren Fall, statt eines unhandlichen zufälligen Ausgangswerts.
6Welche Edge Cases findet Property-Based Testing automatisch?
0, negative Zahlen, sehr große Zahlen, leere Arrays und Strings sowie Unicode-Zeichen, Fälle, die beim manuellen Testen häufig übersehen werden.
7Ersetzt Property-Based Testing E2E-Tests mit Cypress oder Playwright?
Nein, es prüft pure Business-Logik ohne Browser. E2E-Tests bleiben notwendig für echte Nutzerflüsse und UI-Integrationen.
8Kann ich Property-Based Testing auch in JavaScript oder TypeScript nutzen?
Ja, fast-check ist das JS/TS-Pendant zu Eris und funktioniert mit Jest, Vitest oder Mocha, inklusive automatischem Shrinking.
9Für welche Art von Code eignet sich Property-Based Testing am besten?
Pure, deterministische Funktionen ohne Seiteneffekte, etwa Preis-, Steuer- und Rabattberechnungen oder Sortierfunktionen mit klar formulierbaren Invarianten.
10Wie integriere ich Property-Based Tests in die CI-Pipeline?
Eris-Tests laufen im selben PHPUnit-Aufruf wie klassische Tests, ohne separate CI-Konfiguration, ein eigener Job mit höherem Timeout lohnt sich bei vielen Iterationen.