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,4 +1,4 @@
package com.example.editor
package de.werkbaum
import org.springframework.boot.autoconfigure.SpringBootApplication
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 com.example.editor.generated.model.Document as ApiDocument
import com.example.editor.generated.model.DocumentCreateRequest
import com.example.editor.generated.model.DocumentHistoryEntry as ApiHistoryEntry
import com.example.editor.generated.model.DocumentUpdateRequest
import com.example.editor.generated.model.RestoreRequest
import com.example.editor.domain.Document
import com.example.editor.domain.DocumentHistoryEntry
import com.example.editor.service.DocumentService
import de.werkbaum.generated.api.DocumentsApi
import de.werkbaum.generated.model.Document as ApiDocument
import de.werkbaum.generated.model.DocumentCreateRequest
import de.werkbaum.generated.model.DocumentHistoryEntry as ApiHistoryEntry
import de.werkbaum.generated.model.DocumentUpdateRequest
import de.werkbaum.generated.model.RestoreRequest
import de.werkbaum.domain.Document
import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.service.DocumentService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
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 com.example.editor.service.DocumentNotFoundException
import de.werkbaum.service.DocumentConflictException
import de.werkbaum.service.DocumentNotFoundException
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
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.util.UUID
@@ -1,4 +1,4 @@
package com.example.editor.domain
package de.werkbaum.domain
import java.time.OffsetDateTime
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.Entity
import jakarta.persistence.Id
@@ -1,7 +1,7 @@
package com.example.editor.persistence
package de.werkbaum.persistence
import com.example.editor.domain.ChangeType
import com.example.editor.domain.DocumentHistoryEntry
import de.werkbaum.domain.ChangeType
import de.werkbaum.domain.DocumentHistoryEntry
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
@@ -1,7 +1,7 @@
package com.example.editor.persistence
package de.werkbaum.persistence
import com.example.editor.domain.DocumentHistoryEntry
import com.example.editor.repository.DocumentHistoryRepository
import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.repository.DocumentHistoryRepository
import org.springframework.stereotype.Repository
import java.util.UUID
@@ -1,7 +1,7 @@
package com.example.editor.persistence
package de.werkbaum.persistence
import com.example.editor.domain.Document
import com.example.editor.repository.DocumentRepository
import de.werkbaum.domain.Document
import de.werkbaum.repository.DocumentRepository
import org.springframework.stereotype.Repository
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 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
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
/**
@@ -1,4 +1,4 @@
package com.example.editor.service
package de.werkbaum.service
import org.springframework.context.annotation.Bean
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)
@@ -1,4 +1,4 @@
package com.example.editor.service
package de.werkbaum.service
import java.util.UUID
@@ -1,10 +1,10 @@
package com.example.editor.service
package de.werkbaum.service
import com.example.editor.domain.ChangeType
import com.example.editor.domain.Document
import com.example.editor.domain.DocumentHistoryEntry
import com.example.editor.repository.DocumentHistoryRepository
import com.example.editor.repository.DocumentRepository
import de.werkbaum.domain.ChangeType
import de.werkbaum.domain.Document
import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.repository.DocumentHistoryRepository
import de.werkbaum.repository.DocumentRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
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 com.example.editor.repository.DocumentRepository
import de.werkbaum.repository.DocumentHistoryRepository
import de.werkbaum.repository.DocumentRepository
import io.cucumber.java.Before
import io.cucumber.spring.CucumberContextConfiguration
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
@CucumberContextConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// Seit Boot 4 stellt @SpringBootTest die TestRestTemplate-Bean nicht mehr von selbst bereit
@AutoConfigureTestRestTemplate
// Seit Boot 4 stellt @SpringBootTest die Test-Client-Bean nicht mehr von selbst bereit
@AutoConfigureRestTestClient
class CucumberSpringConfiguration {
@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.PLUGIN_PROPERTY_NAME
@@ -10,6 +10,6 @@ import org.junit.platform.suite.api.Suite
@Suite
@IncludeEngines("cucumber")
@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")
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 com.example.editor.domain.Document
import com.example.editor.domain.DocumentHistoryEntry
import com.example.editor.repository.DocumentHistoryRepository
import com.example.editor.repository.DocumentRepository
import de.werkbaum.domain.ChangeType
import de.werkbaum.domain.Document
import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.repository.DocumentHistoryRepository
import de.werkbaum.repository.DocumentRepository
import io.mockk.every
import io.mockk.just
import io.mockk.mockk