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,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