Über unsMediaKontaktImpressum
Kai Schmidt & Markus Schwarz 17. Dezember 2019

Testen mit Kotlin

Früher hörte man manchmal Aussagen wie "Cowboys testen nicht" oder "wer testet ist feige". So langsam ist das Thema Testen bei den Entwicklern jedoch angekommen. Wer eine qualitative Software schreiben möchte, wird an ebenso qualitativen Tests nicht vorbei kommen. An Softwaresystemen werden stetig Änderungen vorgenommen, doch sie bergen die Gefahr, dass manch andere Teile der Software plötzlich nicht mehr funktionieren. Schließlich sind heutige Software-Systeme komplex – seien sie architektonisch noch so gut durchdacht und strukturiert.

Während sich für Programmiersprachen wie Java über die Jahre gewisse Tools und Frameworks etabliert haben, sind für Ökosysteme jüngerer Sprachen noch keine Quasi-Standards etabliert und diese sind daher schnelleren Veränderungszyklen unterworfen. Durch die hohe Interoperabilität mit Java können zwar auch in Kotlin die Java-Platzhirsche eingesetzt werden, doch sind in den vergangenen Jahren alternative Lösungen speziell für Tests mit Kotlin entstanden. Ist der Einsatz dieser neuen Tools überhaupt notwendig oder sinnvoll? Dieser Artikel soll genau diese Aspekte näher beleuchten.

Das Thema Testen deckt weitaus mehr Teilbereiche ab, als wir im Rahmen dieses Artikels beleuchten können. Daher fokussieren wir uns auf Unit- und Integrationstests und sehen bspw. von UI-Tests, sowie speziellen Testzielen wie Performance-Tests oder Monkey-Tests ab. Wir werden nicht explizit auf Testprozesse wie Test Driven Development (TDD) [1] und Behavior Driven Development (BDD) [2] eingehen. Stattdessen werden wir prüfen, wieso das Testen wichtig ist und die Frameworks vorstellen, die sich auf besondere Aspekte dieser Erkenntnisse spezialisieren. Weiterhin sei darauf hingewiesen, dass die Autoren dieses Artikels keine Android-Entwickler sind. Während wir davon ausgehen, dass die hier genannten Informationen ebenfalls für die Android-Entwicklung gelten, liegen unsere Erfahrungen mit Kotlin in der Backend-Entwicklung als Alternative zu Java.

Begleitend zu diesem Artikel wurde ein GitHub-Repository [3] erstellt, über das alle verwendeten Frameworks leicht ausprobiert werden können. Die in diesem Artikel verwendeten Code-Auszüge sind dem dort befindlichen Projekt entnommen.

In der Kotlin-Welt tummeln sich mittlerweile einige Testframeworks, die um die Gunst des Entwicklers buhlen. Wir können im Rahmen dieses Artikels nicht auf alle eingehen, sondern möchten einen Überblick geben. Wer sich einen möglichst umfassenden Blick über die Frameworks verschaffen möchte, sei auf die Zusammenstellung von Libhunt verwiesen [4].

Warum ist Testen so wichtig?

Bevor wir uns näher mit der Vorstellung der ausgewählten Testframeworks beschäftigen, nähern wir uns der Frage, aus welchen Gründen das Thema Testen bereits immer wichtig war und mit der Zeit zunehmend an Bedeutung gewinnt.

Am augenscheinlichsten für die Notwendigkeit von Tests ist die Sicherstellung der Funktionalität. Wer etwas implementiert hat, möchte davon ausgehen, dass die erstellten Systemfunktionen korrekt funktionieren und auch zukünftig – bei weiterer Anpassung der Software – die an das System gestellten (funktionale oder nicht-funktionale) Anforderungen möglichst vollständig erfüllen.

Durch agile Arbeitsweisen, DevOps und Continuous Delivery stellen automatisierte Tests mehr und mehr ein notwendiges Sicherheitsnetz für ständige Anpassungen des Systems dar, um auf schnelle Art und Weise kontinuierlichen und stabilen Kundennutzen zu stiften. Sie sind nachvollziehbar, stellen sicher, dass ein definierter Ablauf jedes Mal gleich durchlaufen wird und bieten dem Entwickler eine möglichst kurze Feedback-Schleife. Gerade durch die Arbeit auf kleineren Inkrementen und stetiges automatisiertes Testen, kann eine Fehlerursache leichter nachvollzogen werden und unterstützt so den Entwickler in der Erstellung neuer Funktionen. Zusätzlich können Tests die Tätigkeiten eines Entwicklers erleichtern und beschleunigen. Gut geschnittene Tests bieten ideale Einstiegspunkte für die gerade auszuarbeitende Funktionalität, sowie zur Analyse und Behebung von Fehlern.

