From b6509ae537343777c95694d2840e8aa8e20c1bcb Mon Sep 17 00:00:00 2001 From: mhoennig Date: Wed, 26 Aug 2026 15:29:24 +0200 Subject: [PATCH] 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 --- backend/CLAUDE.md | 41 +++++++++++--- backend/build.gradle.kts | 3 ++ .../werkbaum/bdd/DocumentStepDefinitions.kt | 53 ++++++++----------- .../werkbaum/service/DocumentServiceTest.kt | 50 ++++++++--------- docs/DECISIONS.md | 8 +++ 5 files changed, 91 insertions(+), 64 deletions(-) diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index e406f01..7f44429 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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 diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 682afe5..38f3f30 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -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") diff --git a/backend/src/test/kotlin/de/werkbaum/bdd/DocumentStepDefinitions.kt b/backend/src/test/kotlin/de/werkbaum/bdd/DocumentStepDefinitions.kt index cc7f74b..88dde2b 100644 --- a/backend/src/test/kotlin/de/werkbaum/bdd/DocumentStepDefinitions.kt +++ b/backend/src/test/kotlin/de/werkbaum/bdd/DocumentStepDefinitions.kt @@ -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 = 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 } } diff --git a/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt b/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt index 33bc849..ddcdc14 100644 --- a/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt +++ b/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt @@ -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 { 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 { 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 { 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 { 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 { 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 } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c2f2982..3459ed4 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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