Sprache als Werkzeug: DSLs mit Kotlin bauen

Seite 2: Eine DSL zur testgetriebenen Entwicklung mit Gherkin

Inhaltsverzeichnis

Die zuvor beschriebenen Konzepte sollen anhand einer DSL für Gherkin-basierte Tests veranschaulicht werden.

Gherkin, bekannt geworden durch das Testwerkzeug Cucumber, nutzt eine vorgegebene Form zur Strukturierung automatisierter Testfälle, besser bekannt als "Given, When, Then". Dabei beschreibt der Given-Teil die Testvorbereitung, der When-Teil die Testausführung und der Then-Teil die Validierung der Ergebnisse im Beispiel Calculator Test. Ohne Einsatz eines entsprechenden Frameworks oder einer DSL lässt sich diese Strukturierung zumeist lediglich als Vorgabe an Entwickler übergeben. Ob sie tatsächlich eingehalten wird, lässt sich kaum überprüfen, da sich die Struktur dem Compiler und somit auch der IDE nicht erschließt. Eine Kontrolle über die Ausführung ist daher ebenfalls nicht möglich.

Strukturierung automatisierter Testfälle mit Gherkin (Abb. 1).

Listing 3: Calculator Test (Zeilen 14 bis 24)

de.digitalfrontiers.gherkin

import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.jupiter.api.Test

class Calculator {

fun add(a: Int, b: Int) = a + b
}

class CalculatorTest {

@Test
fun `adds up numbers (classic)`() {
// given
val subject = Calculator()

// when
val result = subject.add(2, 3)

// then
assertThat(result).isEqualTo(5)
}

@Test
fun `adds up numbers (gherkin)`() {
given {
Calculator()
}.on {
add(2, 3)
}.then {
isEqualTo(5)
}
}
}

Eine entsprechende DSL kann helfen, die Struktur einzuhalten. Darüber hinaus kapselt sie die eigentliche Testausführung, wodurch sich Aspekte wie die mehrfache Testausführung zentral innerhalb der DSL umsetzen lassen. Schließlich lässt sich der zu erstellende Programmcode mit der DSL auf ein Minimum beschränken, da insbesondere temporäre Variablenzuweisungen entfallen können.

Der DSL-basierte Test zeigt die klare Strukturierung nach Gherkin auf, auch wenn hier anstatt when, einem reservierten Schlüsselwort in Kotlin, on verwendet wird. Der given-Block erwartet die Rückgabe des Testsubjekts, wodurch eine separate Variablenzuweisung entfallen kann. Da Kotlin den jeweils letzten Ausdruck eines Lambdas als Rückgabe wertet, kann hier sogar das explizite return entfallen. Der on-Block empfängt das Testsubjekt und führt die zu testende Operation durch, bevor er wiederum das Ergebnis zurückgibt. Der then-Block empfängt schließlich das Ergebnis gekapselt mittels des verwendeten Assertion-Frameworks, so dass hier assertThat() entfallen kann.

Listing 4: Calculator Test (Zeilen 26 bis 35)

package de.digitalfrontiers.gherkin

import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.jupiter.api.Test

class Calculator {

fun add(a: Int, b: Int) = a + b
}

class CalculatorTest {

@Test
fun `adds up numbers (classic)`() {
// given
val subject = Calculator()

// when
val result = subject.add(2, 3)

// then
assertThat(result).isEqualTo(5)
}

@Test
fun `adds up numbers (gherkin)`() {
given {
Calculator()
}.on {
add(2, 3)
}.then {
isEqualTo(5)
}
}
}

Die Umsetzung einer solchen DSL nutzt die erwähnten "Lambdas with Receiver", um Entwicklern die entsprechenden Empfänger-Referenzen für die jeweiligen Kontexte der given-, on- und then-Blöcke zur Verfügung zu stellen.

Den Einstieg im Beispiel Gherkin-DSL stellt dabei der Konstruktoraufruf der Klasse given dar, die hier bewusst mit einem Kleinbuchstaben beginnt. Sie speichert den ihr übergebenen Lambda-Ausdruck, der dafür verantwortlich ist, das generische Testsubjekt zu erzeugen. Die on-Methode ist ein Member der Klasse und steht somit nur infolge eines vorausgegangenen given-Aufrufs zur Verfügung. Sie erwartet einen "Lambda with Receiver"-Ausdruck, wobei der Empfänger in diesem Fall das Testsubjekt ist, sodass es sich direkt im Lambda aufrufen lässt.

Listing 5: Gherkin DSL

package de.digitalfrontiers.gherkin

import assertk.Assert
import assertk.assertThat

class given<S>(private val setup: () -> S) {

fun <R> on(test: S.() -> R): Result<R> =
Result { setup().test() }
}

class Result<R>(private val result: () -> R) {

fun then(assert: Assert<R>.() -> Unit) {
assertThat(result()).assert()
}
}

Der Rückgabewert wird durch eine weitere Result-Klasse repräsentiert. Deren Aufgabe ist es, das Ausführen der zuvor gespeicherten Funktionskette anzustoßen und schließlich in Zusammenspiel mit dem verwendeten Assertion-Framework die Validierung auszuführen. Hierbei wird das vom Framework bereitgestellte Assert-Objekt erneut als Empfänger an den then-Block übergeben.