Neben dem Kernaspekt der Sicherstellung der Funktionalität, dienen Tests zur Dokumentation des Produktionscodes. Sie zeigen durch Beispiele das gewünschte Verhalten einzelner Teile der Anwendung oder machen Invarianten der Anwendung erkennbar. Dies hilft Entwicklern, die entsprechenden Fragmente des Programmcodes leichter nachzuvollziehen.

Testframeworks gehen mit unterschiedlichem Fokus auf die genannten Aspekte ein. Dies versuchen wir bei der Vorstellung der einzelnen Frameworks deutlich zu machen. Zusätzlich bietet Kotlin durch seine Spracheigenschaften Aspekte, die bei der Erstellung von Tests, unabhängig vom verwendeten Framework, durchaus hilfreich sein können.

Was bringt Kotlin mit?

Lange wurde Kotlin in der Community als die Sprache für Android-Entwicklung gesehen und sicherlich hat sie dadurch einen gewissen Durchbruch geschafft – nicht zuletzt durch die Kundgebungen auf der Google I/O zur gleichberechtigten (2017) bzw. zur bevorzugten (2019) Programmiersprache für Android [5,6]. Aber immer mehr Entwickler erkennen die Vorteile, die die Sprache auch für den serverseitigen Einsatz mit sich bringt.

Im Folgenden stellen wir einige Sprach-Features vor, die nicht nur im Produktionscode zum Einsatz kommen, sondern auch beim Schreiben von Tests sehr hilfreich sind. Für interessierte Leser, die einen umfassenderen Blick auf Kotlin bekommen möchten, lohnt sich sicher ein Blick auf die bereits in der Informatik Aktuell erschienen Artikel:

  • Von Java zu Kotlin: In kleinen Schritten zum Ziel [7]
  • Innovative Sprach-Features in Kotlin [8]
  • Ist Kotlin das bessere Java? Eine Einführung [9]

(Immutable) Data Classes

Wird eine Klasse mit dem Modifier data versehen, werden durch den Kotlin Compiler Getter, Setter, Konstruktoren sowie toString-, hashCode- und equals-Methoden generiert. Datenklassen sind somit vergleichbar mit den Annotationen @Value bzw. @Data des Lombok-Projektes [10], aber direkt in die Sprache integriert und prägnanter.

data class Movie(val name: String, val description: String? = null, val rating: Int? = null)

Werden alle Properties mit dem Schlüsselwort valmarkiert, sind Instanzen dieser Klasse unveränderlich. Hierdurch lässt sich beispielsweise sicherstellen, dass das Testobjekt während des Testdurchlaufs nicht verändert wird. Auf diese Weise kann es unbesorgt für weitere Tests wiederverwendet werden, ohne es neu initialisieren zu müssen.

Named Parameters / Default Values

Kotlin bietet die Möglichkeit, die Parameter beim Aufruf einer Funktion oder eines Konstruktors mit Hilfe ihres Namens anzugeben. In Kombination mit Default-Parametern lassen sich damit sehr einfach Fabrikmethoden für Business-Objekte in Tests erstellen. Der Test-Code wird dadurch deutlich besser lesbar, weil nur die für den konkreten Test relevanten Properties gesetzt werden müssen.

fun createMovie(
   name: String = "The Godfather",
   description: String = "The aging patriarch of an organized crime dynasty...",
   rating: Int? = 4
): Movie = Movie(name = name, description = description, rating = rating)

val testMovie = createMovie(name = "Inception")

Sind unsere Business-Objekte ohnehin als Data Classes modelliert, können wir uns beim Schreiben von Tests die automatisch generierte copy-Methode zunutze machen. Hierfür werden gültige Instanzen von Business-Objekten als Fixture hinterlegt und aus ihnen mit Hilfe der copy-Methode neue Instanzen mit spezifischen Werten für konkrete Tests erstellt.

