Das Bild zeigt eine Hand vor dunklem Hintergrund, die eine leuchtende Lichtform präsentiert, die halb wie ein Gehirn, halb wie eine Glühbirne gezeichnet ist. Die Szene vermittelt fokussierte Klarheit und symbolisiert kleine Eingaben mit großer Erkenntnis – passend zum Shrinking im Property-Based Testing.

Im vorigen Artikel habe ich die Grundlagen des eigenschaftsbasierten Testens (Property-Based Testing) erläutert. Falls du diesen noch nicht gelesen hast, empfehle ich dir, das vor diesem Artikel zu tun, denn er bildet die Basis für die Konzepte, die folgen.

Mit eigenschaftsbasierten Tests können wir über einzelne Beispiele hinausgehen und allgemeine Invarianten zum Verhalten eines Systems ausdrücken. Anstatt einige Eingaben und die entsprechenden Ausgaben manuell auszuwählen, generiert das Testframework automatisch zahlreiche Eingaben und überprüft, ob die Eigenschaft für alle gilt.

Minimale Gegenbeispiele

Dieser Folgeartikel befasst sich mit einem weiteren Aspekt eigenschaftsbasierter Tests, der genauso wichtig ist, in Tutorials jedoch oft viel weniger Beachtung findet: Shrinking.

Durch Shrinking wird "irgendeine zufällige fehlerhafte Eingabe" zu einem kleinen, verständlichen Gegenbeispiel. Deshalb können eigenschaftsbasierte Tests subtile Fehler entdecken und trotzdem Fehlermeldungen ausgeben, die Menschen verstehen können.

Wenn ein eigenschaftsbasierter Test fehlschlägt, gibt es normalerweise zwei Phasen:

  • Generierung: Das Framework zieht zufällige Werte aus den von dir definierten Generatoren und sucht nach einer Eingabe, die die Eigenschaft widerlegt.
  • Reduzierung (Shrinking): Sobald eine fehlerhafte Eingabe gefunden wurde, versucht das Framework, diese Schritt für Schritt zu vereinfachen, während die Eigenschaft weiterhin fehlerhaft bleibt, bis ein minimales Gegenbeispiel gefunden wurde.

Ohne zu reduzieren, könnte dir ein eigenschaftsbasierter Test sagen, dass dein Code für [-12, 5, -7, 3, 19, -2] nicht funktioniert, was technisch nützlich, aber schwer zu verstehen ist. Der Fehler könnte so einfach sein wie "die Eigenschaft ist falsch, weil negative Zahlen existieren", doch die fehlerhafte Eingabe scheint zufällig zu sein.

Durch Shrinking wird derselbe Fehler oft zu etwas wie [-1] reduziert.

Plötzlich wird das Problem klar: Die Annahme, dass "Summen niemals negativ sind", ist schlichtweg falsch. Je kleiner das Gegenbeispiel, desto einfacher ist es zu verstehen, was wirklich passiert. Genau das ist das Wesentliche beim Shrinking: Es reduziert fehlerhafte Eingaben, bis nur noch der Kern des Fehlers übrig bleibt.

Auch wenn jede Bibliothek für eigenschaftsbasiertes Testen ihre eigenen Strategien für das Shrinking hat, ist das zugrunde liegende Konzept ähnlich. Für jeden Generator gibt es einen passenden Shrinker. Wenn eine fehlerhafte Eingabe gefunden wird, fragt das Framework jeden Generator, wie sich diese Eingabe verkleinern lässt. Anschließend werden diese kleineren Varianten ausprobiert und jede Variante, die die Eigenschaft immer noch falsch macht, beibehalten. Dieser Prozess wiederholt sich, bis keine kleinere fehlerhafte Variante mehr gefunden werden kann.

Bei einer gut durchdachten Konfiguration von Generatoren geht es deshalb nicht nur darum, welche Werte erzeugt werden können, sondern auch darum, wie diese Werte reduziert werden. Eris kommt mit vernünftigen Standardeinstellungen für gängige Typen, sodass du sofort von der Reduzierung profitierst, ohne etwas konfigurieren zu müssen.

Shrinking in Aktion

Um zu verstehen, wie Shrinking funktioniert, machen wir einmal die absichtlich falsche Annahme, dass die Summe einer Reihe von ganzen Zahlen nie negativ ist.

