de-mood

In einem früheren Artikel habe ich das Konzept der Test Oracles erläutert. Dabei handelt es sich um Verfahren oder Quellen, die darüber entscheiden, ob ein Test erfolgreich ist oder nicht. Wenn du den erwähnten Artikel noch nicht gelesen hast, keine Sorge, du kannst diesen hier trotzdem lesen.

In diesem Artikel geht es darum, wie eigenschaftsbasierte Prüfungen das Orakelproblem in der Praxis lösen können.

Und täglich grüßt das Orakel

Wir brauchen für korrekte Softwaretests eine Autorität, die sich mit dem erwarteten Verhalten auskennt. Bei normalen Unit Tests sind explizite Zusicherungen (Assertions) über Rückgabewerte, Zustandsänderungen oder Interaktionen mit anderen Objekten diese Autorität. Wir schreiben Tests, die bestimmte Ausgaben für bestimmte Eingaben überprüfen: Wenn der Einkaufswagen zum Beispiel zwei Artikel zu je fünf Euro enthält, sollte der Gesamtbetrag zehn Euro betragen.

Dieser Ansatz funktioniert zwar gut für bekannte Szenarien, hat aber eine grundlegende Einschränkung: Wir testen nur das, woran wir denken. Wenn wir zum Beispiel vergessen, einen Test für negative Mengen, leere Warenkörbe oder kostenlose Artikel zu schreiben, bleiben Fehler unentdeckt.

Die Code Coverage zeigt uns zwar, welche Codezeilen ausgeführt wurden, aber wie ich beim Vergleich von Path Coverage und Mutation Testing geschrieben habe, ist Code Coverage ein quantitatives und kein qualitatives Maß. Eine hohe Abdeckung garantiert nicht, dass unsere Tests tatsächlich die Korrektheit überprüfen.

Eigenschaftsbasiertes Testen ist eine komplementäre Lösung, die den Fokus von bestimmten Beispielen auf allgemeines Verhalten legt. Anstatt statische Eingabe-/Ausgabe-Paare manuell zu erstellen, definieren wir Invarianten: allgemeine Wahrheiten über das System, die immer gelten müssen, egal welchen Input die Software bekommt. Das Test Framework erstellt dann automatisch Testfälle, um diese Eigenschaften zu überprüfen, sodass wir nicht mehr Hunderte von einzelnen Assertions brauchen.

Das "Hello world" Beispiel

Schauen wir uns eine sehr einfache Funktion an, an der wir leicht sehen können, wie Eigenschaften aussehen: das Umkehren eines Arrays. Dieses Beispiel wird in vielen Tutorials gezeigt, weil es die wichtigsten Konzepte klar macht und trotzdem für Anfängerinnen und Anfänger verständlich ist. Um es einfach zu halten, testen wir nicht eine eigene Implementierung zum Umkehren der Elemente eines Arrays, sondern nutzen stattdessen die in PHP eingebaute Funktion array_reverse().

Hier ist ein umfassendes Beispiel dafür, wie wir die Umkehrung von Arrays mit Hilfe von eigenschaftsbasierten Tests in PHP mit Eris testen. Eris ist eine Bibliothek für Property-Based Testing, die mit PHPUnit verwendet werden kann. Lass dich nicht von der großen Menge an Beispielcode abschrecken, ich werde dich Schritt für Schritt durch den Code führen.

<?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);
                },
            );
    }

    public function testReversingPreservesLength(): void
    {
        $this
            ->forAll(seq(int()))
            ->then(
                function (array $input): void
                {
                    $reversed = array_reverse($input);

                    $this->assertSameSize($input, $reversed);
                },
            );
    }

    public function testArrayReversePreservesElements(): void
    {
        $this
            ->forAll(seq(int()))
            ->then(
                function (array $input): void
                {
                    $reversed = array_reverse($input);

                    sort($input);
                    sort($reversed);

                    $this->assertSame($input, $reversed);
                },
            );
    }

    public function testFirstElementBecomesLast(): void
    {
        $this
            ->forAll(seq(int()))
            ->when(
                static function (array $input): bool
                {
                    return count($input) > 0;
                },
            )
            ->then(
                function (array $input): void
                {
                    $reversed = array_reverse($input);

                    $this->assertSame(array_first($input), array_last($reversed));
                },
            );
    }
}

