refactor(backend): Paketwurzel de.werkbaum, Tests auf RestTestClient

Das Backend-Geruest kam unter der Platzhalter-Wurzel com.example.editor
herein und widersprach backend/CLAUDE.md. Umgezogen nach de.werkbaum: 17
Kotlin-Dateien, Gradle-group, apiPackage/modelPackage der OpenAPI-Generierung,
die jacoco-Ausschluesse und das Cucumber-glue-Paket. Jetzt war es billig, mit
jeder Woche Entwicklung waere es teurer geworden.

Die BDD-Tests nutzen jetzt RestTestClient statt TestRestTemplate, das in
Spring Boot 4 als Auslaufmodell gilt. Weil Cucumber Senden und Pruefen
trennt, wird die fluent API nicht fuer Zusicherungen genutzt, sondern ueber
returnResult das Ergebnis festgehalten.

Nachgemessen: 22 Tests gruen, check inklusive Coverage-Verifikation besteht,
91,7 % Zeilenabdeckung, generierter Code weiterhin ausgeschlossen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 15:21:34 +02:00
co-authored by Claude Opus 5
parent 7684872a42
commit f3afc8dfa8
24 changed files with 286 additions and 241 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ Verschlüsselung.
1. Vertrag ändern: `src/main/resources/openapi/api.yaml` 1. Vertrag ändern: `src/main/resources/openapi/api.yaml`
2. `./gradlew openApiGenerate` → erzeugt `DocumentsApi` (Interface) und Modelle 2. `./gradlew openApiGenerate` → erzeugt `DocumentsApi` (Interface) und Modelle
nach `build/generated/openapi` (Pakete `com.example.editor.generated.*`) nach `build/generated/openapi` (Pakete `de.werkbaum.generated.*`)
3. `DocumentsController` implementiert das Interface mit 3. `DocumentsController` implementiert das Interface mit
`skipDefaultInterface=true`: Weicht die Implementierung vom Vertrag ab, `skipDefaultInterface=true`: Weicht die Implementierung vom Vertrag ab,
**bricht der Build** Spezifikation und Code können nicht auseinanderlaufen. **bricht der Build** Spezifikation und Code können nicht auseinanderlaufen.
+7 -8
View File
@@ -8,7 +8,7 @@ plugins {
jacoco jacoco
} }
group = "com.example" group = "de.werkbaum"
version = "0.1.0-SNAPSHOT" version = "0.1.0-SNAPSHOT"
java { java {
@@ -37,10 +37,9 @@ dependencies {
// --- Tests --- // --- Tests ---
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
// TestRestTemplate liegt in Boot 4 im eigenen Modul (org.springframework.boot.resttestclient) // RestTestClient-Autokonfiguration (Boot 4). TestRestTemplate gilt dort als
// und braucht spring-boot-restclient zur Laufzeit // Auslaufmodell; die BDD-Tests nutzen den Nachfolger aus spring-test.
testImplementation("org.springframework.boot:spring-boot-resttestclient") testImplementation("org.springframework.boot:spring-boot-resttestclient")
testRuntimeOnly("org.springframework.boot:spring-boot-restclient")
testImplementation("io.mockk:mockk:$mockkVersion") testImplementation("io.mockk:mockk:$mockkVersion")
// Behavior-Tests (BDD) mit Cucumber // Behavior-Tests (BDD) mit Cucumber
@@ -57,8 +56,8 @@ openApiGenerate {
generatorName.set("kotlin-spring") generatorName.set("kotlin-spring")
inputSpec.set("$projectDir/src/main/resources/openapi/api.yaml") inputSpec.set("$projectDir/src/main/resources/openapi/api.yaml")
outputDir.set(layout.buildDirectory.dir("generated/openapi").get().asFile.path) outputDir.set(layout.buildDirectory.dir("generated/openapi").get().asFile.path)
apiPackage.set("com.example.editor.generated.api") apiPackage.set("de.werkbaum.generated.api")
modelPackage.set("com.example.editor.generated.model") modelPackage.set("de.werkbaum.generated.model")
configOptions.set( configOptions.set(
mapOf( mapOf(
"useSpringBoot4" to "true", "useSpringBoot4" to "true",
@@ -106,7 +105,7 @@ tasks.jacocoTestReport {
// Generierter Code zaehlt nicht zur Coverage // Generierter Code zaehlt nicht zur Coverage
classDirectories.setFrom( classDirectories.setFrom(
classDirectories.files.map { classDirectories.files.map {
fileTree(it) { exclude("com/example/editor/generated/**") } fileTree(it) { exclude("de/werkbaum/generated/**") }
} }
) )
} }
@@ -115,7 +114,7 @@ tasks.jacocoTestCoverageVerification {
dependsOn(tasks.test) dependsOn(tasks.test)
classDirectories.setFrom( classDirectories.setFrom(
classDirectories.files.map { classDirectories.files.map {
fileTree(it) { exclude("com/example/editor/generated/**") } fileTree(it) { exclude("de/werkbaum/generated/**") }
} }
) )
violationRules { violationRules {
@@ -1,4 +1,4 @@
package com.example.editor package de.werkbaum
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
@@ -1,14 +1,14 @@
package com.example.editor.api package de.werkbaum.api
import com.example.editor.generated.api.DocumentsApi import de.werkbaum.generated.api.DocumentsApi
import com.example.editor.generated.model.Document as ApiDocument import de.werkbaum.generated.model.Document as ApiDocument
import com.example.editor.generated.model.DocumentCreateRequest import de.werkbaum.generated.model.DocumentCreateRequest
import com.example.editor.generated.model.DocumentHistoryEntry as ApiHistoryEntry import de.werkbaum.generated.model.DocumentHistoryEntry as ApiHistoryEntry
import com.example.editor.generated.model.DocumentUpdateRequest import de.werkbaum.generated.model.DocumentUpdateRequest
import com.example.editor.generated.model.RestoreRequest import de.werkbaum.generated.model.RestoreRequest
import com.example.editor.domain.Document import de.werkbaum.domain.Document
import com.example.editor.domain.DocumentHistoryEntry import de.werkbaum.domain.DocumentHistoryEntry
import com.example.editor.service.DocumentService import de.werkbaum.service.DocumentService
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
@@ -1,7 +1,7 @@
package com.example.editor.api package de.werkbaum.api
import com.example.editor.service.DocumentConflictException import de.werkbaum.service.DocumentConflictException
import com.example.editor.service.DocumentNotFoundException import de.werkbaum.service.DocumentNotFoundException
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ExceptionHandler
@@ -1,4 +1,4 @@
package com.example.editor.domain package de.werkbaum.domain
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
@@ -1,4 +1,4 @@
package com.example.editor.domain package de.werkbaum.domain
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
@@ -1,6 +1,6 @@
package com.example.editor.persistence package de.werkbaum.persistence
import com.example.editor.domain.Document import de.werkbaum.domain.Document
import jakarta.persistence.Column import jakarta.persistence.Column
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.Id import jakarta.persistence.Id
@@ -1,7 +1,7 @@
package com.example.editor.persistence package de.werkbaum.persistence
import com.example.editor.domain.ChangeType import de.werkbaum.domain.ChangeType
import com.example.editor.domain.DocumentHistoryEntry import de.werkbaum.domain.DocumentHistoryEntry
import jakarta.persistence.Column import jakarta.persistence.Column
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.EnumType import jakarta.persistence.EnumType
@@ -1,7 +1,7 @@
package com.example.editor.persistence package de.werkbaum.persistence
import com.example.editor.domain.DocumentHistoryEntry import de.werkbaum.domain.DocumentHistoryEntry
import com.example.editor.repository.DocumentHistoryRepository import de.werkbaum.repository.DocumentHistoryRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.util.UUID import java.util.UUID
@@ -1,7 +1,7 @@
package com.example.editor.persistence package de.werkbaum.persistence
import com.example.editor.domain.Document import de.werkbaum.domain.Document
import com.example.editor.repository.DocumentRepository import de.werkbaum.repository.DocumentRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.util.UUID import java.util.UUID
@@ -1,4 +1,4 @@
package com.example.editor.persistence package de.werkbaum.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID import java.util.UUID
@@ -1,6 +1,6 @@
package com.example.editor.repository package de.werkbaum.repository
import com.example.editor.domain.DocumentHistoryEntry import de.werkbaum.domain.DocumentHistoryEntry
import java.util.UUID import java.util.UUID
interface DocumentHistoryRepository { interface DocumentHistoryRepository {
@@ -1,6 +1,6 @@
package com.example.editor.repository package de.werkbaum.repository
import com.example.editor.domain.Document import de.werkbaum.domain.Document
import java.util.UUID import java.util.UUID
/** /**
@@ -1,4 +1,4 @@
package com.example.editor.service package de.werkbaum.service
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
@@ -1,3 +1,3 @@
package com.example.editor.service package de.werkbaum.service
class DocumentConflictException(message: String) : RuntimeException(message) class DocumentConflictException(message: String) : RuntimeException(message)
@@ -1,4 +1,4 @@
package com.example.editor.service package de.werkbaum.service
import java.util.UUID import java.util.UUID
@@ -1,10 +1,10 @@
package com.example.editor.service package de.werkbaum.service
import com.example.editor.domain.ChangeType import de.werkbaum.domain.ChangeType
import com.example.editor.domain.Document import de.werkbaum.domain.Document
import com.example.editor.domain.DocumentHistoryEntry import de.werkbaum.domain.DocumentHistoryEntry
import com.example.editor.repository.DocumentHistoryRepository import de.werkbaum.repository.DocumentHistoryRepository
import com.example.editor.repository.DocumentRepository import de.werkbaum.repository.DocumentRepository
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.Clock import java.time.Clock
@@ -1,177 +0,0 @@
package com.example.editor.bdd
import io.cucumber.java.de.Angenommen
import io.cucumber.java.de.Dann
import io.cucumber.java.de.Und
import io.cucumber.java.de.Wenn
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.TestRestTemplate
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
/**
* Behavior-Tests gegen die laufende Anwendung (RANDOM_PORT), also echtes
* Verhalten der API inklusive Serialisierung, Statuscodes und Fehlerpfaden.
*/
class DocumentStepDefinitions {
@Autowired
private lateinit var rest: TestRestTemplate
private var lastResponse: ResponseEntity<String>? = null
private var currentDocumentId: String? = null
private fun jsonEntity(body: String): HttpEntity<String> {
val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON }
return HttpEntity(body, headers)
}
private fun createDocument(title: String, content: String): ResponseEntity<String> =
rest.postForEntity(
"/api/v1/documents",
jsonEntity("""{"title":"$title","content":"$content"}"""),
String::class.java,
)
private fun extractId(body: String?): String {
val match = Regex("\"id\"\\s*:\\s*\"([^\"]+)\"").find(body ?: "")
assertNotNull(match, "Antwort enthält keine ID: $body")
return match!!.groupValues[1]
}
// ---------------- Angenommen ----------------
@Angenommen("es existiert ein Dokument mit dem Titel {string}")
fun `es existiert ein Dokument`(titel: String) {
val response = createDocument(titel, "Initialer Inhalt")
assertEquals(201, response.statusCode.value(), "Testdatenanlage fehlgeschlagen")
currentDocumentId = extractId(response.body)
}
// ---------------- Wenn ----------------
@Wenn("ich ein Dokument mit dem Titel {string} und dem Inhalt {string} anlege")
fun `ich lege ein Dokument an`(titel: String, inhalt: String) {
lastResponse = createDocument(titel, inhalt)
currentDocumentId = Regex("\"id\"\\s*:\\s*\"([^\"]+)\"")
.find(lastResponse?.body ?: "")?.groupValues?.get(1)
}
@Wenn("ich alle Dokumente abrufe")
fun `ich rufe alle Dokumente ab`() {
lastResponse = rest.getForEntity("/api/v1/documents", String::class.java)
}
@Wenn("ich dieses Dokument abrufe")
fun `ich rufe dieses Dokument ab`() {
lastResponse = rest.getForEntity("/api/v1/documents/$currentDocumentId", String::class.java)
}
@Wenn("ich ein Dokument mit einer unbekannten ID abrufe")
fun `ich rufe ein unbekanntes Dokument ab`() {
lastResponse = rest.getForEntity(
"/api/v1/documents/00000000-0000-0000-0000-000000000000",
String::class.java,
)
}
@Wenn("ich den Titel dieses Dokuments auf {string} ändere")
fun `ich aendere den Titel`(neuerTitel: String) {
lastResponse = rest.exchange(
"/api/v1/documents/$currentDocumentId",
HttpMethod.PUT,
jsonEntity("""{"title":"$neuerTitel","content":"Aktualisierter Inhalt"}"""),
String::class.java,
)
}
@Wenn("ich dieses Dokument lösche")
fun `ich loesche dieses Dokument`() {
lastResponse = rest.exchange(
"/api/v1/documents/$currentDocumentId",
HttpMethod.DELETE,
HttpEntity.EMPTY,
String::class.java,
)
}
// ---------------- Dann / Und ----------------
@Dann("erhalte ich den Status {int}")
fun `erhalte ich den Status`(status: Int) {
assertEquals(status, lastResponse?.statusCode?.value())
}
@Und("die Antwort enthält den Titel {string}")
fun `die Antwort enthaelt den Titel`(titel: String) {
assertTrue(
lastResponse?.body?.contains("\"title\":\"$titel\"") == true,
"Erwarteter Titel '$titel' nicht in Antwort: ${lastResponse?.body}",
)
}
@Und("die Antwort enthält {int} Dokumente")
fun `die Antwort enthaelt n Dokumente`(anzahl: Int) {
val count = Regex("\"id\"").findAll(lastResponse?.body ?: "").count()
assertEquals(anzahl, count, "Antwort: ${lastResponse?.body}")
}
@Und("die Antwort enthält die Version {long}")
fun `die Antwort enthaelt die Version`(version: Long) {
assertTrue(
lastResponse?.body?.contains("\"version\":$version") == true,
"Erwartete Version $version nicht in Antwort: ${lastResponse?.body}",
)
}
@Und("das Dokument ist nicht mehr abrufbar")
fun `das Dokument ist nicht mehr abrufbar`() {
val response = rest.getForEntity("/api/v1/documents/$currentDocumentId", String::class.java)
assertEquals(404, response.statusCode.value())
}
// ---------------- Historie & Wiederherstellung ----------------
@Wenn("ich die Historie dieses Dokuments abrufe")
fun `ich rufe die Historie ab`() {
lastResponse = rest.getForEntity(
"/api/v1/documents/$currentDocumentId/history",
String::class.java,
)
}
@Wenn("ich dieses Dokument wiederherstelle")
fun `ich stelle dieses Dokument wieder her`() {
lastResponse = rest.postForEntity(
"/api/v1/documents/$currentDocumentId/restore",
jsonEntity("{}"),
String::class.java,
)
}
@Und("die Antwort enthält {int} Historieneinträge")
fun `die Antwort enthaelt n Historieneintraege`(anzahl: Int) {
val count = Regex("\"changeType\"").findAll(lastResponse?.body ?: "").count()
assertEquals(anzahl, count, "Antwort: ${lastResponse?.body}")
}
@Und("die Antwort enthält den Änderungstyp {string}")
fun `die Antwort enthaelt den Aenderungstyp`(typ: String) {
assertTrue(
lastResponse?.body?.contains("\"changeType\":\"$typ\"") == true,
"Erwarteter Änderungstyp '$typ' nicht in Antwort: ${lastResponse?.body}",
)
}
@Und("das Dokument ist wieder abrufbar")
fun `das Dokument ist wieder abrufbar`() {
val response = rest.getForEntity("/api/v1/documents/$currentDocumentId", String::class.java)
assertEquals(200, response.statusCode.value())
}
}
@@ -1,17 +1,17 @@
package com.example.editor.bdd package de.werkbaum.bdd
import com.example.editor.repository.DocumentHistoryRepository import de.werkbaum.repository.DocumentHistoryRepository
import com.example.editor.repository.DocumentRepository import de.werkbaum.repository.DocumentRepository
import io.cucumber.java.Before import io.cucumber.java.Before
import io.cucumber.spring.CucumberContextConfiguration import io.cucumber.spring.CucumberContextConfiguration
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
@CucumberContextConfiguration @CucumberContextConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// Seit Boot 4 stellt @SpringBootTest die TestRestTemplate-Bean nicht mehr von selbst bereit // Seit Boot 4 stellt @SpringBootTest die Test-Client-Bean nicht mehr von selbst bereit
@AutoConfigureTestRestTemplate @AutoConfigureRestTestClient
class CucumberSpringConfiguration { class CucumberSpringConfiguration {
@Autowired @Autowired
@@ -1,4 +1,4 @@
package com.example.editor.bdd package de.werkbaum.bdd
import io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME import io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME
import io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME import io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME
@@ -10,6 +10,6 @@ import org.junit.platform.suite.api.Suite
@Suite @Suite
@IncludeEngines("cucumber") @IncludeEngines("cucumber")
@SelectClasspathResource("features") @SelectClasspathResource("features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.example.editor.bdd") @ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "de.werkbaum.bdd")
@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") @ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty")
class CucumberTest class CucumberTest
@@ -0,0 +1,190 @@
package de.werkbaum.bdd
import io.cucumber.java.de.Angenommen
import io.cucumber.java.de.Dann
import io.cucumber.java.de.Und
import io.cucumber.java.de.Wenn
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.client.EntityExchangeResult
import org.springframework.test.web.servlet.client.RestTestClient
/**
* Behavior-Tests gegen die laufende Anwendung (RANDOM_PORT), also echtes
* Verhalten der API inklusive Serialisierung, Statuscodes und Fehlerpfaden.
*
* Verwendet [RestTestClient] den Nachfolger von TestRestTemplate, das in
* Spring Boot 4 als Auslaufmodell gilt. Die fluent API prueft normalerweise
* sofort; hier wird stattdessen ueber `returnResult` das Ergebnis
* festgehalten, weil Cucumber Senden (Wenn) und Pruefen (Dann) trennt.
*/
class DocumentStepDefinitions {
@Autowired
private lateinit var client: RestTestClient
private var lastResponse: EntityExchangeResult<String>? = null
private var currentDocumentId: String? = null
private fun status(): Int? = lastResponse?.status?.value()
private fun body(): String? = lastResponse?.responseBody
private fun createDocument(title: String, content: String): EntityExchangeResult<String> =
client.post()
.uri("/api/v1/documents")
.contentType(MediaType.APPLICATION_JSON)
.body("""{"title":"$title","content":"$content"}""")
.exchange()
.returnResult(String::class.java)
private fun extractId(body: String?): String {
val match = Regex("\"id\"\\s*:\\s*\"([^\"]+)\"").find(body ?: "")
assertNotNull(match, "Antwort enthält keine ID: $body")
return match!!.groupValues[1]
}
// ---------------- Angenommen ----------------
@Angenommen("es existiert ein Dokument mit dem Titel {string}")
fun `es existiert ein Dokument`(titel: String) {
val response = createDocument(titel, "Initialer Inhalt")
assertEquals(201, response.status.value(), "Testdatenanlage fehlgeschlagen")
currentDocumentId = extractId(response.responseBody)
}
// ---------------- Wenn ----------------
@Wenn("ich ein Dokument mit dem Titel {string} und dem Inhalt {string} anlege")
fun `ich lege ein Dokument an`(titel: String, inhalt: String) {
lastResponse = createDocument(titel, inhalt)
currentDocumentId = Regex("\"id\"\\s*:\\s*\"([^\"]+)\"")
.find(body() ?: "")?.groupValues?.get(1)
}
@Wenn("ich alle Dokumente abrufe")
fun `ich rufe alle Dokumente ab`() {
lastResponse = client.get()
.uri("/api/v1/documents")
.exchange()
.returnResult(String::class.java)
}
@Wenn("ich dieses Dokument abrufe")
fun `ich rufe dieses Dokument ab`() {
lastResponse = client.get()
.uri("/api/v1/documents/$currentDocumentId")
.exchange()
.returnResult(String::class.java)
}
@Wenn("ich ein Dokument mit einer unbekannten ID abrufe")
fun `ich rufe ein unbekanntes Dokument ab`() {
lastResponse = client.get()
.uri("/api/v1/documents/00000000-0000-0000-0000-000000000000")
.exchange()
.returnResult(String::class.java)
}
@Wenn("ich den Titel dieses Dokuments auf {string} ändere")
fun `ich aendere den Titel`(neuerTitel: String) {
lastResponse = client.put()
.uri("/api/v1/documents/$currentDocumentId")
.contentType(MediaType.APPLICATION_JSON)
.body("""{"title":"$neuerTitel","content":"Aktualisierter Inhalt"}""")
.exchange()
.returnResult(String::class.java)
}
@Wenn("ich dieses Dokument lösche")
fun `ich loesche dieses Dokument`() {
lastResponse = client.delete()
.uri("/api/v1/documents/$currentDocumentId")
.exchange()
.returnResult(String::class.java)
}
// ---------------- Dann / Und ----------------
@Dann("erhalte ich den Status {int}")
fun `erhalte ich den Status`(status: Int) {
assertEquals(status, status())
}
@Und("die Antwort enthält den Titel {string}")
fun `die Antwort enthaelt den Titel`(titel: String) {
assertTrue(
body()?.contains("\"title\":\"$titel\"") == true,
"Erwarteter Titel '$titel' nicht in Antwort: ${body()}",
)
}
@Und("die Antwort enthält {int} Dokumente")
fun `die Antwort enthaelt n Dokumente`(anzahl: Int) {
val count = Regex("\"id\"").findAll(body() ?: "").count()
assertEquals(anzahl, count, "Antwort: ${body()}")
}
@Und("die Antwort enthält die Version {long}")
fun `die Antwort enthaelt die Version`(version: Long) {
assertTrue(
body()?.contains("\"version\":$version") == true,
"Erwartete Version $version nicht in Antwort: ${body()}",
)
}
@Und("das Dokument ist nicht mehr abrufbar")
fun `das Dokument ist nicht mehr abrufbar`() {
val response = client.get()
.uri("/api/v1/documents/$currentDocumentId")
.exchange()
.returnResult(String::class.java)
assertEquals(404, response.status.value())
}
// ---------------- Historie & Wiederherstellung ----------------
@Wenn("ich die Historie dieses Dokuments abrufe")
fun `ich rufe die Historie ab`() {
lastResponse = client.get()
.uri("/api/v1/documents/$currentDocumentId/history")
.exchange()
.returnResult(String::class.java)
}
@Wenn("ich dieses Dokument wiederherstelle")
fun `ich stelle dieses Dokument wieder her`() {
lastResponse = client.post()
.uri("/api/v1/documents/$currentDocumentId/restore")
.contentType(MediaType.APPLICATION_JSON)
.body("{}")
.exchange()
.returnResult(String::class.java)
}
@Und("die Antwort enthält {int} Historieneinträge")
fun `die Antwort enthaelt n Historieneintraege`(anzahl: Int) {
val count = Regex("\"changeType\"").findAll(body() ?: "").count()
assertEquals(anzahl, count, "Antwort: ${body()}")
}
@Und("die Antwort enthält den Änderungstyp {string}")
fun `die Antwort enthaelt den Aenderungstyp`(typ: String) {
assertTrue(
body()?.contains("\"changeType\":\"$typ\"") == true,
"Erwarteter Änderungstyp '$typ' nicht in Antwort: ${body()}",
)
}
@Und("das Dokument ist wieder abrufbar")
fun `das Dokument ist wieder abrufbar`() {
val response = client.get()
.uri("/api/v1/documents/$currentDocumentId")
.exchange()
.returnResult(String::class.java)
assertEquals(200, response.status.value())
}
}
@@ -1,10 +1,10 @@
package com.example.editor.service package de.werkbaum.service
import com.example.editor.domain.ChangeType import de.werkbaum.domain.ChangeType
import com.example.editor.domain.Document import de.werkbaum.domain.Document
import com.example.editor.domain.DocumentHistoryEntry import de.werkbaum.domain.DocumentHistoryEntry
import com.example.editor.repository.DocumentHistoryRepository import de.werkbaum.repository.DocumentHistoryRepository
import com.example.editor.repository.DocumentRepository import de.werkbaum.repository.DocumentRepository
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
+33
View File
@@ -5967,3 +5967,36 @@ und JDBC sind JVM-Bibliotheken; das wäre kein Umbau, sondern ein Neubau auf
einem anderen Stack. Unabhängig davon: Beim Deployment gehört ein `-Xmx` einem anderen Stack. Unabhängig davon: Beim Deployment gehört ein `-Xmx`
gesetzt, statt der JVM auf einem geteilten Server die Voreinstellung zu gesetzt, statt der JVM auf einem geteilten Server die Voreinstellung zu
überlassen. überlassen.
**Nachtrag zu D13 — Paketwurzel `de.werkbaum`, und drei Fallen von Spring
Boot 4 (2026-08-26).** Das Backend-Gerüst kam zunächst unter der
Platzhalter-Wurzel `com.example.editor` herein und widersprach damit
`backend/CLAUDE.md`. Umgezogen nach **`de.werkbaum`** (17 Kotlin-Dateien,
Gradle-`group`, `apiPackage`/`modelPackage` der OpenAPI-Generierung, die
jacoco-Ausschlüsse und das Cucumber-`glue`-Paket) — jetzt war es billig, mit
jeder Woche Entwicklung wäre es teurer geworden. Nachgemessen: 22 Tests grün,
`check` inklusive Coverage-Verifikation besteht, 91,7 % Zeilenabdeckung,
generierter Code weiterhin ausgeschlossen.
Dabei sind drei Eigenheiten von **Spring Boot 4** aufgefallen, die jeweils
denselben Ursprung haben — die Modularisierung, bei der vieles aus dem
Kern-Artefakt in eigene Module gewandert ist:
1. **`TestRestTemplate` ist umgezogen** von `org.springframework.boot.test.web.client`
nach `org.springframework.boot.resttestclient` und liegt im Modul
`spring-boot-resttestclient`, das `spring-boot-starter-test` **nicht**
mitbringt.
2. **`@SpringBootTest` stellt die Test-Client-Bean nicht mehr von selbst
bereit** — es braucht `@AutoConfigureTestRestTemplate` bzw.
`@AutoConfigureRestTestClient`.
3. **Liquibase braucht seinen Starter.** Das nackte `org.liquibase:liquibase-core`
bringt die Autokonfiguration nicht mehr mit; ohne
`spring-boot-starter-liquibase` läuft keine Migration, und die Tests
scheitern erst spät mit „Schema validation: missing table".
Die BDD-Tests nutzen jetzt **`RestTestClient`** (aus `spring-test`) statt
`TestRestTemplate`, das in Boot 4 als Auslaufmodell gilt. Umgestellt bei
fünfzehn Aufrufen in einer Datei — die Zahl wächst von hier an nur. Weil
Cucumber Senden (Wenn) und Prüfen (Dann) trennt, wird die fluent API nicht
für Zusicherungen genutzt, sondern über `returnResult` das Ergebnis
festgehalten.