test(backend): Kotest-Assertions statt JUnit, CLAUDE.md berichtigt

backend/CLAUDE.md sah Kotest-Assertions vor, der Code benutzte aber die
JUnit-Assertions. Umgestellt bei 25 Aufrufen in zwei Dateien - jetzt billig,
und Test-Abhaengigkeiten sind unkritisch, weil sie in keinem Artefakt landen.
Wo Kotest spezifischer ist, ersetzt es die handgeschriebenen Meldungen:
shouldContain statt assertTrue(contains, "..."), withClue nur noch dort, wo
der Kontext wirklich hilft.

Gegenprobe per Mutation: eine falsche Erwartung im Service-Test und eine im
BDD-Schritt lassen genau die danach benannten Tests fallen.

CLAUDE.md berichtigt: Das Backend ist nicht mehr "noch nicht bootstrapped",
die Konfiguration heisst application.yaml, die Schichten entsprechen dem
tatsaechlichen Aufbau, und die drei Spring-Boot-4-Fallen von heute sind
festgehalten, damit sie niemanden erneut treffen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 15:29:24 +02:00
co-authored by Claude Opus 5
parent f3afc8dfa8
commit b6509ae537
5 changed files with 91 additions and 64 deletions
+33 -8
View File
@@ -1,17 +1,42 @@
# Werkbaum · Backend # Werkbaum · Backend
Kotlin/Spring-Boot-Anwendung. Aufgaben: Persistenz der Notationstexte, Kotlin/Spring-Boot-Anwendung. Aufgaben: Persistenz der Notationstexte,
Taiga-Integration (REST-API, `#ref`-Auflösung, Status-Sync), später Live-Editing (D76), Taiga-Integration (REST-API, `#ref`-Auflösung,
Tenzu-Adapter. Noch nicht bootstrapped — siehe README.md hier. Status-Sync), später Tenzu-Adapter.
**Stand:** Gerüst steht — Dokumenten-CRUD mit Historie und Wiederherstellung,
API-First aus `src/main/resources/openapi/api.yaml`, H2 mit Liquibase.
Kommandos in README.md hier. Live-Editing ist entschieden (D76), aber noch
nicht gebaut: `docs/live-editing-proposal.md`.
## Konventionen ## Konventionen
- Kotlin, Spring Boot, Gradle (Kotlin DSL), JDK 21. - Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
- Paketwurzel `de.werkbaum`; Schichten: `api` (Controller/DTOs), - Paketwurzel `de.werkbaum`; Schichten: `api` (Controller), `domain`,
`domain`, `integration.taiga` (Client, Mapping), `persistence`. `service`, `repository` (Interfaces), `persistence` (JPA), später
- Tests mit JUnit 5 + Kotest-Assertions; Taiga-Client gegen `integration.taiga` (Client, Mapping).
aufgezeichnete Antworten (WireMock), nie gegen Live-Instanzen. - **API First:** Interfaces und Modelle werden aus der OpenAPI-Spezifikation
- Konfiguration über `application.yml` + Umgebungsvariablen; generiert; der Controller implementiert sie. Ändert sich die Spec, schlägt
der Compile fehl — genau so ist es gewollt.
- Tests mit JUnit 5 als Runner + **Kotest-Assertions** (`shouldBe`,
`shouldContain`, `shouldThrow`) und MockK; Verhalten per Cucumber gegen die
laufende Anwendung (`RestTestClient`, nicht TestRestTemplate — das ist in
Boot 4 Auslaufmodell). Taiga-Client gegen aufgezeichnete Antworten
(WireMock), nie gegen Live-Instanzen.
- Konfiguration über `application.yaml` + Umgebungsvariablen;
keine Zugangsdaten im Repository. keine Zugangsdaten im Repository.
- Keine neuen **Laufzeit**-Abhängigkeiten ohne Rückfrage (Wurzel-CLAUDE.md);
Test-Abhängigkeiten sind unkritisch, sie landen in keinem Artefakt.
## Spring Boot 4 — drei Fallen (D13-Nachtrag)
Vieles ist aus dem Kern in eigene Module gewandert. Was uns getroffen hat:
- `spring-boot-starter-test` bringt **kein** `TestRestTemplate`/`RestTestClient`
mit — dafür `spring-boot-resttestclient`.
- `@SpringBootTest` stellt die Test-Client-Bean **nicht** mehr von selbst
bereit: `@AutoConfigureRestTestClient` gehört an die Testkonfiguration.
- `org.liquibase:liquibase-core` allein bringt die Autokonfiguration nicht
mehr mit; ohne `spring-boot-starter-liquibase` läuft keine Migration, und
der Fehler zeigt sich erst spät als „Schema validation: missing table".
## Wichtig (D14 — Parser-Hoheit) ## Wichtig (D14 — Parser-Hoheit)
Das Backend parst die Notation **nicht**. Es speichert den Text als Ganzes Das Backend parst die Notation **nicht**. Es speichert den Text als Ganzes
+3
View File
@@ -23,6 +23,7 @@ repositories {
val cucumberVersion = "7.23.0" val cucumberVersion = "7.23.0"
val mockkVersion = "1.13.16" val mockkVersion = "1.13.16"
val kotestVersion = "5.9.1"
dependencies { dependencies {
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
@@ -41,6 +42,8 @@ dependencies {
// Auslaufmodell; die BDD-Tests nutzen den Nachfolger aus spring-test. // Auslaufmodell; die BDD-Tests nutzen den Nachfolger aus spring-test.
testImplementation("org.springframework.boot:spring-boot-resttestclient") testImplementation("org.springframework.boot:spring-boot-resttestclient")
testImplementation("io.mockk:mockk:$mockkVersion") testImplementation("io.mockk:mockk:$mockkVersion")
// Nur die Assertions - Testrunner bleibt JUnit 5 (backend/CLAUDE.md)
testImplementation("io.kotest:kotest-assertions-core:$kotestVersion")
// Behavior-Tests (BDD) mit Cucumber // Behavior-Tests (BDD) mit Cucumber
testImplementation("io.cucumber:cucumber-java:$cucumberVersion") testImplementation("io.cucumber:cucumber-java:$cucumberVersion")
@@ -4,9 +4,10 @@ import io.cucumber.java.de.Angenommen
import io.cucumber.java.de.Dann import io.cucumber.java.de.Dann
import io.cucumber.java.de.Und import io.cucumber.java.de.Und
import io.cucumber.java.de.Wenn import io.cucumber.java.de.Wenn
import org.junit.jupiter.api.Assertions.assertEquals import io.kotest.assertions.withClue
import org.junit.jupiter.api.Assertions.assertNotNull import io.kotest.matchers.nulls.shouldNotBeNull
import org.junit.jupiter.api.Assertions.assertTrue import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.web.servlet.client.EntityExchangeResult import org.springframework.test.web.servlet.client.EntityExchangeResult
@@ -31,7 +32,7 @@ class DocumentStepDefinitions {
private fun status(): Int? = lastResponse?.status?.value() private fun status(): Int? = lastResponse?.status?.value()
private fun body(): String? = lastResponse?.responseBody private fun body(): String = lastResponse?.responseBody.orEmpty()
private fun createDocument(title: String, content: String): EntityExchangeResult<String> = private fun createDocument(title: String, content: String): EntityExchangeResult<String> =
client.post() client.post()
@@ -41,18 +42,17 @@ class DocumentStepDefinitions {
.exchange() .exchange()
.returnResult(String::class.java) .returnResult(String::class.java)
private fun extractId(body: String?): String { private fun extractId(body: String?): String =
val match = Regex("\"id\"\\s*:\\s*\"([^\"]+)\"").find(body ?: "") withClue("Antwort enthält keine ID: $body") {
assertNotNull(match, "Antwort enthält keine ID: $body") Regex("\"id\"\\s*:\\s*\"([^\"]+)\"").find(body ?: "").shouldNotBeNull()
return match!!.groupValues[1] }.groupValues[1]
}
// ---------------- Angenommen ---------------- // ---------------- Angenommen ----------------
@Angenommen("es existiert ein Dokument mit dem Titel {string}") @Angenommen("es existiert ein Dokument mit dem Titel {string}")
fun `es existiert ein Dokument`(titel: String) { fun `es existiert ein Dokument`(titel: String) {
val response = createDocument(titel, "Initialer Inhalt") val response = createDocument(titel, "Initialer Inhalt")
assertEquals(201, response.status.value(), "Testdatenanlage fehlgeschlagen") withClue("Testdatenanlage fehlgeschlagen") { response.status.value() shouldBe 201 }
currentDocumentId = extractId(response.responseBody) currentDocumentId = extractId(response.responseBody)
} }
@@ -62,7 +62,7 @@ class DocumentStepDefinitions {
fun `ich lege ein Dokument an`(titel: String, inhalt: String) { fun `ich lege ein Dokument an`(titel: String, inhalt: String) {
lastResponse = createDocument(titel, inhalt) lastResponse = createDocument(titel, inhalt)
currentDocumentId = Regex("\"id\"\\s*:\\s*\"([^\"]+)\"") currentDocumentId = Regex("\"id\"\\s*:\\s*\"([^\"]+)\"")
.find(body() ?: "")?.groupValues?.get(1) .find(body())?.groupValues?.get(1)
} }
@Wenn("ich alle Dokumente abrufe") @Wenn("ich alle Dokumente abrufe")
@@ -110,30 +110,24 @@ class DocumentStepDefinitions {
// ---------------- Dann / Und ---------------- // ---------------- Dann / Und ----------------
@Dann("erhalte ich den Status {int}") @Dann("erhalte ich den Status {int}")
fun `erhalte ich den Status`(status: Int) { fun `erhalte ich den Status`(erwartet: Int) {
assertEquals(status, status()) status() shouldBe erwartet
} }
@Und("die Antwort enthält den Titel {string}") @Und("die Antwort enthält den Titel {string}")
fun `die Antwort enthaelt den Titel`(titel: String) { fun `die Antwort enthaelt den Titel`(titel: String) {
assertTrue( body() shouldContain "\"title\":\"$titel\""
body()?.contains("\"title\":\"$titel\"") == true,
"Erwarteter Titel '$titel' nicht in Antwort: ${body()}",
)
} }
@Und("die Antwort enthält {int} Dokumente") @Und("die Antwort enthält {int} Dokumente")
fun `die Antwort enthaelt n Dokumente`(anzahl: Int) { fun `die Antwort enthaelt n Dokumente`(anzahl: Int) {
val count = Regex("\"id\"").findAll(body() ?: "").count() val count = Regex("\"id\"").findAll(body()).count()
assertEquals(anzahl, count, "Antwort: ${body()}") withClue("Antwort: ${body()}") { count shouldBe anzahl }
} }
@Und("die Antwort enthält die Version {long}") @Und("die Antwort enthält die Version {long}")
fun `die Antwort enthaelt die Version`(version: Long) { fun `die Antwort enthaelt die Version`(version: Long) {
assertTrue( body() shouldContain "\"version\":$version"
body()?.contains("\"version\":$version") == true,
"Erwartete Version $version nicht in Antwort: ${body()}",
)
} }
@Und("das Dokument ist nicht mehr abrufbar") @Und("das Dokument ist nicht mehr abrufbar")
@@ -142,7 +136,7 @@ class DocumentStepDefinitions {
.uri("/api/v1/documents/$currentDocumentId") .uri("/api/v1/documents/$currentDocumentId")
.exchange() .exchange()
.returnResult(String::class.java) .returnResult(String::class.java)
assertEquals(404, response.status.value()) response.status.value() shouldBe 404
} }
// ---------------- Historie & Wiederherstellung ---------------- // ---------------- Historie & Wiederherstellung ----------------
@@ -167,16 +161,13 @@ class DocumentStepDefinitions {
@Und("die Antwort enthält {int} Historieneinträge") @Und("die Antwort enthält {int} Historieneinträge")
fun `die Antwort enthaelt n Historieneintraege`(anzahl: Int) { fun `die Antwort enthaelt n Historieneintraege`(anzahl: Int) {
val count = Regex("\"changeType\"").findAll(body() ?: "").count() val count = Regex("\"changeType\"").findAll(body()).count()
assertEquals(anzahl, count, "Antwort: ${body()}") withClue("Antwort: ${body()}") { count shouldBe anzahl }
} }
@Und("die Antwort enthält den Änderungstyp {string}") @Und("die Antwort enthält den Änderungstyp {string}")
fun `die Antwort enthaelt den Aenderungstyp`(typ: String) { fun `die Antwort enthaelt den Aenderungstyp`(typ: String) {
assertTrue( body() shouldContain "\"changeType\":\"$typ\""
body()?.contains("\"changeType\":\"$typ\"") == true,
"Erwarteter Änderungstyp '$typ' nicht in Antwort: ${body()}",
)
} }
@Und("das Dokument ist wieder abrufbar") @Und("das Dokument ist wieder abrufbar")
@@ -185,6 +176,6 @@ class DocumentStepDefinitions {
.uri("/api/v1/documents/$currentDocumentId") .uri("/api/v1/documents/$currentDocumentId")
.exchange() .exchange()
.returnResult(String::class.java) .returnResult(String::class.java)
assertEquals(200, response.status.value()) response.status.value() shouldBe 200
} }
} }
@@ -5,14 +5,14 @@ import de.werkbaum.domain.Document
import de.werkbaum.domain.DocumentHistoryEntry import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.repository.DocumentHistoryRepository import de.werkbaum.repository.DocumentHistoryRepository
import de.werkbaum.repository.DocumentRepository import de.werkbaum.repository.DocumentRepository
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs import io.mockk.runs
import io.mockk.slot import io.mockk.slot
import io.mockk.verify import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.Clock import java.time.Clock
import java.time.Instant import java.time.Instant
@@ -65,10 +65,10 @@ class DocumentServiceTest {
val result = service.create(title = "Notizen", content = "Hallo") val result = service.create(title = "Notizen", content = "Hallo")
assertEquals(1, result.version) result.version shouldBe 1
assertEquals(ChangeType.CREATED, historyEntry.captured.changeType) historyEntry.captured.changeType shouldBe ChangeType.CREATED
assertEquals(result.id, historyEntry.captured.documentId) historyEntry.captured.documentId shouldBe result.id
assertEquals("Hallo", historyEntry.captured.content) historyEntry.captured.content shouldBe "Hallo"
} }
@Test @Test
@@ -82,9 +82,9 @@ class DocumentServiceTest {
val result = service.update(doc.id, title = "Neu", content = "Neuer Inhalt") val result = service.update(doc.id, title = "Neu", content = "Neuer Inhalt")
assertEquals(4, result.version) result.version shouldBe 4
assertEquals(ChangeType.UPDATED, historyEntry.captured.changeType) historyEntry.captured.changeType shouldBe ChangeType.UPDATED
assertEquals(4, historyEntry.captured.version) historyEntry.captured.version shouldBe 4
} }
@Test @Test
@@ -98,8 +98,8 @@ class DocumentServiceTest {
service.delete(doc.id) service.delete(doc.id)
verify(exactly = 1) { repository.deleteById(doc.id) } verify(exactly = 1) { repository.deleteById(doc.id) }
assertEquals(ChangeType.DELETED, historyEntry.captured.changeType) historyEntry.captured.changeType shouldBe ChangeType.DELETED
assertEquals(3, historyEntry.captured.version) historyEntry.captured.version shouldBe 3
} }
@Test @Test
@@ -107,7 +107,7 @@ class DocumentServiceTest {
val id = UUID.randomUUID() val id = UUID.randomUUID()
every { repository.findById(id) } returns null every { repository.findById(id) } returns null
assertThrows(DocumentNotFoundException::class.java) { service.delete(id) } shouldThrow<DocumentNotFoundException> { service.delete(id) }
} }
@Test @Test
@@ -119,7 +119,7 @@ class DocumentServiceTest {
) )
every { historyRepository.findByDocumentId(id) } returns entries every { historyRepository.findByDocumentId(id) } returns entries
assertEquals(entries, service.history(id)) service.history(id) shouldBe entries
} }
@Test @Test
@@ -127,7 +127,7 @@ class DocumentServiceTest {
val id = UUID.randomUUID() val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList() every { historyRepository.findByDocumentId(id) } returns emptyList()
assertThrows(DocumentNotFoundException::class.java) { service.history(id) } shouldThrow<DocumentNotFoundException> { service.history(id) }
} }
@Test @Test
@@ -146,11 +146,11 @@ class DocumentServiceTest {
val result = service.restore(id) val result = service.restore(id)
assertEquals(id, result.id) result.id shouldBe id
assertEquals("Titel v2", result.title) result.title shouldBe "Titel v2"
assertEquals("Inhalt v2", result.content) result.content shouldBe "Inhalt v2"
assertEquals(4, result.version) result.version shouldBe 4
assertEquals(ChangeType.RESTORED, historyEntry.captured.changeType) historyEntry.captured.changeType shouldBe ChangeType.RESTORED
} }
@Test @Test
@@ -169,8 +169,8 @@ class DocumentServiceTest {
val result = service.restore(id, targetVersion = 1) val result = service.restore(id, targetVersion = 1)
assertEquals("Titel v1", result.title) result.title shouldBe "Titel v1"
assertEquals(4, result.version) result.version shouldBe 4
} }
@Test @Test
@@ -181,7 +181,7 @@ class DocumentServiceTest {
) )
every { repository.findById(id) } returns sampleDocument(id = id) every { repository.findById(id) } returns sampleDocument(id = id)
assertThrows(DocumentConflictException::class.java) { service.restore(id) } shouldThrow<DocumentConflictException> { service.restore(id) }
} }
@Test @Test
@@ -189,7 +189,7 @@ class DocumentServiceTest {
val id = UUID.randomUUID() val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList() every { historyRepository.findByDocumentId(id) } returns emptyList()
assertThrows(DocumentNotFoundException::class.java) { service.restore(id) } shouldThrow<DocumentNotFoundException> { service.restore(id) }
} }
@Test @Test
@@ -197,7 +197,7 @@ class DocumentServiceTest {
val id = UUID.randomUUID() val id = UUID.randomUUID()
every { repository.findById(id) } returns null every { repository.findById(id) } returns null
assertThrows(DocumentNotFoundException::class.java) { service.findById(id) } shouldThrow<DocumentNotFoundException> { service.findById(id) }
} }
@Test @Test
@@ -205,6 +205,6 @@ class DocumentServiceTest {
val docs = listOf(sampleDocument(), sampleDocument()) val docs = listOf(sampleDocument(), sampleDocument())
every { repository.findAll() } returns docs every { repository.findAll() } returns docs
assertEquals(docs, service.findAll()) service.findAll() shouldBe docs
} }
} }
+8
View File
@@ -5994,6 +5994,14 @@ Kern-Artefakt in eigene Module gewandert ist:
`spring-boot-starter-liquibase` läuft keine Migration, und die Tests `spring-boot-starter-liquibase` läuft keine Migration, und die Tests
scheitern erst spät mit „Schema validation: missing table". scheitern erst spät mit „Schema validation: missing table".
Die Zusicherungen sind auf **Kotest** umgestellt (`shouldBe`,
`shouldContain`, `shouldThrow`) — `backend/CLAUDE.md` hatte das vorgesehen,
der Code benutzte aber JUnit-Assertions. Bei 25 Aufrufen war es billig, und
Test-Abhängigkeiten sind unkritisch, weil sie in keinem Artefakt landen.
Gegenprobe per Mutation: eine falsche Erwartung im Service-Test und eine im
BDD-Schritt lassen genau die danach benannten Tests fallen — die
Zusicherungen greifen also, statt nur gut auszusehen.
Die BDD-Tests nutzen jetzt **`RestTestClient`** (aus `spring-test`) statt Die BDD-Tests nutzen jetzt **`RestTestClient`** (aus `spring-test`) statt
`TestRestTemplate`, das in Boot 4 als Auslaufmodell gilt. Umgestellt bei `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 fünfzehn Aufrufen in einer Datei — die Zahl wächst von hier an nur. Weil