Attribut-basiertes Testen mit Scala

Wo hilft funktionale Programmierung bei der Automatisierung von Testaufgaben

Tobias Neef

Das Testen eines Software-Systems ist eine alltägliche Aufgabe mit dem Ziel, dessen Konformität bezüglich einer Spezifikation nachzuweisen. Die grüne Ampel der Testumgebung ist in vielen Projekten das Fieberthermometer, welches die Normaltemperatur des Systems verkündet oder zu Warnungen bei Überschreitung führt. Doch können wir diesem Messgerät trauen? Das Attribut-basiertes Testen ist ein Werkzeug aus der Welt der funktionalen Entwicklung, welches uns helfen kann die Zuversicht in unsere Systeme zu erhöhen.

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:

class Generator {
   val r = new java.util.Random
   def nextZ = r.nextInt
   def nextB = r.nextBoolean
}

val generator = new Generator()
generator.nextZ // 2393
generator.nextZ // -13345
Zufallswerte auf die bekannte Art

Ü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.

// Ein Generator für natürliche Zahlen
def nextN = math.abs(nextZ)

// Ein Generator für ein Intervall als Tupel zweier Ganzzahlen
def nextInterval = (nextN, nextN) match {
   case (a, b) if (a > b) => (b, a)
   case random => random
}
// ...
Kombinierter Zufallsgenerator

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:

val intGenerator = arbitrary[Int]
val stringTupleGenerator = arbitrary[(String, String)]
val listOfStringGenerator = arbitrary[List[String]]
Der arbitrary Generator

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:

def qSum(value: Int) : Int =
   if(value < 10) value
   else value % 9 + qSum(value / 10)
Definition einer Quersummenfunktion

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:

// Funktion zur Prüfung der Teilbarkeit durch 3
def divBy3 (v: Int) = v % 3 == 0

// zu prüfende Eigenschaft für alle Integer Werte
val qSumDivideBy3RuleProperty =
   forAll { value: Int => divBy3(qSum(value)) == divBy3(value) }

qSumDivideBy3RuleProperty.check

// ! Falsified after 13 passed tests.
// > ARG_0: 11
ScalaCheck-Property Beschreibung

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:

{
   "sym" : "ABC",
   "name" : "ABC AG",
   "curr" : "EUR",
   "last" : 42.31,
   "yearHigh" : 43.01,
   "yearLow" : 31.20
}
Börsendaten in JSON

Die Verarbeitung der Daten läuft in 4 Schritten ab.

  1. Beziehe die Daten des Webdienstes in Form eines JSON-Dokuments
  2. Prüfe die syntaktische Wohlgeformtheit des JSON
  3. Extrahiere Symbol, Name und Preis (min, max, aktuell)
  4. 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:

property("JSON-Werte entsprechen Aktie") {
   forAll { (json: JsObject, last: JsNumber, high: JsNumber, low: JsNumber) =>
      val stockJson = json ++ baseStockData(last, high, low)
      Stock.fromJson(stockJson) must
     be(Stock("ABC", "ABC AG", "EUR", last.value, high.value, low.value))
 }
}

def baseStockData(last: JsNumber, yearHigh: JsNumber, yearLow: JsNumber) =
   Json.obj(
      "sym" -> "ABC",
      "name" -> "ABC AG",
      "curr" -> "EUR",
      "last" ->  last,
      "yearHigh" -> yearHigh,
      "yearLow" -> yearLow
   )
Eigenschaftsbeschreibung eines Börsendaten-JSON-Parsers

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:

def jsString: Gen[JsString] =
   Gen.alphaStr.map(str => JsString(str))

def jsNumber: Gen[JsNumber] =
   Gen.chooseNum(0.1, 1000.0).map(dec => JsNumber(dec))
Generatoren für JSON-Text und -Zahlen

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:

def jsTuples: Gen[(String, JsValue)] = for {
   k <- Gen.alphaStr
   v <- oneOf(jsNumber, jsString)
} yield (k, v)

def jsObjects: Gen[JsObject] = for {
   amount <- Gen.chooseNum(1, 10)
   elems <- Gen.listOfN(amount, jsTuples)
} yield JsObject(elems)
Generatoren für JSON-Objekte

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:

   implicit val arbObj: Arbitrary[JsObject] = Arbitrary(jsObjects)
   implicit val arbNum: Arbitrary[JsNumber] = Arbitrary(jsNumber)
Arbitrary Generatoren für JSON-Daten

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:

property("Nicht valide Kursdaten werden zurückgewiesen") {
   forAll { (last: JsNumber, high: JsNumber, low: JsNumber) =>
      whenever(low.value > last.value && last.value > high.value) {
         val stockJson = baseStockData(last, high, low)
         Stock.validateJson(stockJson) must not be ('defined)
      }
   }
}
Bedingte Generatoren

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

  1. ScalaCheck, http://scalacheck.org/  ↩

  2. QuickCheck, http://www.cse.chalmers.se/~rjmh/QuickCheck/  ↩

  3. Martin Fowler, GivenWhenThen, http://martinfowler.com/bliki/GivenWhenThen.html  ↩

  4. Play-Json, http://www.playframework.com/documentation/2.2.x/ScalaJson  ↩

  5. Jesse Eichar, Implicit Parameters, http://daily-scala.blogspot.de/2010/04/implicit-parameters.html  ↩

  6. Artikel Quelltext, https://gist.github.com/tobnee/37e301792b5478b56861  ↩

Thumb dsc01823

Tobias Neef is a Developer and Senior Consultant at innoQ with 10 years of experience in Software-Development. He focuses on designing and implementing distributed, user-centered software systems. Recently he is dedicated to bring functional and reactive technologies like Scala, Play and Akka to Enterprise and Web-Companies.

More content

Java spektrum
Dieser Artikel ist ursprünglich in Ausgabe 02/2014 der Zeitschrift JavaSPEKTRUM erschienen. Die Veröffentlichung auf innoq.com erfolgt mit freundlicher Genehmigung des SIGS-Datacom-Verlags.

Comments

Please accept our cookie agreement to see full comments functionality. Read more