val movieFixture = Movie(
   name = "The Godfather",
   description = "The aging patriarch of an organized crime dynasty...",
   rating = 4
)

val testMovie = movieFixture.copy(name = "Pulp Fiction")

Extension-Functions

Mit Hilfe von Extension-Functions lassen sich "von außen" Funktionen und Attribute auf Typen definieren. Durch diesen Mechanismus lassen sich Tests lesbarer und damit oft wartbarer gestalten, ohne dafür den Produktionscode verändern zu müssen.

fun Movie.topRated(): Movie = this.copy(rating = 5)
val testMovie = movieFixture.topRated()

Leerzeichen in Funktionsnamen

Wenn der Name einer Funktion in Backticks eingeschlossen ist, dann erlaubt Kotlin darin unter anderem Leerzeichen. Während das in Produktionscode eher unüblich ist, bietet dies die Möglichkeit, Testfälle deutlich besser zu beschreiben. Auf diese Weise erhält man in der Zusammenstellung des Testlaufs in der IDE oder in generierten Reports leichter zu lesende Namen.

Test-Frameworks und Bibliotheken

Im Java-Umfeld gibt es bereits reichlich Frameworks und Bibliotheken, die uns bei der Erstellung von Tests behilflich sein wollen. Da Kotlin vollständig interoperabel mit Java ist, können alle diese Tools ohne weiteres verwendet werden, um Kotlin-Code zu testen. Bevor wir einige reine Kotlin-Bibliotheken vorstellen, gilt es zu verstehen, welche Aufgaben Test-Bibliotheken grundsätzlich erfüllen.

  • Test-Engine – Eine Test-Engine stellt die Infrastruktur bereit, um Tests aus der IDE oder von der Kommandozeile zu starten und deren Ergebnis auszuwerten. Prominente Beispiele hierfür sind JUnit und TestNG.
  • Assertions/Matcher – Assertion-Bibliotheken sind typischerweise Funktionssammlungen, um das tatsächliche Ergebnis auf einfache Art und Weise mit dem Erwarteten zu vergleichen. Neben JUnit gibt es in diesem Bereich mit AssertJ und Hamcrest noch weitere sehr beliebte Tools.
  • Test Structure – Einige Frameworks geben dem Entwickler Strukturierungselemente an die Hand, mit denen Tests leichter in logischen Einheiten organisiert werden. In JUnit ist dies über die Gliederung der Tests in Testsuites, Groups bzw. Tags oder über die JUnit5-Annotation @Nested möglich.
  • Mocking – Verwendet der zu testende Code Abhängigkeiten, deren Funktionalität nicht im Fokus des Tests steht und den Test somit nicht beeinflussen sollen, lässt sich deren Verhalten mit Hilfe von Mocks simulieren. Die wohl bekanntesten Vertreter im Java-Umfeld für diese Aufgabe sind Mockito, EasyMock und Powermock.

Die wachsende Beliebtheit von Kotlin und der Wunsch nach idiomatischen Code [11], hat in den letzten Jahren zahlreiche interessante Bibliotheken zutage gebracht. Einige davon werden wir in den folgenden Abschnitten näher vorstellen.

kotlin.test (Jetbrains)

Die Testbibliothek kotlin.test  [12] wird direkt von Jetbrains bereitgestellt. Sie enthält typische Annotationen und Assertions und fungiert als Adapter zum verwendeten Test-Framework. Auf der JVM werden aktuell JUnit, JUnit5 sowie TestNG unterstützt. Abhängig von dem eingesetzten Test-Runner zieht man sich das zugehörige Modul in die Abhängigkeiten des Projektes. Interessierte Entwickler, die ihre Anwendung mit JUnit5 als Test-Runner testen möchten, fügen beispielsweise folgende Einträge in Ihre build.gradle hinzu:

testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.5.2'

Hierdurch erhält man automatisch die transitive Abhängigkeit zur JUnit5-Api, so dass als eigentlichen JUnit5-Test-Runner die Jupiter Engine noch explizit aufgenommen werden muss.

Durch kotlin.test können Tests aufgebaut werden, ohne in den Implementierungsklassen eine direkte Abhängigkeit zu dem tatsächlich eingesetzten Framework aufzuweisen. Der durch kotlin.test mitgelieferte Asserter ist dabei die Schnittstelle. Konkrete Implementierungen finden sich dann über die jeweils eingebundene Abhängigkeit und auch die gängige Annotation @Test wird über kotlin.test abstrahiert. Tests sehen dadurch fast so aus wie mit dem eingesetzten Runner ohnehin gewohnt.

