This article is also available in English

Die grundlegende Idee vom „eigenschaftsbasierten Testen“ (property based testing) ist schnell erklärt: Man definiere eine Funktion mit verschiedenen Parametern nach Belieben. Innerhalb der Funktion dürfen (und sollen) Assertions vorkommen. Statt nun die Funktion manuell mit verschiedenen Parametersätzen aufzurufen, lässt man das ein Testframework machen. Als Entwickler konzentrieren wir uns also auf die Testlogik, statt auf die Testeingaben.

Doch so einfach, wie das in der Theorie klingt, ist es dann leider in der Praxis nicht. Denn es stellen sich drei zentrale Fragen:

Diese Fragen schauen wir uns hier der Reihe nach an. Als Beispiel soll die in TypeScript geschriebene Bibliothek „fast-check“ dienen, die sich nahtlos in sämtliche JS-Testframeworks integrierenn lässt.

Wie schreibe ich Assertions?

Beginnen wir zunächst mit einem Beispiel, welches keine konkrete Bibliothek voraussetzt, sondern mit JavaScript-Bordmitteln auskommt. Angenommen, wir wollen die Implementierung des neuen BigInt-Zahlentyps testen. Wie das obige Rezept angibt, müssen wir zunächst eine Funktion definieren, die eine Reihe von Parametern nimmt und true oder false zurückgibt. Der denkbar einfachste Testfall ist die Kommutativität von +:

function plusCommute(x,  y)  { 
   return x + y === y + x;
}

Da JavaScript ungetypt ist, kann diese Funktion auch mit anderen Werten als BigInt umgehen. Um das noch festzuzurren, könnten wir – zumindest unter Node – noch ein paar Vorbedingungen unterbringen:

function plusCommute(x, y) { 
   assert(typeof x === "bigint"); 
   assert(typeof y === "bigint"); 
   return x + y === y + x;
}

Zum tatsächlichen Testen brauchen wir die Funktion bloß mit mehreren Parametern aufzurufen:

> plusCommute(1n, 2n)
true
> plusCommute(1n, 8n) 
true
> plusCommute(-3n, 8n) 
true
> plusCommute(-3n, 0n) 
true

An dieser Stelle kann man schon absehen, wo die Reise hingehen soll: Wir wollen, dass plusCommute automatisch mit passenden Parametern aufgerufen wird.

Nun mögen manche einwenden, dass sie selten Code schreiben, der stupide zwei Zahlen addiert. Oberflächlich stimmt das, aber Hand aufs Herz: Wie oft hast du bereits Bugs in Code gefunden, der beispielsweise mit Maßeinheiten oder Währungen hantiert? Insbesondere in JavaScript lauern beim Umgang mit Gleitkommazahlen so einige Fallstricke, denn es gibt sehr viel Code, der einfach mit number rechnet, wodurch sich schnell Ungenauigkeiten einschleichen.

Glücklicherweise hat John Hughes, einer der Köpfe hinter dem Konzept des eigenschaftsbasierten Testens, einen Artikel geschrieben, der Hilfestellung gibt, auch komplizierteren Code zu testen [1]. Hughes teilt die „Properties“ in fünf Klassen ein: Nachbedingungen, Invarianten, metamorphe Eigenschaften, induktive Eigenschaften und Modelleigenschaften. Manche dieser Klassen sind einfach erklärt, andere schwieriger. Beginnen wir wieder mit einem Beispiel: Sortierung von Arrays.

Nachbedingung

Die einfachste Eigenschaft ist die Nachbedingung einer Sortierfunktion, sie muss nämlich ein sortiertes Array zurückgeben. In JavaScript können wir das so ausdrücken:

function isSorted(array) {
   for (let i = 0; i < array.length - 1; ++i)
      if (array[i] > array[i+1]) 
         return false;
   return true; 
}

function sortIsSorted(array) { 
   array.sort();
   return isSorted(array); 
}

Diese Funktion sollte immer true zurückgeben. Ein alter Hut? Ganzmselbstverständlich ist das nicht, denn im JDK gab es mit der eingebauten Sortierfunktion tatsächlich Probleme, die schließlichm durch ein Verifikationstool gefunden worden sind [2].