Die oben gezeigte Testfallklasse ArrayReverseTest nutzt den von Eris bereitgestellten Trait Eris\TestTrait. Dieser Trait führt Methoden wie forAll() ein, die verwendet werden können, zum

  1. Generieren von zufälligen Eingaben mit Generatoren
  2. Testen des System-under-Test mit jeder Eingabe
  3. Überprüfen der Eigenschaften, die für alle Ausgaben gelten sollten

Schauen wir uns mal genauer die Testmethode testReversingTwiceIsIdentity() an.

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);
            },
        );
}

Wir fangen damit an, forAll(seq(int())) von innen nach außen zu lesen:

  • int() generiert zufällige ganzzahlige Werte
  • seq() generiert Sequenzen (numerische Arrays) mit einer zufälligen Anzahl von Elementen
  • seq(int()) generiert Arrays mit zufälliger Länge, die zufällige ganzzahlige Werte haben
  • forAll(seq(int())) sagt Eris, dass es den Testcode (siehe unten) einmal für jede mit seq(int()) erzeugte Eingabe ausführen soll

Schließlich verwenden wir then(), um den Testcode in Form eines Callable anzugeben. Dieses Callable wird für jede mit seq(int()) generierte Eingabe aufgerufen (siehe oben).

Wenn du diesen Test ausführst, generiert Eris automatisch Arrays wie [], [1], [2, 5, 8] und viele weitere und überprüft, ob die Eigenschaft der doppelten Umkehrung für jedes einzelne gilt.

Eigenschaftsbasiertes Testen ist am besten geeignet, wenn du mehrere Eigenschaften derselben Operation überprüfen möchtest. Für unser Beispiel mit der Umkehrung des Arrays fallen mir folgende zusätzliche Eigenschaften ein:

  • 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

Die anderen drei Testmethoden der Testfallklasse ArrayReverseTest zeigen, wie diese Eigenschaften getestet werden können.

Die Testmethode testFirstElementBecomesLast() stellt eine weitere von Eris bereitgestellte Methode vor: when(). Wir verwenden sie hier, um zufällig generierte Arrays, die leer sind, durch die Rückgabe von false "abzulehnen".

Selbst mit nur wenigen Eigenschaften wie "doppelte Umkehrung bewahrt Identität" oder "erstes Element wird letztes Element" können wir ein robustes Orakel für die Umkehrung von Arrays erstellen, ohne jemals bestimmte Eingabe- und Ausgabepaare auflisten zu müssen.

Ein Perspektivwechsel

Eine einzelne Eigenschaft wie "das umgekehrte Array hat dieselbe Größe wie das ursprüngliche Array" testet implizit eine unendliche Menge von Eingaben, die nur durch die Fähigkeiten des Generators und die für die Testausführung zur Verfügung stehende Zeit begrenzt ist. Das Test Framework generiert automatisch Hunderte von Testeingaben, lange Arrays, kurze Arrays, leere Arrays, und überprüft, ob diese Eigenschaft für alle gilt.

Diese Verlagerung von konkreten Beispielen zu abstrakten Eigenschaften behebt eine grundlegende Schwäche des beispielbasierten Testens. Wir neigen dazu, die Fälle zu testen, die uns einfallen, was uns zu häufigen Szenarien hinzieht und von Randfällen ablenkt. Wir testen positive Fälle gründlicher als negative Fälle. Wir denken erst daran, Randbedingungen zu testen, nachdem wir durch Fehler an den Rändern Probleme bekommen haben.

Eigenschaftsbasiertes Testen dreht diese Tendenz um, indem es Eingaben generiert, auf die wir selbst nicht gekommen wären, und so automatisch Randfälle entdeckt.

Eigenschaften wirken als partielle Orakel: Sie liefern nicht die vollständige erwartete Ausgabe für jede Eingabe, sondern definieren Beziehungen und Invarianten, die immer gelten müssen. Das Formulieren solcher Eigenschaften ist damit eine Orakeldesign-Aufgabe: Du übersetzt dein Verständnis von korrektem Verhalten in überprüfbare Regeln, die das Test Framework automatisch validiert.

Der zentrale Punkt ist folgender: Für viele Systeme ist es schwierig, die exakte Ausgabe für beliebige Eingaben zu bestimmen, aber oft einfach, grundlegende Zusammenhänge zu prüfen, die für korrektes Verhalten gelten müssen. In manchen Domänen bleibt es anspruchsvoll, gute Eigenschaften zu definieren, denn dabei kodierst du dein Fachwissen und deine Annahmen über Korrektheit in Form von überprüfbaren Bedingungen.

Das Orakel muss dabei kein vollständiges Wissen über Korrektheit besitzen. Es reicht, wenn es die Invarianten kennt: Eigenschaften, die unabhängig vom konkreten Ergebnis immer gelten müssen. Genau darin liegt die Stärke des eigenschaftsbasierten Testens: Statt Ergebnisse zu vergleichen, überprüfst du Prinzipien, die jedes gültige Ergebnis erfüllen muss.

Eigenschaftsbasierte Tests eignen sich besonders gut für Bereiche, in denen Invarianten einfach zu identifizieren und auszudrücken sind. Reine Funktionen mit klaren mathematischen oder logischen Eigenschaften, wie Datums- und Zeitberechnungen, String-Transformationen oder mathematische Berechnungen, sind ideale Kandidaten.

Code, der bereits deterministisch und frei von Seiteneffekten ist, eignet sich gut für eigenschaftsbasierte Tests, ebenso wie Code, bei dem Seiteneffekte isoliert und durch Mock Objects nachgebildet werden können.

Umgekehrt stellen Domänen mit erheblichen externen Nebeneffekten eine Herausforderung dar. Zum Beispiel kann die Erzeugung zufälliger Eingaben für Netzwerkoperationen oder Dateisysteminteraktionen kostspielig, destruktiv oder unpraktisch sein, wenn es um umfassende Tests geht. Ähnlich verhält es sich mit stark zustandsbehafteten Systemen, bei denen die Beschreibung von Invarianten über Zustandsübergänge hinweg schwierig ist und die daher eher von gezielten Beispiel- oder Integrationstests profitieren.

Fazit

Eigenschaftsbasiertes Testen ist ein Testansatz, der über die Aufzählung von manuellen Beispielen hinausgeht. Indem bestimmte erwartete Ergebnisse durch allgemeine Invarianten ersetzt werden, ermöglicht das eigenschaftsbasierte Testen die automatische Erkundung von Eingaberäumen unter Beibehaltung klarer Prüfkriterien.

Im nächsten Artikel werden wir untersuchen, was passiert, wenn eine Eigenschaft fehlschlägt, und wie Bibliotheken wie Eris fehlschlagende Eingaben automatisch auf ihre einfachste Form reduzieren. So werden aus zufälligen Testfehlern nachvollziehbare, reproduzierbare Bugs.

Das Orakelproblem, also die Frage, ob ein Test bestanden oder nicht bestanden werden sollte, wird lösbar, wenn der Fokus auf Invarianten statt auf vollständigen Spezifikationen liegt. Roundtrip-Eigenschaften überprüfen, ob inverse Operationen Daten erhalten. Idempotenz-Eigenschaften überprüfen die Stabilität. Invariante Eigenschaften stellen sicher, dass wesentliche Merkmale während Transformationen erhalten bleiben. Metamorphe Eigenschaften erleichtern Vergleiche in Bereichen, in denen exakte Ausgaben schwer zu berechnen sind.

Im PHP-Ökosystem macht die Bibliothek Eris eigenschaftsbasiertes Testen zugänglich und praktisch, indem sie sich nahtlos in PHPUnit integriert und eine schrittweise Einführung in bestehenden Test Suites ermöglicht. In Kombination mit Mutation Tests zur Qualitätssicherung und beispielbasierten Tests für die Dokumentation bietet das eigenschaftsbasierte Testen einen ausgeklügelten Testansatz, der Fehler aufdeckt, die bei beispielbasierten Tests systematisch übersehen werden.

Wenn du weißt, wann und wie du eigenschaftsbasierte Tests anwenden kannst, kannst du Teststrategien entwickeln, die die Softwarequalität wirklich verbessern, anstatt nur Metrikziele zu erfüllen. Auch wenn das Orakelproblem vielleicht nie vollständig gelöst werden kann und eine perfekte Kenntnis der Korrektheit nach wie vor schwer zu erreichen ist, bieten eigenschaftsbasierte Tests leistungsstarke Werkzeuge zur Überprüfung allgemeiner Aussagen über das Verhalten von Software bei unterschiedlichen Eingaben.