import kotlin.test.Test
import kotlin.test.assertEquals

class CalculatorTest {
   @Test
   fun `correctly adds two numbers`() {
       assertEquals(3, Calculator().add(1, 2))
   }
}

Die Testmethode würde mit JUnit5 genau gleich aussehen, nur die imports kämen natürlich direkt aus dem API-Paket von Jupiter. Selbstverständlich lassen sich Bibliotheken wie AssertJ oder Hamcrest zusätzlich einsetzen.

Solange keine konkreten Feature- oder Implementierungs-Ansprüche an ein Testframework bestehen, kann man damit auf dem Abstraktionslevel von kotlin.test bleiben und die Tests ggf. später ohne Migrationsaufwand (im Idealfall durch eine angepasste Abhängigkeitskonfiguration im eingesetzten Build-Werkzeug) auf eines der anderen unterstützten Frameworks umstellen.

Ein weiterer Aspekt der beim Einsatz von kotlin.test zum Tragen kommen kann, ist die Unterstützung von Multiplatform-Projekten [13]. Neben den oben genannten Test-Frameworks für die JVM, bietet kotlin.test dieselbe Abstraktion für Kotlin-Code der nach Javascript übersetzt wird. Hier werden die dort üblichen Frameworks Jasmine, Mocha und Jest unterstützt. Multiplatform und das Modul kotlin-test-js sind aktuell noch im Stadium eines experimentellen Features.

Spek

Spek ist ein Test-Framework, das sich Ideen des Behaviour Driven Development zu eigen macht. Das Ziel hierbei ist es, die Tests nicht an technischen Objekten, sondern am spezifizierten Verhalten der Software zu orientieren. Die Testfälle sollen in der Sprache der fachlichen Spezifikation (bspw. der Akzeptanzkriterien aus der User Story) verfasst werden, so dass grüne Tests nicht nur ein Zeichen für funktionierende Software sind, sondern zudem das spezifikationsgemäße Verhalten dokumentieren.

Spek bietet mit dem Specification Style und dem Gherkin Style zwei Stilrichtungen für die Erstellung von Tests. Beide sind durch eigene Domain Specific Languages (DSL) umgesetzt.

Angenommen, wir haben ein RSVP-System, bei dem Gäste zu einem Event zu- oder absagen können.

class RsvpService {
   private val guests = mutableMapOf<String, Boolean>()

   val guestCount: Int
       get() = guests.count { it.value }

   fun respond(name: String, participating: Boolean) {
       guests[name] = participating
   }

   fun hasResponded(name: String): Boolean = guests.containsKey(name)
   fun isParticipating(name: String): Boolean = guests[name] ?: false
}

Anstatt nun die einzelnen Funktionen des Services isoliert mit Unit-Tests zu prüfen, wird das Verhalten des gesamten Service anhand von Testfällen geprüft. Optimalerweise sind die Testfälle Teil der Spezifikation oder werden gemeinsam mit dem Fachbereich erstellt. Beispielsweise könnten die Testfälle folgendermaßen beschrieben sein.

Wenn ein Gast das erste Mal auf ein Event antwortet
   und diese Antwort positiv ist,
dann nimmt diese Person teil
   und die Anzahl der Gäste erhöht sich um eine Person
   und diese Person hat geantwortet.

Wenn ein Gast das erste Mal auf ein Event antwortet
   und diese Antwort negativ ist,
dann nimmt diese Person nicht teil
   und die Anzahl der Gäste bleibt unverändert
   und diese Person hat geantwortet.

Der Specification Style von Spek ist angelehnt an Jasmine (Javascript) und RSpec (Ruby) und bietet durch die Schachtelung von describe- und context-Blöcken die Möglichkeit, auch komplexe Testfälle einfach zu beschreiben. Das folgende Beispiel zeigt den strukturellen Aufbau eines solchen Spezifikationstests für die oben beschriebenen Testfälle. Jeder it-Block enthält hierbei genau eine Assertion.