Das ist offensichtlich falsch, sobald negative Zahlen ins Spiel kommen. Aber überleg mal, wie sich eine solche falsche Annahme in einem Domänenmodell widerspiegeln könnte: "Kontostände können niemals unter Null fallen." Eigenschaftsbasiertes Testen ist sehr effektiv, um solche Annahmen zu hinterfragen.

Hier ist ein vollständiger, minimaler Testfall für PHPUnit mit Eris, der diese falsche Eigenschaft umsetzt.

<?php declare(strict_types=1);
use Eris\TestTrait;
use PHPUnit\Framework\TestCase;
use function Eris\Generator\int;
use function Eris\Generator\seq;

final class SumIsNeverNegativeTest extends TestCase
{
    use TestTrait;

    public function testSumOfIntegersIsNeverNegative(): void
    {
        $this
            ->forAll(seq(int()))
            ->when(
                static function (array $numbers): bool
                {
                    return count($numbers) > 0;
                }
            )
            ->then(
                function (array $numbers): void
                {
                    $this->assertGreaterThanOrEqual(
                        0,
                        array_sum($numbers),
                        'Minimal input for which this test fails: ' .
                        var_export($numbers, true) . PHP_EOL
                    );
                }
            );
    }
}

Jetzt erkläre ich dir, was passiert, wenn dieser Test ausgeführt wird:

  1. Generierung: Ein paar Fehlerfälle finden

    • int() generiert zufällige ganzzahlige Werte
    • seq() steckt diese ganzen Zahlen in Arrays unterschiedlicher Größe
    • when() filtert leere Arrays raus

    Irgendwann wird es ein Array erzeugen, dessen Summe negativ ist, zum Beispiel [-12, 5, -7, 3]. array_sum([-12, 5, -7, 3]) gibt -11 zurück, was dazu führt, dass unsere Zusicherung fehlschlägt.

    An dieser Stelle würde ein eigenschaftsbasiertes Test-Framework, das kein Shrinking durchführt, einfach dieses zufällige Array melden und dann aufhören.

  2. Shrinking: Den einfachsten Fehlerfall finden

    Stattdessen ruft Eris jetzt die mit den verwendeten Generatoren verbundenen Shrinker auf:

    • Bei seq() wird versucht, das Array zu vereinfachen, indem Elemente entfernt und die Werte der verbleibenden Elemente vereinfacht werden.
    • Bei int() wird versucht, zu ganzen Zahlen mit kleinerer Größe (näher an Null) zu kommen.

Schließlich kommt Eris zu dem Schluss, dass [-1] ein minimaler Gegenbeweis ist: Jede weitere Vereinfachung (zum Beispiel [] oder [0]) würde die Eigenschaft nicht mehr verletzen. Der dir gemeldete Fehler bezieht sich dann auf dieses kleine Array und nicht mehr auf das ursprüngliche zufällige Array. Die Ausführung des oben gezeigten Tests ergibt die unten gezeigte (gekürzte) Ausgabe:

There was 1 failure:

1) SumIsNeverNegativeTest::testSumOfIntegersIsNeverNegative
Minimal input for which this test fails: array (
  0 => -1,
)

Failed asserting that -1 is equal to 0 or is greater than 0.

Auch ohne die zufällige Eingabe zu überprüfen, die zuerst gefunden wurde, zeigt dir das minimale Gegenbeispiel sofort, wo das eigentliche Problem liegt: Die Eigenschaft selbst ist falsch, weil es negative Zahlen gibt.

Durch das Shrinking wurde der Fehler auf seine wesentliche Form reduziert: "ein Array, das eine einzelne negative Ganzzahl enthält".

Fazit

Im letzten Artikel ging es um Invarianten und Generatoren als die wichtigsten Ideen beim eigenschaftsbasierten Testen. Shrinking ist die andere Hälfte der Geschichte:

  • Generatoren erkunden weite Teile des Eingaberaums
  • Eigenschaften sagen dem Framework, was immer gelten muss
  • Shrinking macht die unvermeidlichen Fehler verständlich

Auch ohne Shrinking würde das eigenschaftsbasierte Testen Fehler finden, aber viele davon würden durch große, komplexe Eingaben, die schwer zu verstehen sind, verdeckt werden. Mit Shrinking werden eigenschaftsbasierte Tests jedoch zu präzisen Diagnosewerkzeugen. Sie sagen dir nicht nur, dass etwas nicht stimmt. Sie zeigen dir den kleinsten möglichen Fehler.