In einem früheren Artikel habe ich das eigenschaftsbasierte Testen (Property-Based Testing) anhand eines einfachen Beispiels eingeführt: der Umkehrung von Arrays mit array_reverse(). Für diese Operation haben wir mehrere Eigenschaften formuliert:
- Das umgekehrte Array hat dieselbe Größe wie das ursprüngliche Array
- Das umgekehrte Array hat die gleichen Elemente wie das ursprüngliche Array, nur in einer anderen Reihenfolge
- Das erste Element des ursprünglichen Arrays wird zum letzten Element des umgekehrten Arrays
- Wenden wir
array_reverse()zweimal an, so erhalten wir das ursprüngliche Array
Einige Leserinnen und Leser haben mich anschließend gefragt: Ist das ein Ersatz für die Data Provider von PHPUnit? Sollen wir künftig nur noch Properties benutzen? Oder sind Data Provider weiterhin sinnvoll?
Diese Fragen sind berechtigt, denn auf den ersten Blick sehen sich beide Methoden ähnlich. In beiden Fällen wird eine Testmethode mit mehreren verschiedenen Eingaben ausgeführt. Tatsächlich lösen sie aber unterschiedliche Probleme und bringen unterschiedliche Garantien mit sich. Hinzu kommt ein technischer Unterschied, der oft übersehen wird: Wie PHPUnit diese Tests ausführt und wie sich das auf die Testisolation auswirkt.
Eine Testmethode, mehrere Eingaben
Zunächst das Offensichtliche: Sowohl mit dem Data Provider-Ansatz als auch mit eigenschaftsbasiertem Testen können wir dieselbe Logik mit vielen unterschiedlichen Eingaben testen.
Ein typischer PHPUnit-Test mit Data Provider könnte so aussehen:
<?php declare(strict_types=1); use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class ArrayReverseTest extends TestCase { public static function provider(): array { return [ 'empty array' => [ [], ], 'single element' => [ [1], ], 'multiple unique elements' => [ [1, 2, 3], ], 'multiple non-unique elements' => [ [1, 2, 2, 3], ], ]; } #[DataProvider('provider')] public function testReversingTwiceIsIdentity(array $input): void { $this->assertSame($input, array_reverse(array_reverse($input))); } }
Wir definieren einige typische und interessante Fälle explizit. Jeder dieser Datensätze, die von der Data Provider-Methode provider() zurückgegeben werden, werden von PHPUnit als eigener Testfall ausgeführt.
Dasselbe Verhalten können wir als Eigenschaft formulieren:
<?php declare(strict_types=1); use function Eris\Generator\int; use function Eris\Generator\seq; use Eris\TestTrait; use PHPUnit\Framework\TestCase; final class ArrayReverseTest extends TestCase { use TestTrait; public function testReversingTwiceIsIdentity(): void { $this ->forAll(seq(int())) ->then( function (array $input): void { $reversed = array_reverse($input); $doubleReversed = array_reverse($reversed); $this->assertSame($input, $doubleReversed); }, ); } }
Hier beschreiben wir nicht konkrete Eingaben, sondern eine allgemeine Eigenschaft: "Zweimaliges Umkehren liefert das ursprüngliche Array". Eris generiert automatisch viele Arrays ([], [1], [2, 5, 8], lange Arrays, Arrays mit negativen Zahlen usw.) und prüft die Eigenschaft für jede dieser Eingaben.
Oberflächlich betrachtet: In beiden Fällen wird array_reverse() mit verschiedenen Eingaben getestet. Doch konzeptionell und technisch passiert etwas sehr Unterschiedliches.
Beispiele versus Invarianten
Der erste grundlegende Unterschied ist die Art der Spezifikation.
Beim Data Provider-Ansatz verwenden wir konkrete Beispiele:
- Jeder vom Data Provider gelieferte Datensatz ist ein explizit gewählter Testfall
- Für jede Eingabe geben wir die exakt erwartete Ausgabe an
- Die Menge der getesteten Eingaben ist endlich: genau die Fälle, die wir auflisten
Für unser einfaches Beispiel mit array_reverse() bedeutet das:
- Wir testen das leere Array
- Wir testen ein Array mit einem Element
- Wir testen ein Array mit wenigen Elementen
- Vielleicht testen wir ein paar Spezialfälle (Duplikate, ...)
Mit eigenschaftsbasiertem Testen verschieben wir den Fokus und beschreiben allgemeine Wahrheiten (Invarianten) über array_reverse(). Wir sagen nicht "dieser konkrete Input muss zu genau diesem konkreten Ergebnis führen", sondern "für alle Eingaben muss folgende Bedingung gelten".
Data Provider beantworten also: "Was soll bei diesem konkreten Input passieren?" Properties beantworten: "Welche allgemeinen Aussagen müssen für alle Input gelten?"
Auswahl versus Generierung
Der zweite Unterschied betrifft die Frage: Wer wählt die Testdaten?
Beim Data Provider-Ansatz entscheiden wir, mit welchen Arrays wir array_reverse() testen:
- Wir überlegen uns: Welche Beispiele sind aussagekräftig?
- Wir fügen gezielt Randfälle hinzu: leere Arrays, Arrays mit einem Element, möglicherweise sehr lange Arrays (wenn wir daran denken)
- Wir dokumentieren mit diesen Beispielen, wie sich
array_reverse()in typischen Situationen verhalten soll
Der Nachteil: Wir testen nur, was uns einfällt. Wenn wir zum Beispiel nie an ein sehr großes Array denken, oder an Arrays mit ungewöhnlichen Schlüsseln, dann bleiben diese Fälle ungetestet.
Beim eigenschaftsbasierten Testen beschreiben wir "nur" den Eingaberaum: forAll(seq(int())) generiert automatisch viele unterschiedliche Arrays:
- Sehr kurze und sehr lange Arrays
- Arrays mit negativen Zahlen, Null, großen Zahlen
- Arrays, auf die wir selbst wahrscheinlich nie gekommen wären
Findet eine Bibliothek für eigenschaftsbasiertes Testen wie Eris einen Gegenbeweis zu unserer Eigenschaft, nutzt sie Shrinking, um die Eingabe zu einem möglichst kleinen, einfachen Beispiel zu verkleinern, das den Fehler immer noch auslöst.
Kurz gesagt: Data Provider dokumentieren deinen aktuellen Wissensstand. Properties plus Generatoren helfen dabei, die Lücken in diesem Wissensstand aufzudecken.
Deterministisch versus explorativ
Der dritte Unterschied betrifft Wiederholbarkeit und Zielsetzung.
Tests, die Data Provider verwenden, sind deterministisch:
- Jeder Testlauf führt den getesteten Code mit exakt denselben Eingaben aus
- Sie eignen sich perfekt für Regressionstests: Ein gefundener Bug wird als Beispiel festgehalten und jede Ausführung der Testsuite überprüft, dass dieser Bug (genau so) nicht wieder eingeführt wurde
- Sie dienen als lebende Dokumentation: "Wir erwarten für diesen realen Geschäftsfall genau dieses Ergebnis"
Eigenschaftsbasierte Tests sind explorativ:
- Sie durchsuchen den Eingaberaum mit Zufallskomponente
- Ihr Zweck ist nicht Dokumentation, sondern Entdeckung: Finde Fehler, die ich mit handgeschriebenen Beispielen nie gefunden hätte
- Wir können sie mit einem festen Random Seed deterministisch machen, wenn wir einen gefundenen Fehler nachstellen möchten, aber das ist der Ausnahmefall
Data Provider frieren bekannte Szenarien ein. Eigenschaftsbasierte Tests erkunden unbekannte Szenarien.
Der kritische Unterschied
Bis hierhin war der Vergleich eher konzeptionell. Jetzt wird es technisch. Und hier kommt ein oft übersehener, aber wichtiger Unterschied ins Spiel: Es geht darum, wie PHPUnit diese Tests ausführt und welche Auswirkungen das auf die Testisolation hat.
Nehmen wir an, unsere Data Provider-Methode liefert wie im Beispiel oben 4 Datensätze. PHPUnit zählt das als 4 separate Tests. Für jeden Datensatz passiert Folgendes:
- PHPUnit erzeugt eine neue Instanz der Testklasse
ArrayReverseTest - Before-Test-Methoden wie
setUp()werden ausgeführt - Die Testmethode
testReversingTwiceIsIdentity()wird mit einem Datensatz ausgeführt - After-Test-Methoden wie
tearDown()werden ausgeführt - Die Instanz wird verworfen
Das hat zwei Konsequenzen:
- Jeder Datensatz wir für einen Test in einer frischen Umgebung verwendet
- Jede Form von Zustand in deiner Testklasse wird für jeden Datensatz neu aufgebaut und anschließend wieder abgeräumt
Wenn du also etwa in einer Before-Test-Methode eine große Fixture aufbaust, ist gewährleistet, dass jeder Test für jeden Datensatz diesen Zustand "frisch" sieht.
Angenommen, Eris ist so konfiguriert, dass es 100 verschiedene Arrays generiert. PHPUnit zählt das als 1 Test, nicht 100. Allerdings zählt PHPUnit natürlich die Zusicherungen korrekt, in unserem Beispiel also 100. Die Ausführung sieht so aus:
- PHPUnit erzeugt eine neue Instanz der Testklasse
ArrayReverseTest - Before-Test-Methoden wie
setUp()werden genau einmal ausgeführt - Die Testmethode
testReversingTwiceIsIdentity()wird genau einmal ausgeführt - After-Test-Methoden wie
tearDown()werden genau einmal ausgeführt - Die Instanz wird verworfen
Das heißt: Zwischen den 100 Iterationen ruft PHPUnit weder Before-Test-Methoden wie setUp() oder After-Test-Methoden wie tearDown() auf. Für PHPUnit ist das eine einzige Testausführung.
Die Isolation zwischen den einzelnen Iterationen im then() Callable ist nicht automatisch garantiert.
Best Practices
Um mit einer Bibliothek für eigenschaftsbasiertes Testen wie Eris sauber zu arbeiten, hilft es, sich an ein paar Grundregeln zu halten:
-
Bevorzuge zustandslosen Code
Gerade für Funktionen wie
array_reverse()ist das zum Glück einfach: Wir haben eine Funktion, die ein Array entgegennimmt und ein neues Array zurückgibt. Kein globaler Zustand, keine Seiteneffekte. -
Erzeuge Zustand lokal im
then()CallableWenn wir in einzelnen Iterationen zusätzliche Datenstrukturen brauchen, erzeugen wir sie im Callable selbst. Jede Iteration bekommt so ihren eigenen lokalen Zustand.
-
Falls unvermeidbar: explizit aufräumen
Wenn wir globalen Zustand brauchen, beispielsweise einen Cache oder eine temporäre Datei, dann kümmern wir uns am Ende des Callables um das Aufräumen, damit die nächste Iteration wieder sauber starten kann.
Fazit
Schon an einem so einfachen Beispiel wie dem Testen der Funktion array_reverse() lassen sich die Rollen von Data Provider-Einsatz und eigenschaftsbasiertem Testen klar abgrenzen.
Data Provider dokumentieren konkrete, wichtige Fälle (inklusive gefundener Bugs für Regressionstests), profitieren von der vollständigen Testisolation durch PHPUnit und sind hervorragend geeignet, um Einsteigerinnen und Einsteigern sowie Fachfremden das Verhalten zu erklären.
Beim eigenschaftsbasierten Testen erfassen Properties allgemeine Invarianten und generieren automatisch viele Eingaben, inklusive solcher, an die wir nicht gedacht hätten. Die Tests müssen wir bewusst mit Blick auf Isolation schreiben, weil PHPUnit alle Iterationen in einem einzigen Testkontext ausführt.
Eine gute Teststrategie nutzt beides: Wir formulieren allgemeine Eigenschaften mit Properties, um systematisch den Eingaberaum zu erkunden. Wenn unsere eigenschaftsbasierten Tests einen interessanten Gegenbeweis findet, überführen wir ihn als explizites Beispiel und dokumentierten Regressionstest in einen "normalen" PHPUnit-Test. Wir nutzen Data Provider gezielt dort, wo uns die automatische Testisolation von PHPUnit hilft oder wo konkrete Beispiele das Verhalten besser erklären als abstrakte Eigenschaften.