Damit wir uns in diesem Beispiel auf den Strukturierungsaspekt konzentrieren können, sind die konkreten Implementierungen nicht explizit dargestellt. Sie sind dem den Artikel begleitenden GitHub-Repository zu entnehmen.

internal object RsvpServiceSpek : Spek({
   describe("Guest is responding") {
       context("without previous responses to this event") {
           context("with a positive response") {
               it("increases the guest count to one") { ... }
               it("marks the person as a responder") { ... }
               it("marks the person as a participating guest") { ... }
           }

           context("with a negative response") {
               it("does not change the guest count") { ... }
               it("marks the person as a responder") { ... }
               it("marks the person as a non participating guest") { ... }
           }
       }
   }
})

Analog dazu lassen sich mit dem Gherkin-Style die Testfälle in Features und Szenarien unterteilen, die dann mit der bei Gherkin [14] üblichen Abfolge von Given-When-Then definiert werden.

internal object RsvpServiceFeature : Spek({
   Feature("Guest sends a response") {
       Scenario("A positive initial response") {
           Given("the user has not yet responded") { … }
           When("responding positively") { … }
           Then("the guest count is increased by one") { ... }
           And("the person is marked as a responder") { ... }
           And("the person is marked as a participating guest") { ... }
       }

       Scenario("A negative initial response") {
           Given("the user has not yet responded") { … }
           When("responding negatively") { ... }
           Then("the guest count remains unchanged") { ... }
           And("the person is marked as a responder") { ... }
           And("the person is marked as a non participating guest") { ... }
       }
   }
})

Welche der beiden Varianten gewählt wird, ist Geschmackssache und hängt vom Anwendungsfall ab. Ist die Anzahl der möglichen Szenarien durch wenige Eingabeparameter überschaubar, ist die Gherkin-Schreibweise eine sehr gute Möglichkeit, um spezifiziertes Verhalten durch Tests zu beschreiben und abzusichern. Der Specification Style ermöglicht es, durch Schachtelung der Blöcke selbst bei großer Anzahl an Kombinationen, Tests übersichtlich und gut wartbar zu schreiben.

Für Spek existiert ein Plugin namens "Spek Framework" für IntelliJ IDEA und Android Studio. Hiermit lassen sich sowohl ganze Tests als auch einzelne Blöcke wie gewohnt direkt aus der IDE starten und die Testergebnisse analysieren. Durch das Plugin sind die Ergebnisse der einzelnen Blöcke im Testresultat gut erkennbar:

KotlinTest (KotlinTest.io)

Abgesehen von dem sehr ähnlichen Namen hat KotlinTest verglichen mit dem zuvor vorgestellten kotlin.test wenig gemeinsam. Die beiden Bibliotheken verfolgen unterschiedliche Ziele. Während kotlin.test darauf abzielt, eine Abstraktion auf die verschiedenen Frameworks zu geben, ist KotlinTest zu einem breiten Werkzeugkasten herangewachsen und bietet Matcher, Assertions, Möglichkeiten der Teststrukturierung, Testdatengenerierung und noch einiges mehr. Im Rahmen dieses Artikels werden wir daher nicht auf alle Funktionen im Detail eingehen können.

Das wahrscheinlich bekannteste und meist verbreitete Einsatzgebiet von KotlinTest ist das Prüfen der Ergebniswerte (Assertions) über Methoden auf den Objekten selbst. Insbesondere in früheren Versionen war dies der Kernaspekt von KotlinTest und es ist nach wie vor möglich, ausschließlich die Kotlintest-Assertions einzubinden.

testImplementation "io.kotlintest:kotlintest-assertions:3.4.2"

Die Assertions von KotlinTest verzichten auf den Einsatz von Methoden wie bspw. assertThat() oder expect(), sondern erweitern die Ergebnistypen mit Hilfe von Extension-Functions um Methoden, die mit should beginnen. Eine Assertion mit KotlinTest sieht bspw. so aus:

@Test
fun `correctly adds multiple numbers`() {
   val result = subject.add(1, 2, 3, 4)
   result.shouldBe(10)    // Entspricht: assertEquals(10, result)
}

Eine Assertion ist damit zwar nicht mehr so explizit zu erkennen, kann aber auf der anderen Seite den Lesefluss des Tests verbessern. Was nun genau präferiert wird, muss jeder (bzw. jedes Team) für sich entscheiden.

