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,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 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
|
||||
+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.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())
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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
|
||||
Reference in New Issue
Block a user