Test-Driven Development: Unit Tests mit Scala entwickeln

Schon Kent Beck, der Urvater der testgetriebenen Entwicklung hat in seinem Buch „Test-driven Development“ [4] gefordert, dass ein Test auch immer eine Geschichte erzählen soll. Klassische Erzählungen folgen meist dem bewährten Muster von Einleitung, Hauptteil und Schluss. Analog dazu soll ein Unit Test dieser Form folgen und eine zusammenhängende und für Außenstehende verständliche Geschichte, am besten mit Happy End, erzählen. Gemeinsame Testdaten brechen mit diesem Muster, indem dieselben Testdaten initialisiert werden, erzählen alle Unit Tests immer wieder dieselbe Einleitung. Würden Märchen nach diesem Muster erzählt werden, so würden Frau Holle, Hänsel, Gretel, Dornröschen und Rotkäppchen in jedem Märchen ihr Gastspiel geben.

Würden alle Charaktere in jedem Märchen vorkommen, so würde die Einleitung und Vorstellung aller Personen unnötig lange dauern. Dasselbe gilt für die Erzeugung bzw. das Zurücksetzen der Testdaten; dies muss vor jedem Testlauf erfolgen und verlangsamt die Testausführung unnötig. Einmal mehr ist es Altmeister Kent Beck, der in seinem Buch eine schnelle Ausführung und schnelles Feedback als eines von fünf Kriterien für einen guten Test definiert. Warum also versuchen wir nicht, die Einleitung individuell angepasst an jeden einzelnen Unit Test zu gestalten? Eine häufige Antwort ist, dass das Aufsetzen der Testdaten in Java zu schreibintensiv ist und daher gerne ausgelagert wird. Initialisierungsmethoden können daher in Java sehr schnell unübersichtlich werden. Wieder einmal ist es Scalas kompakte und ausdrucksstarke Syntax, die uns hier weiterhelfen kann. In Scala 2.8 wurde das Scala Collection API komplett überarbeitet und folgt einer klar nachvollziehbaren Linie.

Abb. 3: Scala Collections

Abbildung 3, übernommen aus Martin Oderskys Scala-2.8-Collection-Dokument [5], zeigt, dass im Wesentlichen zwischen Maps, Sets und Sequenzen unterschieden wird. Für jeden Typ gibt es mehrere Implementierungen, wobei hierbei immer zwischen veränderbaren und bevorzugt unveränderbaren Collections unterschieden wird.

Listing 2 enthält eine Methode eines gewöhnlichen Unit Tests, geschrieben in Scala.

Listing 2:
@Test def testCelebrities() = {
  val celebrities = List(
Person("Michael Jackson", 1958, 2009),
Person("Madonna", 1958),
Person("Jesus", deathYear=31))
			
  val no1HitsUk = Map(
7 -> Person("Michael Jackson", 1958, 2009),
13 -> Person("Madonna", 1958),
0 -> Person("Jesus", deathYear=31))
  .
}

Zu Testzwecken werden Collections mit berühmten Persönlichkeiten und deren Anzahl an Nummer1-Hits im vereinigten Königreich erstellt. Die Liste bzw. die Map können jeweils in einer Anweisung erzeugt und mit Elementen gefüllt werden. Im Gegensatz zu Java werden Collections nicht explizit über einen Konstruktor, sondern über so genannte Singleton-Objekte erzeugt. Der Ausdruck
Map(…)führt dazu, dass die Methode scala.collection.immutable.Map.apply(…)des Singleton-Objekts scala.collection.immutable.Map ausgeführt wird. Ist, wie in unserem Fall, der Name der Klasse und des Singleton-Objekts identisch, so wird das Singleton auch als Companion-Objekt bezeichnet. Der Kamerad übernimmt also die Verantwortung für die Erzeugung der Collection.

Die Erzeugung der Collection-Inhalte kommt ebenfalls ohne den Aufruf eines Konstruktors aus. Zur Erzeugung wird zudem ein in Scala 2.8 eingeführtes Feature aufgegriffen: benennbare Argumente mit Default-Werten. Listing 3 zeigt unser eigenes Companion-Objekt, das für die Erzeugung unserer Berühmtheiten verantwortlich ist.

Listing 3:
object Person {
  def apply(name:String, birthYear:Int = 0, deathYear:Option[Int] = None) =
    new Person(name, birthYear, 
      deathYear match {
        case Some(i) => i
        case None => null
      }
    )
}

Am Beispiel des optionalen Todesjahres zeigt sich schon ein kleiner aber feiner Unterschied zu Java: Was in Java null ist, ist in Scala ein Option-Objekt. Eine Option kann entweder ein Some oder ein None sein. Durch dieses Vorgehen umschifft man elegant sämtliche unschönen NullPointerExceptions, die uns allen schon viel zu oft über den Weg gelaufen sind. Sofern eine Person noch lebt, kann das Todesdatum einer Person leer sein. Da wir Java-Code testen, wird die Scala Option für das Todesdatum abhängig vom Wert in Javas null bzw. den entsprechenden Integer-Wert übersetzt.