Eine Alternative zur hier dargestellten Schreibweise bieten Funktionen in der Infix-Notation. Sie ermöglichen das Weglassen der Punkte bzw. Klammern, wodurch die Prüfung einer domänenspezifischen Sprache ähnelt. In Infix-Notation sähe die gleiche Assertion wie folgt aus:

result shouldBe 10

Als Entwickler empfiehlt sich dennoch die Form der Extension-Function. Diese passt meist besser in den Stil des Testfalls und wird durch eingesetzte IDEs besser unterstützt.

Es gibt eine ganze Reihe vordefinierter Matcher [15], die das Entwicklerleben erleichtern können. Bspw. lässt sich über die Methode shouldContainExactlyInAnyOrder eine Collection darauf prüfen, dass genau die gleichen Einträge einer anderen Collection enthalten sind, die Reihenfolge der Einträge jedoch keine Rolle spielen soll.

Sofern die vordefinierten Matcher nicht ausreichen, können eigene Matcher um einiges eleganter definiert werden als bspw. Custom Matcher in AssertJ [16]. Hierfür muss die Methode test aus dem Matcher-Interface des zu prüfenden Typs implementiert und dann über eine Extension-Function an den jeweiligen Typ gebunden werden. Hier ein Matcher, der beispielsweise sicherstellt, dass der Rsvp-Service Gäste gelistet hat:

fun haveGuests() = object : Matcher<RsvpService> {
   override fun test(value: RsvpService) = MatcherResult(value.guestCount > 0, "service should return some guests", "service should not return any guests but returned ${value.guestCount}")
}

Hierdurch könnte im Testfall bereits die Infix-Notation genutzt werden:

service should haveGuests()
service shouldNot haveGuests()

Um die jeweils zugehörige Extension-Function nutzen zu können, muss diese noch auf dem zugehörigen Typen (in unserem Fall der RSVPService) hinzugefügt werden:

private fun RsvpService.hasGuests() = this should haveGuests() // ermöglicht service.hasGuests
private fun RsvpService.hasNoGuests() = this shouldNot haveGuests() // ermöglicht service.hasNoGuests

Eine weitere sehr dynamische Methode, um Anforderungen an Listenelementen auszudrücken, sind Inspectors. Über eine Lambda lässt sich eine Anzahl von Assertions nutzen, die für alle oder eine bestimmte Anzahl von Elementen gültig sein sollen:

val guests = listOf("Joe", "Jironemo")
guests.forAll {  //Inspector forAll stellt sicher, dass alle Assertions für alle Listenelement erfüllt sind
   it.shouldStartWith("J")
   it.shouldContain("o")
   it.shouldContain("e")
}

Daneben bietet KotlinTest, ähnlich zu Spek, Möglichkeiten, seine Tests zu strukturieren. Gegenüber Spek kommt KotlinTest nicht mit zwei Geschmacksrichtungen daher, sondern gleich mit 10 sogenannten Test-Styles. Alle Test-Styles sind in der Dokumentation beispielhaft dargestellt [17] und ähneln stark der mit Spek bereits vorgestellten Funktionalität, so dass wir auf eine gesonderte Übersicht verzichten. Dieser Variantenreichtum könnte allerdings Fluch und Segen gleichermaßen sein. Damit die Code-Basis nicht zu unübersichtlich wird, sollte man sich im Projekt auf ein verhältnismäßig kleines Subset verständigen oder ggf. vollständig darauf verzichten und möglichst sprechende Methodennamen verwenden.

KotlinTest ermöglicht die Erstellung parametrierbarer Tests. Auf diese Weise lassen sich gleiche Logiken unter Berücksichtigung unterschiedlicher Werte testen, ohne die Testlogik selbst mehrfach definieren zu müssen:

@Test
fun `correctly adds two numbers`() {
   forall(
       row(1, 2, 3),
       row(1, -2, -1)
   )
   { addend1, addend2, sum ->
       subject.add(addend1, addend2) shouldBe sum
   }
}

Der Test wird entsprechend zweimal durchlaufen. Beim ersten Durchlauf wird geprüft, ob bei der Addition der Zahlen 1 und 2 das Ergebnis 3 zurückgegeben wird. Beim zweiten Durchlauf wird die Addition der Zahlen 1 und -2 auf das Ergebnis -1 überprüft.

