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.
Inhaltsverzeichnis
- 1. Beispiel-Tests vs. Property-Based Tests: der Kern des Unterschieds
- 2. Wie eine Property-Based-Testing-Bibliothek arbeitet
- 3. Was eine gute Property ausmacht
- 4. Beispiel-Test: Rabattberechnung mit PHPUnit
- 5. Dieselbe Rabattberechnung als Property mit Eris
- 6. Wie Property-Based Testing automatisch Edge Cases findet
- 7. Shrinking: vom Fehlschlag zum minimalen Gegenbeispiel
- 8. JS/TS-Perspektive: fast-check und CI-Integration
- 9. Wo Property-Based Testing in die Teststrategie passt
- 10. Zusammenfassung
- 11. FAQ
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.