für PHP-Projekte mit PHPUnit und Eris
Beispielbasierte Tests prüfen nur die Fälle, die dem Entwickler eingefallen sind. Property-based Testing dreht den Ansatz um: statt konkreter Eingaben formuliert man Invarianten – Eigenschaften, die für jede denkbare Eingabe gelten müssen – und lässt die Bibliothek Tausende zufälliger Gegenbeispiele suchen.
Inhaltsverzeichnis
- 1. Was Property-based Testing von Beispieltests unterscheidet
- 2. Invarianten formulieren: das Herz des Ansatzes
- 3. Eris: Property-based Testing für PHP
- 4. Generatoren: strukturierte Zufallsdaten erzeugen
- 5. Shrinking: minimale Gegenbeispiele automatisch finden
- 6. Praxisbeispiele: Preisberechnung und Validierung
- 7. Property-based vs. Example-based: Direktvergleich
- 8. Zusammenfassung
- 9. FAQ
1. Was Property-based Testing von Beispieltests unterscheidet
Ein klassischer PHPUnit-Test legt konkrete Eingabewerte fest und prüft konkrete Ausgaben: assertSame(119.00, $calculator->calculateGross(100.00)). Das ist ein Beispieltest – er prüft einen spezifischen Fall und gibt kein Urteil über alle anderen Eingaben. Property-based Testing formuliert stattdessen eine Eigenschaft (Property), die für alle möglichen Eingaben gelten muss: "Die Bruttoberechnung muss für jeden nicht-negativen Betrag einen Wert zurückgeben, der größer oder gleich dem Nettobetrag ist." Diese Eigenschaft wird dann nicht für einen, sondern für Tausende automatisch generierter Eingaben geprüft.
Der Unterschied im Entdeckungspotenzial ist erheblich. Beispieltests finden Fehler für die Eingaben, die dem Entwickler beim Schreiben eingefallen sind. Property-based Tests finden Fehler für Eingaben, die niemand erwartet hätte: negative Nullwerte, sehr große Zahlen, leere Strings, Unicode-Sonderzeichen, Grenzwerte bei Integer-Überlauf. Diese Klasse von Fehlern – edge cases, die erst im Produktionsbetrieb durch echte Nutzer auftreten – wird durch Property-based Testing systematisch abgedeckt, bevor die Software in Produktion geht.
2. Invarianten formulieren: das Herz des Ansatzes
Die schwierigste Aufgabe beim Property-based Testing ist nicht die technische Integration, sondern das Formulieren guter Invarianten. Eine Invariante ist eine Eigenschaft, die für alle Eingaben aus einem definierten Eingaberaum gelten muss. Gute Invarianten beschreiben das Verhalten auf abstrakter Ebene, ohne konkrete Ausgaben vorherzusagen. Schlechte Invarianten sind so allgemein, dass sie immer wahr sind ("die Funktion gibt etwas zurück"), oder so spezifisch, dass sie nur die konkreten Beispiele reproduzieren.
Bewährte Muster für Invarianten: Symmetrie (encode und decode heben sich auf), Idempotenz (mehrfaches Anwenden gibt dasselbe Ergebnis wie einmaliges Anwenden), Monotonieerhaltung (wenn die Eingabe wächst, wächst auch die Ausgabe), Identität (Nulloperation lässt den Wert unverändert), Äquivalenz (zwei verschiedene Implementierungen geben dasselbe Ergebnis). Diese fünf Muster decken den Großteil der sinnvoll formulierbaren Invarianten für Geschäftslogik ab.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\Model;
use Eris\Generator;
use Eris\TestTrait;
use Mironsoft\Catalog\Model\PriceCalculator;
use PHPUnit\Framework\TestCase;
/**
* Property-based tests for PriceCalculator.
* Uses Eris library for generator-driven test input.
*
* Install: composer require --dev giorgiosironi/eris
*/
final class PriceCalculatorPropertyTest extends TestCase
{
use TestTrait;
private PriceCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new PriceCalculator(taxRate: 0.19);
}
/**
* Invariant: Gross price is always >= net price for non-negative inputs.
* Tested for 100 random float values in [0.0, 10000.0].
*/
public function testGrossIsAlwaysAtLeastNet(): void
{
$this->forAll(
Generator\float()->between(0.0, 10000.0)
)->then(function (float $net): void {
$gross = $this->calculator->calculateGross(net: $net);
self::assertGreaterThanOrEqual(
$net,
$gross,
"Gross {$gross} must be >= net {$net}"
);
});
}
/**
* Invariant: Applying tax and removing it returns the original value (within rounding).
* Symmetry property: decode(encode(x)) ≈ x
*/
public function testNetToGrossToNetIsApproximatelyIdentity(): void
{
$this->forAll(
Generator\float()->between(0.01, 9999.99)
)->then(function (float $net): void {
$gross = $this->calculator->calculateGross(net: $net);
$netAgain = $this->calculator->calculateNet(gross: $gross);
self::assertEqualsWithDelta(
$net,
$netAgain,
0.005,
"Round-trip failed for net={$net}"
);
});
}
}
3. Eris: Property-based Testing für PHP
Eris (von Giorgio Sironi) ist die bekannteste Property-based Testing-Bibliothek für PHP. Sie integriert sich direkt in PHPUnit über ein Trait (TestTrait) und bietet eine umfangreiche Sammlung von Generatoren für primitive Typen, Arrays, Strings und benutzerdefinierte Objekte. Die Installation erfolgt über Composer: composer require --dev giorgiosironi/eris. Alternativ gibt es Eqentive als modernere Alternative mit nativer PHP 8-Unterstützung und stärker typisierten Generatoren.
Die Kernmethode von Eris ist forAll(Generator)->then(callable). forAll nimmt einen oder mehrere Generatoren entgegen und then erhält die generierten Werte als Argumente. Standardmäßig führt Eris die Property 100 mal mit unterschiedlichen Zufallswerten aus; diese Zahl kann über $this->limitTo(500) angepasst werden. Bei einem Fehler sucht Eris automatisch nach dem minimalen Eingabewert, der den Fehler reproduziert (Shrinking). Der Seed für den Zufallsgenerator wird bei Fehlern ausgegeben, sodass fehlgeschlagene Tests exakt reproduziert werden können: $this->withSeed(12345)->forAll(...).
4. Generatoren: strukturierte Zufallsdaten erzeugen
Generatoren sind die Bausteine von Property-based Tests. Sie definieren den Eingaberaum, aus dem die Bibliothek zufällige Werte erzeugt. Eris bietet Generatoren für alle PHP-Primitivtypen und kombinierbare Bausteine für komplexe Strukturen. Der Generator Generator\int() erzeugt beliebige Integer; Generator\int()->between(1, 100) schränkt den Bereich ein. Generator\string() erzeugt Unicode-Strings, einschließlich Leerzeichen, Sonderzeichen und Steuerzeichen – genau die Eingaben, die bei manuellen Tests typischerweise vergessen werden.
Für domänenspezifische Daten kombiniert man Generatoren: Generator\map(Generator\int()->between(1, 1000), fn($i) => "SKU-{$i}") erzeugt gültige SKU-Strings. Generator\tuple(Generator\int(), Generator\string()) erzeugt Paare. Generator\vector(10, Generator\float()) erzeugt Arrays mit genau 10 Float-Werten. Generator\elements(['a', 'b', 'c']) wählt zufällig aus einer festen Menge – nützlich für Enum-ähnliche Eingaben oder Statuswerte.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\Model;
use Eris\Generator;
use Eris\TestTrait;
use Mironsoft\Catalog\Model\SkuValidator;
use PHPUnit\Framework\TestCase;
/**
* Property-based tests using composed generators.
* Demonstrates: map, elements, tuple generators.
*/
final class SkuValidatorPropertyTest extends TestCase
{
use TestTrait;
private SkuValidator $validator;
protected function setUp(): void
{
$this->validator = new SkuValidator(maxLength: 64, allowedPattern: '/^[A-Z0-9\-]+$/');
}
/**
* Invariant: Any valid SKU must always pass validation.
* Generator produces structurally valid SKUs — tests that validator accepts them.
*/
public function testValidSkuAlwaysPassesValidation(): void
{
// Compose a generator for valid SKUs: uppercase letters, digits, hyphens
$skuGenerator = Generator\map(
Generator\tuple(
Generator\elements(['MRN', 'CAT', 'PROD', 'SKU']),
Generator\int()->between(100, 99999)
),
fn(array $parts): string => "{$parts[0]}-{$parts[1]}"
);
$this->forAll($skuGenerator)
->then(function (string $sku): void {
self::assertTrue(
$this->validator->isValid($sku),
"Expected valid SKU '{$sku}' to pass validation"
);
});
}
/**
* Invariant: SKUs exceeding max length must always fail validation.
* Generator: strings longer than maxLength.
*/
public function testTooLongSkuAlwaysFailsValidation(): void
{
$longSkuGenerator = Generator\map(
Generator\int()->between(65, 200),
fn(int $len): string => str_repeat('A', $len)
);
$this->forAll($longSkuGenerator)
->then(function (string $sku): void {
self::assertFalse(
$this->validator->isValid($sku),
"Expected too-long SKU to fail validation"
);
});
}
}
5. Shrinking: minimale Gegenbeispiele automatisch finden
Shrinking ist eine der wertvollsten Funktionen von Property-based Testing-Bibliotheken. Wenn ein zufällig generierter Wert eine Property verletzt, sucht die Bibliothek automatisch nach einem kleineren, einfacheren Wert, der denselben Fehler auslöst. Aus einem initialen Gegenbeispiel wie dem String "aXbYcZ123!@#" kann Shrinking das minimale Gegenbeispiel "X" extrahieren – sofern "X" denselben Fehler auslöst. Das macht Debugging erheblich einfacher, weil der Entwickler sofort den kleinsten reproduzierbaren Fehlerfall sieht, statt ein komplexes generiertes Beispiel manuell analysieren zu müssen.
Shrinking funktioniert für alle eingebauten Generatoren von Eris automatisch. Für benutzerdefinierte Generatoren muss eine Shrinking-Strategie implementiert werden – oder man nutzt zusammengesetzte Generatoren aus eingebauten Bausteinen, die bereits Shrinking-Strategien mitbringen. Ein häufiger Fehler: Property-Tests schreiben, die bei zu weitem Eingaberaum nie sinnvoll Shrinking betreiben können, weil der Generator keine strukturellen Beziehungen zwischen den generierten Werten kennt. Gute Generatoren bilden die Domäne des getesteten Codes ab – nicht die vollständige Menge aller möglichen Strings oder Integer.
6. Praxisbeispiele: Preisberechnung und Validierung
Property-based Testing entfaltet seinen größten Nutzen bei Algorithmen mit klaren mathematischen Eigenschaften: Preisberechnungen (Monotonie, Symmetrie bei Hin- und Rückrechnung), Sortierfunktionen (Idempotenz, Längenerhaltung, Elemente bleiben erhalten), Serialisierung (encode/decode sind invers), Validierung (strukturell gültige Eingaben bestehen, strukturell ungültige scheitern) und Konvertierungslogik (Einheitenkonvertierungen, Formatierungen). Diese Kategorien decken einen großen Teil der Geschäftslogik in typischen PHP-Projekten ab.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\Model;
use Eris\Generator;
use Eris\TestTrait;
use Mironsoft\Catalog\Model\CartCalculator;
use PHPUnit\Framework\TestCase;
/**
* Property-based tests for CartCalculator.
* Tests mathematical invariants of cart total calculation.
*/
final class CartCalculatorPropertyTest extends TestCase
{
use TestTrait;
private CartCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new CartCalculator();
}
/**
* Invariant: Total of multiple items >= total of any single item.
* Monotonicity: adding more items cannot decrease the total.
*/
public function testTotalIsMonotonicallyIncreasing(): void
{
$priceGenerator = Generator\float()->between(0.01, 999.99);
$itemCountGenerator = Generator\int()->between(1, 10);
$this->forAll($priceGenerator, $itemCountGenerator)
->then(function (float $unitPrice, int $quantity): void {
$singleTotal = $this->calculator->total([$unitPrice]);
$multipleTotal = $this->calculator->total(array_fill(0, $quantity, $unitPrice));
self::assertGreaterThanOrEqual(
$singleTotal,
$multipleTotal,
"Total for {$quantity} items must be >= total for 1 item"
);
});
}
/**
* Invariant: Sum is commutative — order of items does not change total.
* Symmetry property for unordered collections.
*/
public function testTotalIsIndependentOfItemOrder(): void
{
$priceListGenerator = Generator\vector(
5,
Generator\float()->between(0.01, 999.99)
);
$this->forAll($priceListGenerator)
->then(function (array $prices): void {
$original = $this->calculator->total($prices);
shuffle($prices);
$shuffled = $this->calculator->total($prices);
self::assertEqualsWithDelta(
$original,
$shuffled,
0.001,
'Cart total must be independent of item order'
);
});
}
}
7. Property-based vs. Example-based: Direktvergleich
Beide Teststile ergänzen sich. Property-based Testing ersetzt Beispieltests nicht – es erweitert sie um eine andere Erkenntnisdimension. Die Tabelle zeigt, wann welcher Ansatz der bessere ist.
| Kriterium | Example-based Testing | Property-based Testing |
|---|---|---|
| Eingabeabdeckung | Nur explizit gewählte Beispiele | Tausende zufälliger Eingaben |
| Edge-case-Entdeckung | Nur bekannte Edge Cases | Automatisch, auch unbekannte |
| Testlesbarkeit | Sehr hoch – konkrete Werte | Mittel – abstrakte Invarianten |
| Lernaufwand | Gering | Mittel (Invarianten formulieren) |
| Bestgeeignet für | Bekannte Geschäftsregeln, Regression | Algorithmen, Konvertierung, Validierung |
| Fehlerdiagnose | Direkt nachvollziehbar | Mit Shrinking: minimal reproduzierbarer Fall |
Die empfohlene Strategie: Beginne jede neue Klasse mit Beispieltests für die wichtigsten bekannten Szenarien. Wenn die Klasse Algorithmen oder Konvertierungslogik enthält, ergänze Property-based Tests für die identifizierten Invarianten. Setze den Property-based Test nicht als Ersatz für Beispieltests ein, sondern als ergänzenden Layer, der systematisch den Eingaberaum absucht. Teams, die Property-based Testing einführen, berichten häufig, dass der Prozess des Formulierens von Invarianten bereits wertvolle Erkenntnisse über das gewünschte Verhalten des Codes liefert – noch bevor ein Test ausgeführt wurde.
Mironsoft
Property-based Testing, Testarchitektur und Qualitätssicherung für PHP-Teams
Property-based Testing in euer PHP-Projekt einführen?
Wir identifizieren geeignete Komponenten in eurem Produktionscode, formulieren Invarianten für eure Geschäftslogik und integrieren Eris in eure PHPUnit-Suite – mit Workshop für das gesamte Entwicklungsteam.
Code-Analyse
Geeignete Algorithmen und Validierungslogik für Property-based Tests identifizieren
Invarianten-Workshop
Team-Workshop zum Formulieren von Properties für bestehende Geschäftslogik
Eris-Integration
Eris in PHPUnit-Suite integrieren, benutzerdefinierte Generatoren implementieren
8. Zusammenfassung
Property-based Testing erweitert das PHPUnit-Toolkit um eine fundamentale Dimension: statt konkrete Beispiele zu prüfen, werden Invarianten für ganze Eingabebereiche formuliert und automatisch gegen Tausende zufälliger Werte geprüft. Die Eris-Bibliothek macht diesen Ansatz direkt in PHPUnit nutzbar, mit Shrinking-Unterstützung für minimale Fehlerfälle und reproduzierbaren Seeds für fehlgeschlagene Tests. Die wichtigsten Invarianten-Muster – Symmetrie, Idempotenz, Monotonieerhaltung, Identität und Äquivalenz – decken den Großteil der testbaren Eigenschaften von Geschäftslogik ab.
Property-based Testing ersetzt Beispieltests nicht, sondern ergänzt sie als zweite Linie der Qualitätssicherung. Teams, die beide Ansätze kombinieren, finden Fehlerklassen, die mit reinen Beispieltests nicht erkannt werden können: Grenzwertfehler, Integer-Überläufe, Fließkomma-Präzisionsprobleme und unerwartete Seiteneffekte bei extremen Eingaben. Der Einstieg ist gering: eine Invariante für eine bestehende Klasse formulieren, Eris installieren und den ersten Property-Test schreiben. Das Ergebnis ist unmittelbar sichtbar.
Property-based Testing für PHP — Das Wichtigste auf einen Blick
Invarianten
Symmetrie, Idempotenz, Monotonie, Identität, Äquivalenz – diese fünf Muster decken den Großteil der Geschäftslogik-Properties ab.
Eris-Integration
composer require --dev giorgiosironi/eris. TestTrait einbinden. forAll(Generator)->then(callable) als Grundstruktur für jeden Property-Test.
Shrinking
Automatisch für alle eingebauten Generatoren. Bei Fehler: minimaler Eingabewert wird ausgegeben. Seed für exakte Reproduzierbarkeit notiert.
Kombination
Beispieltests für bekannte Geschäftsregeln, Property-based Tests für Algorithmen und Konvertierungslogik. Beide Ansätze ergänzen sich.