Eine ähnliche Funktionalität bietet JUnit5 durch das separate Artefakt junit.jupiter.params. Sollte man also die direkte Abhängigkeit gegenüber JUnit5 nicht scheuen, könnte der Entwickler das gleiche Ergebnis über die hierdurch erhaltenen JUnit5-Annotations erzielen:

@ParameterizedTest
@CsvSource(
   "1, 2, 3",
   "1, -2, -1"
)
fun `correctly adds multiple numbers`(addend1 : Int, addend2 : Int, sum : Int) {
   subject.add(addend1, addend2) shouldBe sum
}

Kluent

Als Alternative zu den KotlinTest-Assertions bietet sich die Bibliothek Kluent  [18] an. Kluent ist ein schlanker Aufsatz mit einer Fluent-API auf JUnit-Assertions und Mockito und bietet hierüber eine Nutzung, die natürlichen Sätzen möglichst ähnlich sind. Wie bei KotlinTest werden diese in den beiden Geschmacksrichtungen Extension-Function und Infix-Notation angeboten.

result.shouldBeInRange(2, 4).shouldEqual(3)
result `should be in range` 2..4 and result `should equal` 3

Der Umfang der Assertions von Kluent ist vergleichbar mit den von KotlinTest angebotenen Möglichkeiten. Es sei noch erwähnt, dass Kluent zwar unter der Haube die Assertions von kotlin.test verwendet und somit eigentlich framework-agnostisch ist, aber dennoch in der aktuellen Version eine Abhängigkeit zu JUnit4 mitbringt.

Strikt

Strikt [19] ist eine modular aufgebaute Bibliothek an Assertion-Funktionen, die in Kombination mit jedem beliebigen Test-Runner eingesetzt werden kann.

expectThat(subject.getGuestList())
   .isNotEmpty()
   .hasSize(2)
   .all {
       startsWith("J")
       contains("o")
       contains("e")
   }

Außerdem werden, ähnlich zu KotlinTest, "Soft Assertions" unterstützt, durch die über einen Lambda-Block garantiert werden kann, dass alle darin enthaltenen Assertions geprüft werden. Ohne "Soft Assertions" wäre der Testfall nach der ersten fehlschlagenden Assertion beendet.

expect {
   that(subject.guestCount).isEqualTo(2)
   that(subject.hasResponded(name)).isTrue()
   that(subject.isParticipating(name)).isTrue()
}

Neben den Basisfunktionen existieren optionale Module mit speziellen Assertions für Bibliotheken wie Spring, Arrow und Protobuf.

AssertK

Wer die Funktionsweise von AssertJ gewohnt ist, dem sei das Kotlin-Pendant AssertK [20] ans Herz gelegt. Jede Assertion wird mit der Methode assertThat eingeleitet und ist daher auf den ersten Blick erkennbar. Einige mitgelieferte Matcher erleichtern das Formulieren von Assertions ungemein. Auch mit AssertK lassen sich mehrere Assertions einfach zusammenfassen.

assertThat(subject.getGuestList()).all {
   isNotEmpty()
   hasSize(2)
   each {
       startWith("J")
       haveSubstring("o")
   }
}

Wer eigene Matcher schreiben möchte, erhält eine leicht zu erlernende Syntax, die einer DSL sehr nahe ist. Ebenfalls ließe sich unser RsvpService mit Hilfe einer Extension-Function auf der Assert-Klasse um eine hasGuests-Assertion erweitern.

fun Assert<RsvpService>.hasGuests() {
   prop("guestCount", RsvpService::guestCount).isGreaterThan(0)
}

Im eigentlichen Test können wir diese Assertion dann folgendermaßen verwenden.

assertThat(subject).hasGuests()

MockK und Mockito-Kotlin

Ob und in welchen Fällen die Verwendung von Mocks und Mocking-Frameworks bei der Erstellung von Tests eine gute Idee ist, sei einmal dahingestellt [21], aber der Vollständigkeit halber seien an dieser Stelle noch das Kotlin eigene Mocking-Framework MockK und die auf Mockito aufbauende Funktionssammlung Mockito-Kotlin erwähnt. Mit beiden ist es möglich, Mocks zu definieren und zu verwenden. Wer Mockito kennt, wird sich mit beiden Bibliotheken schnell zurechtfinden, wobei Mockito-Kotlin stärker versucht, die von Mockito bekannte API nachzuempfinden.