Übrigens: Auch die Funktion isSorted kann man testen, beispielsweise, ob sie fehlschlägt, wenn man das umgedrehte Array einwirft:

function isSortedNotReverse(array) { 
   array.sort().reverse();
   return !isSorted(array);
}

Bonuspunkte erhält, wer errät, wann diese Eigenschaft nicht gilt, das heißt, wann ein sortiertes und dann umgekehrtes Array ebenso sortiert ist.

Invarianten

Gehen wir nun zur zweiten Klasse über, den Invarianten. Was wäre, wenn die sort()-Funktion immer ein leeres Array produzierte? Sortiert ist ein leeres Array zweifelsohne, aber hilfreich wäre eine solche Routine ganz sicher nicht. Die Invariante sagt hier aus, dass die Elemente hinterher die gleichen wie vorher sind. Ganz einfach ist das nicht zu formulieren, denn man muss sich zunächst eine Kopie des Arrays anfertigen:

function sortKeepsElements(inputArray) {
   const outputArray = [...inputArray] ; 
   outputArray.sort();
   return inputArray.every(elem => outputArray.includes(elem)); 
}

Wenn man es genau nimmt, müsste man hier noch prüfen, ob sich in das Zielarray nicht noch weitere (zusätzliche) Elemente eingeschlichen haben, sowie ob mehrfach vorkommende Elemente in der gleichen Anzahl vorliegen. Bei „Standard“-Sortieralgorithmen wären wir an dieser Stelle fertig: Es müssten keine weiteren Eigenschaften getestet werden, denn durch das Paar aus Nachbedingung und Invariante ist die Sortierung vollständig spezifiziert. Möchte man noch die Stabilität eines Sortierverfahrens abprüfen (seit Version ES2019 von ECMAScript), müsste man der Sortierfunktion noch einen speziellen Komparator mitgeben. Auch diese Eigenschaft fällt unter „Invariante“, denn Stabilität besagt, dass gleiche Elemente nicht umsortiert werden [3].

Metamorphe Eigenschaften

Die dritte Klasse von Eigenschaften sind die sogenannten „meta26 morphen“. Hughes beschreibt diese als „ähnliche Aufrufe führen zu ähnlichen Ergebnissen“. Kommutativität und Assoziativität von Addition und Multiplikation gehören zu solchen Eigenschaften. Man greift zu diesen Tests, wenn man nicht direkt weiß, welches Ergebnis eine Funktion liefert, man es aber in Bezug zu einem anderen Aufruf setzen kann. Die oben definierte Funktion isSortedNotReverse beschreibt auch eine solche metamorphe Eigenschaft.

Eine andere Spielart hiervon sind „vergleichende“ Tests: Oftmals steht man vor dem Problem, dass es für eine bestimmte Funktionalität eine offensichtlich korrekte, aber langsame, oder alternativ eine effiziente, aber komplizierte Implementierung gibt. Man kann einfach einen Test schreiben, der beide Implementierungen miteinander vergleicht. Beispielsweise erfüllt Bubblesort sämtliche Eigenschaften einer stabilen Sortierfunktion, aber niemand würde auf die Idee kommen, Bubblesort in einer realen Software zu benutzen.

Die anderen beiden Klassen sind leider etwas schwieriger zu erklären, deswegen überspringen wir sie an dieser Stelle und wenden uns der zweiten eingangs gestellten Frage zu.

Woher kennt das Framework die Parameter?

Grundsätzlich muss die Testbibliothek eine Methode haben, um zu einer Eigenschaft die passenden Parameter zu generieren. Die Begrifflichkeiten unterscheiden sich zwischen den Bibliotheken, aber meistens wird diese Funktionalität von einem „Generator“ bereitgestellt. Ein Generator ist ein Objekt (oder eine Funktion), was entweder zufällig oder deterministisch eine Serie von Werten eines bestimmten Typs oder einer bestimmten Form erzeugt. Beispielsweise könnte ein Generator für BigInt die (unendliche) Serie der positiven Zahlen beginnend mit 1n erzeugen. Alternativ sämtliche Zahlen, wobei diese in zufälliger Reihenfolge generiert werden.

