Oft wird die Zuversicht in das Ergebnis eines Testlaufes durch Metriken begründet. So gilt die Testabdeckung als zentraler Faktor, der die Zuversicht in die Richtigkeit eines Testergebnis stärkt. Historisch gesehen ist die Definition geeigneter Testdaten eine Herrausforderung, die zuletzt weniger im Fokus der Aufmerksamkeit stand. Intuitiv ist jedem Entwickler bewusst, dass Tests mit Randwerten wahrscheinlicher zu einem Fehler führen als Zufallswerte. Versucht man hingegen Zufallswerte in großer Anzahl zu generieren, kann sich dieser Nachteil relativieren. Doch wie kann ein solcher Testansatz in der Realität funktionieren und was hat das Ganze mit funktionaler Programmierung zu tun?
Attribut-basiertes Testen („Property-based testing“) hat das Ziel, die
Spezifikation eines Software-Systems anhand generierter Testdaten zu prüfen und
steht damit im Kontrast zum Beispiel-basierten Testen, welches heute primär in
Funktionstests zum Einsatz kommt. Doch wie werden die Testdaten zur Verfügung
gestellt, wenn nicht durch explizites Beschreiben dieser? Die meisten
Java-Entwickler kennen die Möglichkeiten über die Klasse java.util.Random
Zufallswerte elementarer Datentypen zu erzeugen:
Über die Funktion nextInt
können zufällige Ganzzahlen generiert werden. Was
ist jedoch, wenn man für die zu testende Funktion ausschließlich positive
Zahlen oder gar komplexe Datentypen benötigt? Es zeigt sich, dass komplexe
Datentypen aus der Kombination elementarer Datentypen beschrieben werden
können. Analog dazu können Zufallsgeneratoren für komplexe Datentypen aus der
Kombination elementarer Zufallsgeneratoren beschrieben werden. Ein Beispiel
hierfür ist die Definition eines Zufallsgenerators für natürliche Zahlen und
Intervalle.
Wie das Beispiel zeigt, können komplexe Generatoren durch die Verwendung elementarer
Generatoren beschrieben werden. Auch domänenspezifische Datentypen wie Alter,
Buchung oder Stammbaum lassen sich nach diesem Muster definieren. Schnell zeigt
sich jedoch, dass sich die Verbindung von Generatoren nach diesem Muster als
schwierig erweist. So greift die nextInterval
Funktion auf den Generator für
natürliche Zahlen zurück, obwohl der gleiche Generator auch für ganze Zahlen
funktionieren würde. Ein weiterer Nachteil dieses manuellen Vorgehens ist die
Normalverteilung der Daten, die ohne Weiteres nicht beeinflusst werden kann.
Hilfe leisten zu diesem Zweck entwickelte Bibliotheken für das Attribut-basierte Testen. In der Scala-Welt ist dies ScalaCheck [1], eine an das Haskell-Vorbild QuickCheck [2] angelehnte Bibliothek, welche eigenständig oder als Ergänzung bestehender Test-Frameworks eingesetzt werden kann. Neben der Möglichkeit generische Generatoren miteinander zu verbinden, erlaubt ScalaCheck die Werteverteilung der Generierung zu beeinflussen.
Datengenerierung mit ScalaCheck
In ScalaCheck sind sogenannte Generatoren für die Generierung von Daten
verantwortlich. Diese können über Instanzen der Klasse org.scalacheck.Gen
beschrieben werden. Im Kern sind Generatoren Funktionen, welche für einen
bestimmten Typ, wie beispielsweise String
definiert sind. Zum Beispiel ist
Gen.oneOf("grün", "orange", "rot")
ein Generator vom Typ Gen[String]
,
welcher statt beliebiger Strings aus einer vordefinierten Liste auswählt.
ScalaCheck bringt bereits eine Reihe von Generatoren mit, welche
sich auf gebräuchliche Datentypen und deren Kompositionen beziehen. Über
den arbitrary
Generator können Instanzen dieser Daten bezogen werden:
Der arbitrary
Generator wird von ScalaCheck selbst für die Generierung von
Property-Parametern verwendet. Properties sind die testbaren Attribute eines
Programms, deren erwartetes Verhalten durch eine Menge von Testdaten
verifiziert wird. Nehmen wir hinzu eine fehlerhafte Quersummenfunktion als
Beispiel:
Beim manuellen Testen dieser Funktion müsste man sich gute Grenzwerte überlegen, bei denen Fehler wahrscheinlich scheinen. Ist der Wertebereich einer Funktion entsprechend groß oder hat die Funktion viele Bedingungen, lässt die Verlässlichkeit eines Grenzwertes nach. Das Attribut-basierte Testen trennt daher die erwarteten Eigenschaften eines Programms von den Testdaten.
Für alle natürlichen Zahlen gilt die Regel, dass eine Zahl durch 3 teilbar ist, wenn die Quersumme dieser durch 3 (ohne Rest) teilbar ist. Aus dieser allgemeinen Eigenschaft kann man folgende ScalaCheck-Property ableiten:
Das Beispiel zeigt, dass in ScalaCheck die Spezifikation der Eigenschaft im Zentrum des Tests steht und das Finden passender Testdaten dem Framework überlassen wird. Assertions wie in diesem Beispiel sind auch aus klassischen Unit-Tests bekannt, welche vorwiegend nach einer Gegeben / Wann / Dann Struktur [3] aufgebaut sind. Der Unterschied beim Attribut-basierten Testen liegt im „Gegeben“-Teil des Tests. Hier werden normalerweise die Daten beschrieben, die als Eingabe für einen Test gelten sollen. Die Auslagerung dieses Aspekts in Generatoren ist es, welche die knappe Formulierung von Tests ermöglicht.
Führt man den Quersummen-Test mehrfach aus, fällt eine weitere interessante Eigenschaft von ScalaCheck auf. Der Wert, für den ein Fehlerfall aufgezeigt wird, ist i.d.R. klein, gemessen am Definitionsbereich. Dies ist dadurch zu erklären, dass die ersten negativen Resultate oft verworfen werden, mit dem Ziel, einfachere Instanzen des Fehlerfalls zu identifizieren. Diese Eigenschaft ist vor allem bei komplexen und verschachtelten Datentypen hilfreich, da Probleme anhand einfacher Eingangsdaten besser identifiziert und letztendlich behoben werden können. Hierfür stellt ScalaCheck für geläufige Datentypen sogenannte Reduktions-Strategien bereit. Für eigene Datentypen können angepasste Strategien hinterlegt werden.
Domänenspezifische Generatoren
Bisher gezeigte Tests kamen mit vordefinierten Generatoren aus. Oft benötigen Tests jedoch domänenspezifische Daten, sodass ein wichtiger Aspekt von ScalaCheck die Erweiterbarkeit der Generatoren ist.
Ein reales Anwendungsgebiet könnte ein Webdienst sein, über den Börsendaten bezogen werden. Dieser liefert neben dem aktuellen Kurs einer Aktie auch die Jahreshoch- und Tiefstwerte:
Die Verarbeitung der Daten läuft in 4 Schritten ab.
- Beziehe die Daten des Webdienstes in Form eines JSON-Dokuments
- Prüfe die syntaktische Wohlgeformtheit des JSON
- Extrahiere Symbol, Name und Preis (min, max, aktuell)
- Prüfe semantische Validität
Falls einer der genannten Punkte scheitert, sollen die Daten von einem alternativen Dienst bezogen werden. Bei der Definition der Tests müssen die Punkte 1 und 2 nicht berücksichtigt werden, da diese Funktion durch Bibliotheken umgesetzt werden. Punkt 3 ist in einer eigenen Funktion umgesetzt und muss daher getestet werden.
Die durch TDD vorgegebene Spezifikation durch Tests und deren anschließende Umsetzung in Algorithmen kann dazu führen, dass die Implementierung in zu spezifische Testfälle mündet. Ein geläufiger Fall ist die Definition eines zu strengen Parsers für eine öffentliche API. Wer hatte noch nicht den Fall, dass ein bisher funktionierender und getesteter Client nicht mehr seine Funktion erfüllt, da die Gegenseite nicht mehr exakt die erwarteten Werte liefert?
Attribut-basiertes Testen kann durch die Generierung zufälliger Werte dazu beitragen, dass die eigene Implementierung belastbarer gegenüber akzeptablen Veränderungen wird. Für JSON-Daten, welche im Falle der Börsendaten-API benötigt werden, lassen sich mit ScalaCheck leicht anpassbare Generatoren erstellen.
Die Definition eines solchen Tests kann typisch für das Attibut-basierte Testvorgehen sehr knapp gehalten werden:
Im Kern des Tests steht die Aussage, dass die gelesenen und transformierten
Börsendaten dem Domänenobjekt Stock
entsprechen.
Wie dem Programmcode zu entnehmen ist, benötigt die Ausführung des Tests
Generatoren für JsNumber
und JsObject
aus der Play-JSON Bibliothek [4].
Da diese nicht Teil der ScalaCheck Distribution sind, müssen diese zunächst
definiert werden. Im Allgemeinen besteht ein JSON-Objekt aus einer Menge von
Attributen und dazugehörigen Werten, die wiederum Objekte oder einfache
Datentypen wie Strings oder Zahlen sein können. Im Rahmen dieses Beispiels sind
flache JSON-Objekte ausreichend, deren Werte lediglich elementare Datentypen
enthalten können. Für diese müssen zunächst Generatoren definiert werden:
Eine Stärke von ScalaCheck ist die API, welche es ermöglicht, bestehende
Generatoren miteinander zu verbinden. Der JsString
Generator greift zum
Beispiel auf Gen.alphaStr
zu, der uns lesbare Zeichenketten generiert.
Anschließend muss das Ganze in einen JsString
verpackt werden und die
Generator-Definition ist abgeschlossen. Analog können auch JsNumber
Werte
generiert werden, mit dem Unterschied, dass hier ein Wertebereich über den
Gen.chooseNum
Generator festgelegt wird.
In den Beispielen ist hinter dem Generatornamen auch dessen Typ angegeben. Diese Angabe ist optional da der Typ von Scala aus dem Programmkontext abgeleitet werden kann.
Abgeleitete Generatoren
Über die bisher definierten Generatoren können die Basiswerte in einem JSON-Objekt generiert werden. Für die vollständige Objektdefinition muss jedes dieser Werte mit einem Attributnamen in Verbindung gebracht werden. Hierfür wird ein Tuple-Generator definiert, der aufbauend auf den bestehenden Generatoren für Text und Zahlen neue Attribut- und Wertepaare für ein JSON-Objekt erzeugt:
Das Beispiel zeigt, wie ScalaCheck genutzt werden kann, um bestehende
Generatoren zu erweitern. So liefert Gen.oneOf
eine Gleichverteilung der
Werte. Der Generator Gen.frequency
hingegen würde die Werte nach einem zu
definierenden Schlüssel verteilen. Je nach Anwendungsfall hat man so die
richtigen Werkzeuge, um die Zufallsgenerierung in die richtigen Bahnen zu
lenken. Bei Gen.listOfN
kann man, wie der Name schon sagt, bestehende
Generatoren vom Typ T
nutzen und aus ihnen Generatoren vom Typ List[T]
erzeugen.
Wie im Beispiel zu sehen macht es für komplexe Generatoren Sinn statt
mehrfacher Transformation über die map
Funktion, sogenannte
for-comprehensions zu nutzen, welche es ermöglichen, auch bei komplexen
Generatoren einfache Definitionen beizubehalten.
Der abschließende Schritt zum lauffähigen Test, ist die Definition zweier
arbitrary
Generatoren:
Diese werden, wie bereits erwähnt, von ScalaCheck herangezogen, wenn es um die Generierung von Daten geht, da das Framework zwar den erwarteten Datentyp kennt, aber nicht den Generator, welcher passende Instanzen erzeugt. In Scala können die Generatoren über sogenannte implicits [5] registriert werden. Das bedeutet, dass eine Funktion eine Objektinstanz eines bestimmten Typs im Kontext erwartet, welche vom Algorithmus genutzt werden kann, um ein bestimmtes Verhalten umzusetzen. Falls keine Definition im Kontext vorhanden ist, wird dies vom Compiler angezeigt, sodass Laufzeitfehler vermieden werden.
ScalaChecks forAll
erwartet demnach ein als implizit deklariertes Objekt vom
Typ Arbitrary[T]
für alle Parameter vom Typ T
. Dieser Mechanismus kann auch
genutzt werden, um für unterschiedliche Tests verschiedene Generatoren zu
registrieren.
Bedingte Generatoren
Wie so oft kann es im Beispiel der Börsendaten-API dazu kommen, dass valide
JSON-Objekte geliefert werden, diese aber inhaltlich fehlerhaft sind. Die
Stock
Klasse enthält Kennzahlen zum Kursverlauf einer Aktie im Kontext des
aktuellen Jahres. Ist der Höchstwert nun kleiner als der aktuelle Wert oder
dieser kleiner als das Jahresminimum, liegt ein Fehler vor und das Programm
müsste die Eingangsdaten verwerfen.
Die bisher definierten Generatoren liefern zufällige Werte und können daher nicht verwendet werden, um dieses Verhalten zu überprüfen. Hier sind bedingte Generatoren eine Möglichkeit, den relevanten Wertebereich einzuschränken:
Dieser Test ist exemplarisch, wie die Trennung von Datengenerierung und
Testbeschreibung zu lesbaren Tests führen kann. Jeder Entwickler mit
grundlegenden Scala-Erfahrungen sollte in der Lage sein, aus dem Test die
Anforderungen an die validateJson
Funktion abzulesen.
Auch wenn es sich bei bedingten Generatoren um ein hilfreiches Werkzeug handelt, hat diese Technik Grenzen, wenn die Zahl der validen Eingaben klein im Vergleich zum Wertebereich ist. Hier wird ScalaCheck die Generierung nach einer definierten Anzahl von Versuchen abbrechen.
Bezug zur Funktionalen Programmierung
In typisierten funktionalen Programmiersprachen wie Scala oder Haskell spielt Testen eine eher dem Typsystem nachgelagerte Rolle, da mit Ersterem Beweise möglich sind, welche Ausdrücke im Kontext des Systems valide sind und welche nicht. Im Allgemeinen können aber nicht alle Invarianten durch das Typsystem beschrieben werden. Im Kontext funktionaler Sprachen werden daher, neben klassischen Unit-Test Frameworks, bevorzugt Werkzeuge für das Attribut-basierte Testen angeboten.
Auch wenn Attribut-basiertes Testen außerhalb von funktionalen Programmiersprachen eingesetzt werden kann, ist es eng mit der Funktionalen Programmierung verbunden. Die Ursachen hierfür liegen in den Annahmen, die Frameworks wie ScalaCheck über den Programmentwurf treffen. So wird davon ausgegangen, dass das Verhalten einer Funktion nur von ihren Argumenten abhängig ist oder einmal definierte Werte unveränderlich sind. Wie auch in der Anwendungsentwicklung führen diese Annahmen dazu, dass verschiedene Funktionsbelegungen sicher parallel ausgeführt werden können.
Attribut-basiertes Testen überall
Anhand dieses Artikels kann man sehen, dass Attribut-basiertes Testen ein hilfreiches Werkzeug ist, um die Robustheit eines Systems zu erhöhen. Wie so oft bei neueren technischen Entwicklungen, stellt sich die Frage, ob mit ihnen alte Methoden ihren Wert verlieren? Pragmatisch gesehen kann die Antwort darauf nur „Nein“ lauten, da auch dieses Modell mit Nachteilen zu kämpfen hat. Vor allem wenn sich Tests über Systemgrenzen hinweg bewegen, können von Hand ausgewählte Testdaten erforderlich sein, um zeitnah Feedback über das Systemverhalten zu bekommen. Des Weiteren sind domänenspezifische Generatoren ebenfalls Programmcode, welcher mit Fehlern behaftet sein kann. Vor allem bei sehr komplexen Generatoren ist dieses Problem nicht zu vernachlässigen und kann dazu führen, dass Generatoren selbst getestet werden sollten.
Auf der positiven Seite führt die Trennung von Testspezifikation und der Spezifikation von Testdaten zu einfach verständlichen, kurzen Tests. Auch kann die Generierung von Eingabedaten dazu führen, robustere Programme zu entwerfen. Dies ist gerade im Kern der Anwendungsdomäne ein großer Vorteil.
Die Beispiele dieses Artikels sind in der Kombination ScalaCheck mit ScalaTest zur Definition der Assertions umgesetzt. Das vollständige Beispiel kann hier [6] eingesehen werden.
Referenzen
-
ScalaCheck, http://scalacheck.org/ ↩
-
QuickCheck, http://www.cse.chalmers.se/~rjmh/QuickCheck/ ↩
-
Martin Fowler, GivenWhenThen, http://martinfowler.com/bliki/GivenWhenThen.html ↩
-
Play–Json, http://www.playframework.com/documentation/2.2.x/ScalaJson ↩
-
Jesse Eichar, Implicit Parameters, http://daily-scala.blogspot.de/2010/04/implicit-parameters.html ↩
-
Artikel Quelltext, https://gist.github.com/tobnee/37e301792b5478b56861 ↩