Unterschiedlich ist insbesondere die Definition der Mock-Rückgabewerte, wie hier am Beispiel für die Methode exists des Mocks dao, die den Wert false zurückliefern soll:

Mockito.`when`(dao.exists(any())).thenReturn(false) // Mockito
whenever(dao.exists(any())).thenReturn(false) //Mockito-Kotlin
every { dao.exists(any()) } returns false //MockK

Außerdem ist MockK kritischer bei Methodenaufrufen für die keine Rückgabewerte definiert wurden. Sollte MockK einen derartigen Aufruf entdecken, reagiert die Bibliothek mit einer MockKException. Neben der expliziten Definition des Rückgabewertes ermöglichen in diesen Fällen sogenannte RelaxedMocks einen lockereren Umgang. Für deren Definition gibt es bei der Erstellung des Mocks verschiedene Möglichkeiten:

@MockK(relaxed = true)
lateinit var dao: PersonDao

@RelaxedMockK
lateinit var dao: PersonDao

val dao = mockk<PersonDao>(relaxed = true)

Bei Kotlin sind Typen und Funktionen standardmäßig als final deklariert. Mit MockK lassen sich diese direkt mocken. Da Mockito-Kotlin das ursprüngliche Mockito im Bauch hat, müssen Typen und Funktionen mit dem open-Modifier versehen oder das All-open-Plugin [22] eingebunden werden.

Fazit

Durch die Möglichkeiten von Kotlin haben sich einige Testframeworks herauskristallisiert, die den Funktionsumfang der bisherigen Java-Frameworks übersteigen. Insbesondere bieten Spek und KotlinTest Strukturierungs- und Gruppierungsmöglichkeiten, die mit den bekannten Werkzeugen bislang nicht in der Form möglich waren. Gerade wenn es darum geht, Tests als eine erweiterte Dokumentation des spezifizierten Verhaltens zu verstehen, bieten sich hier ganz neue Möglichkeiten, die keine der bisherigen Java-Bibliotheken so elegant umsetzt. Daher ist es durchaus lohnenswert, sich die Möglichkeiten genauer anzusehen, die durch Kotlin-Frameworks geboten werden.

Die Qual der Wahl liegt (mal wieder) bei den Entwicklungsteams. Mit der in diesem Artikel vorgestellten Übersicht sollte es jedem Entwickler möglich sein, seine eigene Auswahl von Frameworks zu treffen. Ein recht bekannt gewordener Artikel von Philipp Hauer [23] empfiehlt beispielsweise die Kombination aus JUnit5, AssertJ und MockK. Aufgrund unterschiedlicher Anforderungen, Erfahrungen im Team und auch persönlicher Präferenzen kann es aus unserer Sicht gar nicht die eine optimale Lösung geben. Vielmehr ist wichtig, dass sich das Team für ein Setup entscheidet und dies konsequent anwendet. Alle Entscheidungen können richtig sein, solange das Team Tests erstellt, die seine jeweiligen Anforderungen zufriedenstellen. Hierfür haben wir am Anfang dieses Artikels aufgestellt, welche Aspekte Tests erfüllen können bzw. sollten. Alle hier vorgestellten Tools sind geeignet, das Team bei der Erstellung von Tests zu unterstützen.

Die Auswahl hängt ebenfalls von der Integrierbarkeit der Testframeworks mit den im Projekt eingesetzten Techniken zusammen. Beispielsweise wird sich die Nutzung von Spek für Integration-Tests, die des Spring-Application-Contexts bedürfen, mindestens als sehr aufwändig erweisen, wie das zugehörige Issue [24] auf der Projektseite von Spek beschreibt.

Autoren

Kai Schmidt

Kai Schmidt ist freiberuflicher Software-Entwickler und -Architekt. Er berät und gestaltet in Bereichen wie Logistik, Flugzeugbau, Finanzen und Handel betriebliche Anwendungssysteme, die in Java, Kotlin, C# und JavaScript…
>> Weiterlesen

Markus Schwarz

Markus Schwarz ist freiberuflicher Software-Entwickler aus Hamburg. Nach vielen Jahren der Entwicklung in Java, Ruby, PHP und Javascript hat er vor zwei Jahren seine Liebe zu Kotlin entdeckt.
>> Weiterlesen
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben