Kotlin-Testing-Patterns
Umfassende Kotlin-Testing-Patterns zum Schreiben zuverlässiger, wartbarer Tests, die der TDD-Methodik mit Kotest und MockK folgen.
Wann verwenden
- Beim Schreiben neuer Kotlin-Funktionen oder -Klassen
- Beim Hinzufügen von Testabdeckung zu bestehendem Kotlin-Code
- Bei der Implementierung von Property-basierten Tests
- Beim Befolgen des TDD-Workflows in Kotlin-Projekten
- Beim Konfigurieren von Kover für Codeabdeckung
Wie es funktioniert
- Zielcode identifizieren — Finden Sie die zu testende Funktion, Klasse oder das Modul
- Kotest-Spezifikation schreiben — Wählen Sie einen Spezifikationsstil (StringSpec, FunSpec, BehaviorSpec), der dem Testumfang entspricht
- Abhängigkeiten mocken — Verwenden Sie MockK, um die zu testende Einheit zu isolieren
- Tests ausführen (RED) — Überprüfen Sie, ob der Test mit dem erwarteten Fehler fehlschlägt
- Code implementieren (GREEN) — Schreiben Sie minimalen Code, um den Test zu bestehen
- Refaktorieren — Verbessern Sie die Implementierung, während die Tests grün bleiben
- Abdeckung prüfen — Führen Sie
./gradlew koverHtmlReportaus und überprüfen Sie eine Abdeckung von über 80 %
Beispiele
Die folgenden Abschnitte enthalten detaillierte, ausführbare Beispiele für jedes Testmuster:
Kurzreferenz
- Kotest-Spezifikationen — StringSpec, FunSpec, BehaviorSpec, DescribeSpec-Beispiele in Kotest-Spezifikationsstile
- Mocking — MockK-Einrichtung, Coroutinen-Mocking, Argumenterfassung in MockK
- TDD-Workflow — Vollständiger RED/GREEN/REFACTOR-Zyklus mit EmailValidator in TDD-Workflow für Kotlin
- Abdeckung — Kover-Konfiguration und -Befehle in Kover-Abdeckung
- Ktor-Tests — testApplication-Einrichtung in Ktor-testApplication-Tests
TDD-Workflow für Kotlin
Der RED-GREEN-REFACTOR-Zyklus
RED -> Schreiben Sie zuerst einen fehlschlagenden Test
GREEN -> Schreiben Sie minimalen Code, um den Test zu bestehen
REFACTOR -> Verbessern Sie Code, während Tests grün bleiben
REPEAT -> Fahren Sie mit der nächsten Anforderung fort
Schritt-für-Schritt-TDD in Kotlin
// Schritt 1: Definieren Sie die Schnittstelle/Signatur
// EmailValidator.kt
package com.example.validator
fun validateEmail(email: String): Result<String> {
TODO("not implemented")
}
// Schritt 2: Schreiben Sie einen fehlschlagenden Test (RED)
// EmailValidatorTest.kt
package com.example.validator
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.result.shouldBeFailure
import io.kotest.matchers.result.shouldBeSuccess
class EmailValidatorTest : StringSpec({
"gültige E-Mail gibt Erfolg zurück" {
validateEmail("[email protected]").shouldBeSuccess("[email protected]")
}
"leere E-Mail gibt Fehlschlag zurück" {
validateEmail("").shouldBeFailure()
}
"E-Mail ohne @ gibt Fehlschlag zurück" {
validateEmail("userexample.com").shouldBeFailure()
}
})
// Schritt 3: Tests ausführen - Fehlschlag überprüfen
// $ ./gradlew test
// EmailValidatorTest > gültige E-Mail gibt Erfolg zurück FEHLGESCHLAGEN
// kotlin.NotImplementedError: An operation is not implemented
// Schritt 4: Minimalen Code implementieren (GREEN)
fun validateEmail(email: String): Result<String> {
if (email.isBlank()) return Result.failure(IllegalArgumentException("E-Mail darf nicht leer sein"))
if ('@' !in email) return Result.failure(IllegalArgumentException("E-Mail muss @ enthalten"))
val regex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
if (!regex.matches(email)) return Result.failure(IllegalArgumentException("Ungültiges E-Mail-Format"))
return Result.success(email)
}
// Schritt 5: Tests ausführen - Bestehen überprüfen
// $ ./gradlew test
// EmailValidatorTest > gültige E-Mail gibt Erfolg zurück BESTANDEN
// EmailValidatorTest > leere E-Mail gibt Fehlschlag zurück BESTANDEN
// EmailValidatorTest > E-Mail ohne @ gibt Fehlschlag zurück BESTANDEN
// Schritt 6: Bei Bedarf refaktorieren, überprüfen, ob Tests noch bestanden werden
Kotest-Spezifikationsstile
StringSpec (Einfachster)
class CalculatorTest : StringSpec({
"addiere zwei positive Zahlen" {
Calculator.add(2, 3) shouldBe 5
}
"addiere negative Zahlen" {
Calculator.add(-1, -2) shouldBe -3
}
"addiere Null" {
Calculator.add(0, 5) shouldBe 5
}
})
FunSpec (JUnit-ähnlich)
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("getUser gibt Benutzer zurück, wenn gefunden") {
val expected = User(id = "1", name = "Alice")
coEvery { repository.findById("1") } returns expected
val result = service.getUser("1")
result shouldBe expected
}
test("getUser wirft Ausnahme, wenn nicht gefunden") {
coEvery { repository.findById("999") } returns null
shouldThrow<UserNotFoundException> {
service.getUser("999")
}
}
})
BehaviorSpec (BDD-Stil)
class OrderServiceTest : BehaviorSpec({
val repository = mockk<OrderRepository>()
val paymentService = mockk<PaymentService>()
val service = OrderService(repository, paymentService)
Given("eine gültige Bestellanfrage") {
val request = CreateOrderRequest(
userId = "user-1",
items = listOf(OrderItem("product-1", quantity = 2)),
)
When("die Bestellung aufgegeben wird") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Success
coEvery { repository.save(any()) } answers { firstArg() }
val result = service.placeOrder(request)
Then("sollte sie eine bestätigte Bestellung zurückgeben") {
result.status shouldBe OrderStatus.CONFIRMED
}
Then("sollte die Zahlung belastet werden") {
coVerify(exactly = 1) { paymentService.charge(any()) }
}
}
When("die Zahlung fehlschlägt") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Declined
Then("sollte eine PaymentException geworfen werden") {
shouldThrow<PaymentException> {
service.placeOrder(request)
}
}
}
}
})
DescribeSpec (RSpec-Stil)
class UserValidatorTest : DescribeSpec({
describe("validateUser") {
val validator = UserValidator()
context("mit gültigen Eingaben") {
it("akzeptiert einen normalen Benutzer") {
val user = CreateUserRequest("Alice", "[email protected]")
validator.validate(user).shouldBeValid()
}
}
context("mit ungültigem Namen") {
it("lehnt leeren Namen ab") {
val user = CreateUserRequest("", "[email protected]")
validator.validate(user).shouldBeInvalid()
}
it("lehnt Namen ab, die die maximale Länge überschreiten") {
val user = CreateUserRequest("A".repeat(256), "[email protected]")
validator.validate(user).shouldBeInvalid()
}
}
}
})
Kotest-Matcher
Kern-Matcher
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.*
import io.kotest.matchers.collections.*
import io.kotest.matchers.nulls.*
// Gleichheit
result shouldBe expected
result shouldNotBe unexpected
// Zeichenketten
name shouldStartWith "Al"
name shouldEndWith "ice"
name shouldContain "lic"
name shouldMatch Regex("[A-Z][a-z]+")
name.shouldBeBlank()
// Sammlungen
list shouldContain "item"
list shouldHaveSize 3
list.shouldBeSorted()
list.shouldContainAll("a", "b", "c")
list.shouldBeEmpty()
// Nullwerte
result.shouldNotBeNull()
result.shouldBeNull()
// Typen
result.shouldBeInstanceOf<User>()
// Zahlen
count shouldBeGreaterThan 0
price shouldBeInRange 1.0..100.0
// Ausnahmen
shouldThrow<IllegalArgumentException> {
validateAge(-1)
}.message shouldBe "Das Alter muss positiv sein"
shouldNotThrow<Exception> {
validateAge(25)
}
Benutzerdefinierte Matcher
fun beActiveUser() = object : Matcher<User> {
override fun test(value: User) = MatcherResult(
value.isActive && value.lastLogin != null,
{ "Benutzer ${value.id} sollte aktiv sein und einen letzten Login haben" },
{ "Benutzer ${value.id} sollte nicht aktiv sein" },
)
}
// Verwendung
user should beActiveUser()
MockK
Grundlegendes Mocking
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val logger = mockk<Logger>(relaxed = true) // Relaxed: gibt Standardwerte zurück
val service = UserService(repository, logger)
beforeTest {
clearMocks(repository, logger)
}
test("findUser delegiert an Repository") {
val expected = User(id = "1", name = "Alice")
every { repository.findById("1") } returns expected
val result = service.findUser("1")
result shouldBe expected
verify(exactly = 1) { repository.findById("1") }
}
test("findUser gibt null für unbekannte ID zurück") {
every { repository.findById(any()) } returns null
val result = service.findUser("unknown")
result.shouldBeNull()
}
})
Coroutinen-Mocking
class AsyncUserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("getUser suspendierende Funktion") {
coEvery { repository.findById("1") } returns User(id = "1", name = "Alice")
val result = service.getUser("1")
result.name shouldBe "Alice"
coVerify { repository.findById("1") }
}
test("getUser mit Verzögerung") {
coEvery { repository.findById("1") } coAnswers {
delay(100) // Simuliere asynchrone Arbeit
User(id = "1", name = "Alice")
}
val result = service.getUser("1")
result.name shouldBe "Alice"
}
})
Argumenterfassung
test("save erfasst das Benutzerargument") {
val slot = slot<User>()
coEvery { repository.save(capture(slot)) } returns Unit
service.createUser(CreateUserRequest("Alice", "[email protected]"))
slot.captured.name shouldBe "Alice"
slot.captured.email shouldBe "[email protected]"
slot.captured.id.shouldNotBeNull()
}
Spy und Partielles Mocking
test("Spy auf ein reales Objekt") {
val realService = UserService(repository)
val spy = spyk(realService)
every { spy.generateId() } returns "fixed-id"
spy.createUser(request)
verify { spy.generateId() } // Überschrieben
// Andere Methoden verwenden die reale Implementierung
}
Coroutinen-Tests
runTest für Suspend-Funktionen
import kotlinx.coroutines.test.runTest
class CoroutineServiceTest : FunSpec({
test("gleichzeitige Abrufe werden gemeinsam abgeschlossen") {
runTest {
val service = DataService(testScope = this)
val result = service.fetchAllData()
result.users.shouldNotBeEmpty()
result.products.shouldNotBeEmpty()
}
}
test("Timeout nach Verzögerung") {
runTest {
val service = SlowService()
shouldThrow<TimeoutCancellationException> {
withTimeout(100) {
service.slowOperation() // Dauert > 100 ms
}
}
}
}
})
Testen von Flows
import io.kotest.matchers.collections.shouldContainInOrder
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
class FlowServiceTest : FunSpec({
test("observeUsers gibt Aktualisierungen aus") {
runTest {
val service = UserFlowService()
val emissions = service.observeUsers()
.take(3)
.toList()
emissions shouldHaveSize 3
emissions.last().shouldNotBeEmpty()
}
}
test("searchUsers entprellt Eingaben") {
runTest {
val service = SearchService()
val queries = MutableSharedFlow<String>()
val results = mutableListOf<List<User>>()
val job = launch {
service.searchUsers(queries).collect { results.add(it) }
}
queries.emit("a")
queries.emit("ab")
queries.emit("abc") // Nur dies sollte die Suche auslösen
advanceTimeBy(500)
results shouldHaveSize 1
job.cancel()
}
}
})
TestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
class DispatcherTest : FunSpec({
test("verwendet Test-Dispatcher für kontrollierte Ausführung") {
val dispatcher = StandardTestDispatcher()
runTest(dispatcher) {
var abgeschlossen = false
launch {
delay(1000)
abgeschlossen = true
}
abgeschlossen shouldBe false
advanceTimeBy(1000)
abgeschlossen shouldBe true
}
}
})
Property-basierte Tests
Kotest-Property-Testing
import io.kotest.core.spec.style.FunSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.forAll
import io.kotest.property.checkAll
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
// Hinweis: Der Serialisierungs-Roundtrip-Test unten erfordert, dass die User-Datenklasse
// mit @Serializable (aus kotlinx.serialization) annotiert ist.
class PropertyTest : FunSpec({
test("String-Umkehrung ist involutorisch") {
forAll<String> { s ->
s.reversed().reversed() == s
}
}
test("Listensortierung ist idempotent") {
forAll(Arb.list(Arb.int())) { list ->
list.sorted() == list.sorted().sorted()
}
}
test("Serialisierungs-Roundtrip erhält Daten") {
checkAll(Arb.bind(Arb.string(1..50), Arb.string(5..100)) { name, email ->
User(name = name, email = "$email@test.com")
}) { user ->
val json = Json.encodeToString(user)
val decoded = Json.decodeFromString<User>(json)
decoded shouldBe user
}
}
})
Benutzerdefinierte Generatoren
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
// Benutzerdefinierter Generator für Bestellungsobjekte
fun Arb.Companion.order(): Arb<Order> = arbitrary {
Order(
id = random.nextPrintableString(10),
amount = random.nextDouble(1.0, 1000.0),
status = OrderStatus.entries.random(),
createdAt = random.nextLong(0, System.currentTimeMillis()),
)
}
// Verwendung in Property-Tests
test("Bestellsortierung nach Betrag") {
forAll(Arb.list(Arb.order())) { orders ->
val sortiert = orders.sortedBy { it.amount }
sortiert.zipWithNext { a, b -> a.amount <= b.amount }.all { it }
}
}
Kover-Abdeckung
build.gradle.kts-Konfiguration
// In build.gradle.kts
plugins {
id("org.jetbrains.kotlinx.kover") version "0.8.3"
}
kover {
engine {
jacoco() // oder intellij für IntelliJ-Engine
}
coverageRules {
rule {
classOf() // Wählen Sie eine bestimmte Klasse
bound {
minValue = 80 // Mindestabdeckung in %
}
}
rule {
packageOf() // Oder ein gesamtes Paket
bound {
minValue = 80
}
}
}
}
// Optional: Bestimmte Klassen von der Abdeckung ausschließen
koverReport {
filters {
excludes {
classes(
"*.dataclass.*",
"*.generated.*",
)
}
}
}
Nützliche Kover-Befehle
# HTML-Bericht generieren
./gradlew koverHtmlReport
# XML-Bericht generieren (für CI-Tools wie SonarQube)
./gradlew koverXmlReport
# Abdeckungsregeln anwenden (schlägt fehl, wenn unter 80 %)
./gradlew koverVerify
# Bericht für ein bestimmtes Modul generieren
./gradlew :app:koverHtmlReport
Ktor-testApplication-Tests
Grundlegende Einrichtung
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
class UserRoutesTest : FunSpec({
test("GET /users gibt 200 und Benutzerliste zurück") {
testApplication {
application {
configureSerialization()
configureRouting()
}
val response = client.get("/users")
response.status shouldBe HttpStatusCode.OK
val body = response.bodyAsText()
body shouldContain "[]"
}
}
})
Mit Datenbankabstraktion
class UserRepositoryTest : FunSpec({
test("CRUD-Operationen für Benutzer") {
testApplication {
application {
configureSerialization()
moduleForTesting()
}
client.post("/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest("Alice", "[email protected]"))
}.apply {
status shouldBe HttpStatusCode.Created
}
val getAllResponse = client.get("/users")
getAllResponse.status shouldBe HttpStatusCode.OK
// Datenbank-Cleanup für Tests
}
}
})
Fehlerbehebung
Häufige Probleme
| Problem | Ursache | Lösung |
|---|---|---|
NoBeanDefFoundException | Fehlende Testkonfiguration | Stellen Sie sicher, dass testApplication { } korrekt eingerichtet ist |
TimeoutCancellationException | Coroutinen werden nicht rechtzeitig abgeschlossen | Verwenden Sie runTest mit advanceTimeBy oder advanceUntilIdle |
MockKException | Nicht gemockte Aufrufe | Fügen Sie every { } oder coEvery { } für alle externen Aufrufe hinzu |
| Niedrige Kover-Abdeckung | Unzureichende Testabdeckung | Fügen Sie Tests für Grenzfälle und Ausnahmen hinzu |
Tipps
- Verwenden Sie
relaxed = truefür Logger und einfache Abhängigkeiten, um die Einrichtung zu vereinfachen - Verwenden Sie
slotodercapture()in MockK, um Argumente zu überprüfen, die an gemockte Funktionen übergeben werden - Für Coroutinen-Tests: Verwenden Sie immer
runTest, um die Testausführung zu steuern - Für Property-Tests: Beginnen Sie mit einfachen Generatoren und passen Sie die Datenbereiche an, während die Tests stabil werden