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
Kotlin/Spring-Boot-Anwendung. Aufgaben: Persistenz der Notationstexte,
Taiga-Integration (REST-API, `#ref`-Auflösung, Status-Sync), später
Tenzu-Adapter. Noch nicht bootstrapped — siehe README.md hier.
Live-Editing (D76), Taiga-Integration (REST-API, `#ref`-Auflösung,
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
- Kotlin, Spring Boot, Gradle (Kotlin DSL), JDK 21.
- Paketwurzel `de.werkbaum`; Schichten: `api` (Controller/DTOs),
`domain`, `integration.taiga` (Client, Mapping), `persistence`.
- Tests mit JUnit 5 + Kotest-Assertions; Taiga-Client gegen
aufgezeichnete Antworten (WireMock), nie gegen Live-Instanzen.
- Konfiguration über `application.yml` + Umgebungsvariablen;
- Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
- Paketwurzel `de.werkbaum`; Schichten: `api` (Controller), `domain`,
`service`, `repository` (Interfaces), `persistence` (JPA), später
`integration.taiga` (Client, Mapping).
- **API First:** Interfaces und Modelle werden aus der OpenAPI-Spezifikation
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 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)
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 mockkVersion = "1.13.16"
val kotestVersion = "5.9.1"
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
@@ -41,6 +42,8 @@ dependencies {
// Auslaufmodell; die BDD-Tests nutzen den Nachfolger aus spring-test.
testImplementation("org.springframework.boot:spring-boot-resttestclient")
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
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.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 io.kotest.assertions.withClue
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.client.EntityExchangeResult
@@ -31,7 +32,7 @@ class DocumentStepDefinitions {
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> =
client.post()
@@ -41,18 +42,17 @@ class DocumentStepDefinitions {
.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]
}
private fun extractId(body: String?): String =
withClue("Antwort enthält keine ID: $body") {
Regex("\"id\"\\s*:\\s*\"([^\"]+)\"").find(body ?: "").shouldNotBeNull()
}.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")
withClue("Testdatenanlage fehlgeschlagen") { response.status.value() shouldBe 201 }
currentDocumentId = extractId(response.responseBody)
}
@@ -62,7 +62,7 @@ class DocumentStepDefinitions {
fun `ich lege ein Dokument an`(titel: String, inhalt: String) {
lastResponse = createDocument(titel, inhalt)
currentDocumentId = Regex("\"id\"\\s*:\\s*\"([^\"]+)\"")
.find(body() ?: "")?.groupValues?.get(1)
.find(body())?.groupValues?.get(1)
}
@Wenn("ich alle Dokumente abrufe")
@@ -110,30 +110,24 @@ class DocumentStepDefinitions {
// ---------------- Dann / Und ----------------
@Dann("erhalte ich den Status {int}")
fun `erhalte ich den Status`(status: Int) {
assertEquals(status, status())
fun `erhalte ich den Status`(erwartet: Int) {
status() shouldBe erwartet
}
@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()}",
)
body() shouldContain "\"title\":\"$titel\""
}
@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()}")
val count = Regex("\"id\"").findAll(body()).count()
withClue("Antwort: ${body()}") { count shouldBe anzahl }
}
@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()}",
)
body() shouldContain "\"version\":$version"
}
@Und("das Dokument ist nicht mehr abrufbar")
@@ -142,7 +136,7 @@ class DocumentStepDefinitions {
.uri("/api/v1/documents/$currentDocumentId")
.exchange()
.returnResult(String::class.java)
assertEquals(404, response.status.value())
response.status.value() shouldBe 404
}
// ---------------- Historie & Wiederherstellung ----------------
@@ -167,16 +161,13 @@ class DocumentStepDefinitions {
@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()}")
val count = Regex("\"changeType\"").findAll(body()).count()
withClue("Antwort: ${body()}") { count shouldBe anzahl }
}
@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()}",
)
body() shouldContain "\"changeType\":\"$typ\""
}
@Und("das Dokument ist wieder abrufbar")
@@ -185,6 +176,6 @@ class DocumentStepDefinitions {
.uri("/api/v1/documents/$currentDocumentId")
.exchange()
.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.repository.DocumentHistoryRepository
import de.werkbaum.repository.DocumentRepository
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.slot
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 java.time.Clock
import java.time.Instant
@@ -65,10 +65,10 @@ class DocumentServiceTest {
val result = service.create(title = "Notizen", content = "Hallo")
assertEquals(1, result.version)
assertEquals(ChangeType.CREATED, historyEntry.captured.changeType)
assertEquals(result.id, historyEntry.captured.documentId)
assertEquals("Hallo", historyEntry.captured.content)
result.version shouldBe 1
historyEntry.captured.changeType shouldBe ChangeType.CREATED
historyEntry.captured.documentId shouldBe result.id
historyEntry.captured.content shouldBe "Hallo"
}
@Test
@@ -82,9 +82,9 @@ class DocumentServiceTest {
val result = service.update(doc.id, title = "Neu", content = "Neuer Inhalt")
assertEquals(4, result.version)
assertEquals(ChangeType.UPDATED, historyEntry.captured.changeType)
assertEquals(4, historyEntry.captured.version)
result.version shouldBe 4
historyEntry.captured.changeType shouldBe ChangeType.UPDATED
historyEntry.captured.version shouldBe 4
}
@Test
@@ -98,8 +98,8 @@ class DocumentServiceTest {
service.delete(doc.id)
verify(exactly = 1) { repository.deleteById(doc.id) }
assertEquals(ChangeType.DELETED, historyEntry.captured.changeType)
assertEquals(3, historyEntry.captured.version)
historyEntry.captured.changeType shouldBe ChangeType.DELETED
historyEntry.captured.version shouldBe 3
}
@Test
@@ -107,7 +107,7 @@ class DocumentServiceTest {
val id = UUID.randomUUID()
every { repository.findById(id) } returns null
assertThrows(DocumentNotFoundException::class.java) { service.delete(id) }
shouldThrow<DocumentNotFoundException> { service.delete(id) }
}
@Test
@@ -119,7 +119,7 @@ class DocumentServiceTest {
)
every { historyRepository.findByDocumentId(id) } returns entries
assertEquals(entries, service.history(id))
service.history(id) shouldBe entries
}
@Test
@@ -127,7 +127,7 @@ class DocumentServiceTest {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList()
assertThrows(DocumentNotFoundException::class.java) { service.history(id) }
shouldThrow<DocumentNotFoundException> { service.history(id) }
}
@Test
@@ -146,11 +146,11 @@ class DocumentServiceTest {
val result = service.restore(id)
assertEquals(id, result.id)
assertEquals("Titel v2", result.title)
assertEquals("Inhalt v2", result.content)
assertEquals(4, result.version)
assertEquals(ChangeType.RESTORED, historyEntry.captured.changeType)
result.id shouldBe id
result.title shouldBe "Titel v2"
result.content shouldBe "Inhalt v2"
result.version shouldBe 4
historyEntry.captured.changeType shouldBe ChangeType.RESTORED
}
@Test
@@ -169,8 +169,8 @@ class DocumentServiceTest {
val result = service.restore(id, targetVersion = 1)
assertEquals("Titel v1", result.title)
assertEquals(4, result.version)
result.title shouldBe "Titel v1"
result.version shouldBe 4
}
@Test
@@ -181,7 +181,7 @@ class DocumentServiceTest {
)
every { repository.findById(id) } returns sampleDocument(id = id)
assertThrows(DocumentConflictException::class.java) { service.restore(id) }
shouldThrow<DocumentConflictException> { service.restore(id) }
}
@Test
@@ -189,7 +189,7 @@ class DocumentServiceTest {
val id = UUID.randomUUID()
every { historyRepository.findByDocumentId(id) } returns emptyList()
assertThrows(DocumentNotFoundException::class.java) { service.restore(id) }
shouldThrow<DocumentNotFoundException> { service.restore(id) }
}
@Test
@@ -197,7 +197,7 @@ class DocumentServiceTest {
val id = UUID.randomUUID()
every { repository.findById(id) } returns null
assertThrows(DocumentNotFoundException::class.java) { service.findById(id) }
shouldThrow<DocumentNotFoundException> { service.findById(id) }
}
@Test
@@ -205,6 +205,6 @@ class DocumentServiceTest {
val docs = listOf(sampleDocument(), sampleDocument())
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
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
`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