Die Factory-Methode unseres Companion-Objekts erwartet drei Argumente, die jeweils mit Default-Werten belegt sind. Die Methode kann mit weniger Argumenten ausgeführt werden, die Parameterliste wird entsprechend ihrer Default-Werte aufgefüllt. Da Madonna noch unter uns weilt , kommt sie in Listing 2 ohne Angabe eines Todesjahres aus. Um Parameter mit gleichen Typen zu unterscheiden, müssen diese benannt werden. Bei Jesus ist es daher notwendig, festzulegen, ob es sich beim Jahr 31 um sein Geburts- oder Todesjahr handelt. Das Companion-Objekt aus Listing 3 erzeugt die Personeninstanzen unseres in Java geschriebenen Domänenmodells. Durch benennbare Argumente kann die Reihenfolge der Parameter frei gewählt und beliebig vertauscht werden. Gerade für Testzwecke sind benennbare Argumente mit Default-Werten ein sehr mächtiges Instrument in unserer Werkzeugkiste. Die Default-Werte definieren den Grundzustand der Domänenobjekte, zusätzliche Argumente spezifizieren das Testobjekt weiter.

Doch wir wollen in Tests nicht nur die schöne heile Welt mit vielen validen Domänenobjekten abtesten, sondern auch sehen, wie unser System mit ungültigen Eingaben umgeht. Scala unterstützt einen funktionalen Programmierstil, was bedeutet, dass gültige Domänenobjekte im Handumdrehen dank Funktionen in ungültige verwandelt werden können. Um z. B. zu erfahren, wie das System mit falschen Geburts- und Todesjahren umgehen kann, können wir eine Funktion auf allen Berühmtheiten anwenden: celebrities.foreach(p => manipulateDeathYear(p)).
Auf jeder Person unserer Liste wird also die übergebene Funktion ausgeführt. Scala betrachtet Funktionen als Bürger erster Klasse, sodass diese sich ebenso einfach wie Methoden und Felder definieren lassen: val manipulateDeathYear = (p:Person) => {p.setDeathYear(p.getBirthYear – 1); p}. Die dargestellte Funktion setzt das Todesjahr vor das Geburtsjahr, wodurch das Domänenobjekt einen ungültigen Zustand erreicht. Funktionen haben gegenüber herkömmlichen Methoden den entscheidenden Vorteil, dass sie beliebig herumgereicht werden können. Legt man Funktionen bspw. innerhalb eines Packages ab, so können diese im jedem Test referenziert und verwendet werden: val manipulate = manipulateDeathYear. Der Code zur Manipulation der Testobjekte muss also nicht unnötigerweise in Basisklassen oder Hilfsklassen ausgelagert werden. Funktionen sind an keinen Kontext gebunden und die Testobjekte können durch die Ausführung mehrerer Funktionen hintereinander beliebig manipuliert werden.

Auch zur Formulierung der Erwartungshaltung eignen sich Funktionen besonders gut. Nehmen wir z. B. an, wir hätten einen Service geschaffen, der Michael Jackson wiederbeleben könnte. Nach Ausführung des Service wollen wir natürlich sicherstellen, dass Michael Jackson tatsächlich lebt. Die Überprüfung wird ebenfalls durch eine Funktion umgesetzt:

assert(celebrities.exists(p => p.getName == „Michael Jackson“ && p.isAlive()))

Die exists-Methode ist eine von vielen Methoden, die jede Scala Collection mit sich bringt. Sie erwartet ein Prädikat, das in unserem Fall genau ein lebender Michael Jackson ist. Zwar bietet Apache Commons Collections mit seinen CollectionUtils ebenfalls die Möglichkeit, eine solche Existenzprüfung auf Basis von Prädikaten durchzuführen, allerdings ist dies in Java nur umständlich durch anonyme innere Klassen zu erreichen. Der funktionale Ansatz in Scala ermöglicht es uns, kurz und prägnant unsere Erwartungen zu formulieren. Ganz nebenbei sehen wir hier auch einen ersten Vorgeschmack auf den Einsatz von Assertions in Scala.

Im Gegensatz zu JUnit ist es in TestNG möglich, Testmethoden mit Parametern zu versehen [6]. So genannte DataProvider liefern darüber hinaus die für den Test notwendigen Werte. In Scala kann dasselbe über so genannte implizite Parameter erreicht werden. Der Wert, in unserem Fall Madonna, wird als implizite Variable abgelegt: implicit val madonna = Person(„Madonna“, 1958).

Die implizite Variable kann nun an allen Stellen als Lückenfüller herhalten. Möchte man also einen Test mit Madonna durchführen, so kann die Parameterliste einfach um eine implizite Person erweitert werden: @Test def testWithMadonna(implicit p:Person) = {.}. Das Schlüsselwort implicit ermöglicht es, die Testmethode auch ohne übergebene Parameter auszuführen. In diesem Fall wird die Parameterliste mit Madonna als implizite Variable aufgefüllt.

Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.