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:
co-authored by
Claude Opus 5
parent
7684872a42
commit
f3afc8dfa8
+1
-1
@@ -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.
|
||||||
|
|||||||
@@ -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
-1
@@ -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
|
||||||
+10
-10
@@ -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
|
||||||
+3
-3
@@ -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
-1
@@ -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
-1
@@ -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
|
||||||
+2
-2
@@ -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
|
||||||
+3
-3
@@ -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
|
||||||
+3
-3
@@ -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
|
||||||
|
|
||||||
+3
-3
@@ -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
-1
@@ -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
|
||||||
+2
-2
@@ -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 {
|
||||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.example.editor.service
|
package de.werkbaum.service
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
+6
-6
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-6
@@ -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
|
||||||
+2
-2
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-6
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user