Die allermeisten Testbibliotheken erzeugen Werte zufällig, und zwar so, dass der gesamte Werteraum abgedeckt wird. In fast-check ist daher von „Arbitraries“ die Rede, also von „beliebigen“ Werten. Als Besonderheit werden oft besonders trickreiche Werteeingestreut, beispielsweise 0, NaN oder der leere String. Natürlich gibt es hierbei eine Einschränkung: Es können immer nur endlich viele Werte generiert werden. Deshalb lässt sich die Anzahl der Durchläufe konfigurieren.

In den meisten getypten Programmiersprachen, wie Haskell, ScalaoderJava, existierenTestbibliotheken, die anhand der Parametertypen einer Funktion automatisch die passenden Testgeneratoren benutzen. In JavaScript steht uns dieses Mittel allerdings nicht zur Verfügung. Deswegen muss man bei fast-check immer explizit die Generatoren auflisten, was aber auch nicht weiter kompliziert ist.

Bleiben wir doch zunächst bei unserem Beispiel mit den Zahlen. In fast-check sieht der Aufruf der Eigenschaft dann so aus:

import fc from 'fast-check';

fc.assert( 
   fc.property(
      fc.bigInt(), 
      fc.bigInt(), 
      plusCommute
   ) 
);

Diese Verschachtelung kann ungewohnt sein. Die äußere Schale sorgt dafür, dass eine fehlgeschlagene Auswertung zu einer Exception führt (andernfalls wird bloß ein spezielles Objekt zurückgegeben). Die innere Schicht erzeugt eine Eigenschaft, bei der zunächst die Generatoren spezifiziert werden und anschließend der Callback, der mit den generierten Werten aufgerufen wird. In der Praxis würde noch ein Wrapper dafür sorgen, dass der Test von (beispielsweise) Jest oder Mocha ausgeführt wird:

import fc from 'fast-check';

test('addition should commute', () => { 
   fc.assert(
      fc.property(
         fc.bigInt(), 
         fc.bigInt(), 
         (x, y) => x + y === y + x
      )
   );
});

Solche Konstruktionen sind nicht unüblich für Testcode in Java-Script, insbesondere wenn asynchrone Funktionen involviert sind. Im Falle der Arrays kann man fast-check dazu anweisen, diese aus Elementen eines Generators befüllen zu lassen:

fc.assert( fc.property(
   fc.array(
      fc.integer()),
      sortIsSorted 
   )
);

Dummerweise schlägt dieser Test fehl, mit folgender kryptischer Fehlermeldung:

Property failed after 4 tests
{ seed: 1397560093, path: "3:2:4:6:5", endOnFailure: true } 
Counterexample: [[10,2]]
Shrunk 4 time(s)
Got error: Property failed by returning false

Schauen wir uns doch diese im Detail an:

Ziemlich clever, oder nicht? Hier interessiert uns aber zunächst nur, warum die Sortierfunktion mit der Eingabe [10, 2] nicht korrekt sortiert. Die Antwort hält [3] bereit: Wir haben vergessen, einen Komparator zu übergeben. Der korrekte Aufruf müsste lauten:

array.sort((a, b) => a - b)

Fehlt der Callback, konvertiert JavaScript die Elemente zunächst in Strings. Und damit sind wir schon bei der letzten Frage des Artikels angekommen.

Was ist der Mehrwert?

Ein klassischer Unittest hätte dieses mysteriöse Verhalten möglicherweise nicht abgefangen, denn solange nur eine überschaubare Anzahl von Fällen geprüft wird, verhält sich sort möglicherweise wie erwartet:

>array = [ 3, 2, 1]
[ 3, 2, 1 ]
> array.sort() 
[ 1, 2, 3 ]

fast-check hingegen unterliegt nicht dem menschlichen „Confirmation Bias“ und wirft munter beliebige Werte in die Funktion hinein. Obwohl die Tests dadurch unter Umständen länger dauern, erhält man dadurch eine wesentlich bessere Abdeckung.

Ein Nachteil ist jedoch der mangelnde Determinismus. Wem es zu brenzlig ist, dass bei jedem Testlauf gegebenenfalls andere Werte zufällig generiert werden, sollte einen statischen Seed konfigurieren. So gut wie alle Testbibliotheken unterstützen dies. Eine höhere Zusicherung erhält man dann im Ausgleich dadurch, dass man die Anzahl der Testfälle erhöht. fast-check beispielsweise führt jede Eigenschaft 100 Mal aus; möglich sind auch 48 deutlich höhere Werte.

Es gibt aber noch einen deutlich profunderen Vorteil dieses Teststils. Und dieser Vorteil setzt noch vor der Phase des eigentlichen Testings an: Allein der Akt, geeignete Testeigenschaften zu formulieren, zwingt zum einen zu klarerer Strukturierung der Programmlogik, zum anderen deckt er „versteckte“ Annahmen auf. Was genau heißt das?

Testeigenschaften funktionieren dann besonders gut, wenn die „Funktion unter Test“ keinerlei Nebenwirkungen hat. Im Endeffekt sorgt das dafür, dass man Programmlogik besser von Interaktion mit Umsystemen trennt. Denn mehr noch als in Unittests fallen schwergewichtige Setup-Prozesse eher auf, wenn sie 100x so oft ausgeführt werden müssen.

Die Alternative dazu ist, dass man die Software so strukturiert, dass etwaige Nebenwirkungen gut gekapselt werden können. Wird etwa eine Datenbankroutine getestet, muss die Datenbank schnell in einen frischen Zustand zurückgesetzt werden können. Hierfür eignen sich Transaktionen. In einem praktischen Projekt habe ich für Code, der mit dem Dateisystem interagiert, ein Memory-FS benutzt, um nicht ständig temporäre Dateien aufräumen zu müssen; neue Instanzen können in Mikrosekunden automatisch aufgesetzt und aufgeräumt werden.

Außerdem führen einem eigenschaftsbasierte Tests vor Auge, welche Annahmen konkret für das korrekte Funktionieren eines Features notwendig sind. Negative Zahlen als Parameter sind hierfür ein Dauerbrenner. Kommt Ihre Anwendung damit klar, wenn man versucht, ein Event mit einer negativen Dauer zu erzeugen? Oder ein Datum vor dem 01.01.1970? In manchen Fällen sind solche Eingaben nicht gewünscht, weshalb man sie per Vorbedingung abfängt. Aber trotzdem decken fast-check und Konsorten solche Annahmen gnadenlos auf und machen daher das Gesamtsystem robuster.

Fazit

„Lob des eigenschaftsbasierten Testens“, schrieb David MacIver – Autor einer Testbibliothek für Python – 2019 in einem Artikel [4]. Dort zeigt er, wie der vorliegende Artikel, die Vorteile dieses Stils auf. Aber viel wichtiger, er forderte auf, damit anzufangen! Auch wenn man in einem System bereits zahlreiche klassische Tests hat, ist es nie zu spät, neue Features mit Eigenschaften zu testen, oder bestehenden Features Eigenschaften an die Seite zu stellen [5]. Möglicherweise findet man dadurch auch Chancen für Refactorings, die die Mikroarchitektur der Software sauberer machen. Eine Reihe von Tipps, wie Testeigenschaften jedoch nicht aussehen sollten, gibt es bei [6].

Literatur und Links

  1. J. Hughes, How to Specify It!  ↩

  2. St. de Gouw, J. Rot, F. S. de Boer, R. Bubel, R. Hähnle, OpenJDK’s Java.utils.Collection.sort() Is Broken: The Good, the Bad and the Worst Case  ↩

  3. Array.prototype.sort()  ↩

  4. D. MacIver, In praise of property–based testing  ↩

  5. J. M. Lange, Evolving toward property–based testing with Hypothesis  ↩

  6. J. Sinclair, How not to write property tests in JavaScript  ↩

TAGS