diff --git a/CLAUDE.md b/CLAUDE.md index bbc2f0a..7bb7f4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ All production code lives under `de.hoennig.gittally`. Commands are in the `comm ## Testing Conventions -Tests use **Kotest `FunSpec`** style. `SpringExtension` is registered globally in `KotestProjectConfig` — do not add it per-spec. +Tests use **Kotest `FunSpec`** style. `SpringExtension` is registered globally in `io.kotest.provided.ProjectConfig` — do not add it per-spec. ```kotlin class MyTest : FunSpec() { @@ -63,7 +63,21 @@ Use `shouldBe`, `shouldNotBe`, `shouldThrow` etc. from `io.kotest.matchers`. ### Mocking in Spring Slice Tests -`springmockk` is not compatible with the target Spring Boot version (4.0). The `@MockkBean` annotation is unavailable. Use `@TestConfiguration` to register MockK mocks as Spring beans: +Use `@MockkBean` from `springmockk` to inject MockK mocks into the Spring context: + +```kotlin +@WebMvcTest(SomeController::class) +class SomeControllerTest : FunSpec() { + @MockkBean + lateinit var someService: SomeService + init { + beforeEach { clearMocks(someService) } + // full MockK syntax: every { } / verify { } + } +} +``` + +Alternatively, register mocks via `@TestConfiguration` without the springmockk dependency: ```kotlin @WebMvcTest(SomeController::class) @@ -87,7 +101,5 @@ Pure unit tests (no Spring context) use MockK directly without any Spring wiring All major decisions are in `docs/adrs/`. Run `adr-status` (after `source .envrc`) for a one-line summary of each. Decisions in force: - **Test framework**: Kotest + MockK + WireMock + Testcontainers (ADR 0001) -- **Gradle**: 8.14 target (currently 8.8; upgrade pending Spring Boot 4.0 migration) (ADR 0002) -- **Spring Boot**: 4.0 target (currently 3.3.4 — pending upgrade; 3.3.x is EOL) (ADR 0003) - -The Spring Boot and Gradle upgrades are open work items documented in the ADRs. When upgrading, verify `ApplicationContextTest` passes as the primary smoke test. +- **Gradle**: 8.14.5 (ADR 0002) +- **Spring Boot**: 4.0.6 (ADR 0003) diff --git a/build.gradle.kts b/build.gradle.kts index 7e1b236..050640c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,11 +1,11 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - id("org.springframework.boot") version "3.3.4" - id("io.spring.dependency-management") version "1.1.6" - id("org.jlleitschuh.gradle.ktlint") version "12.1.1" - kotlin("jvm") version "1.9.25" - kotlin("plugin.spring") version "1.9.25" + id("org.springframework.boot") version "4.0.6" + id("io.spring.dependency-management") version "1.1.7" + id("org.jlleitschuh.gradle.ktlint") version "14.2.0" + kotlin("jvm") version "2.2.0" + kotlin("plugin.spring") version "2.2.0" } group = "de.hoennig" @@ -29,26 +29,26 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") // Kotest - testImplementation("io.kotest:kotest-runner-junit5:5.9.1") - testImplementation("io.kotest:kotest-assertions-core:5.9.1") - testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0") + testImplementation("io.kotest:kotest-runner-junit5:6.1.5") + testImplementation("io.kotest:kotest-assertions-core:6.1.5") + testImplementation("io.kotest:kotest-extensions-spring:6.1.5") // MockK testImplementation("io.mockk:mockk:1.13.11") - testImplementation("com.ninja-squad:springmockk:4.0.2") + testImplementation("com.ninja-squad:springmockk:5.0.1") // WireMock - testImplementation("org.wiremock.integrations:wiremock-spring-boot:3.1.0") + testImplementation("org.wiremock.integrations:wiremock-spring-boot:4.2.1") // Testcontainers (versions managed by Spring Boot BOM) testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.testcontainers:testcontainers") } -tasks.withType { - kotlinOptions { - freeCompilerArgs += "-Xjsr305=strict" - jvmTarget = "21" +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + jvmTarget = JvmTarget.JVM_21 } } diff --git a/docs/adrs/0002-2026-06-09.gradle-version.md b/docs/adrs/0002-2026-06-09.gradle-version.md index 63b1674..a265cfa 100644 --- a/docs/adrs/0002-2026-06-09.gradle-version.md +++ b/docs/adrs/0002-2026-06-09.gradle-version.md @@ -2,11 +2,11 @@ **Status:** - proposed: 2026-06-09 -- accepted: - +- accepted: 2026-06-09 - rejected: - - superseded: - -**Decision [proposed]:** Gradle 8.14 — minimum required by Spring Boot 4.0; Gradle 9 deferred until openapi-generator and ktlint-gradle confirm compatibility. +**Decision [accepted]:** Gradle 8.14.5 — minimum required by Spring Boot 4.0; Gradle 9 deferred until openapi-generator and ktlint-gradle confirm compatibility. ## Context and Problem Statement @@ -41,7 +41,7 @@ It has a direct dependency chain: - Plugin ecosystem readiness varies: the [openapi-generator Gradle plugin](https://github.com/OpenAPITools/openapi-generator/issues/22084) has a known Gradle 9 issue in its generated build files, and - [ktlint-gradle 12.1.1](https://github.com/JLLeitschuh/ktlint-gradle) Gradle 9 support + [ktlint-gradle 14.2.0](https://github.com/JLLeitschuh/ktlint-gradle) Gradle 9 support needs verification. - The [`io.spring.dependency-management` plugin](https://github.com/spring-gradle-plugins/dependency-management-plugin) used to resolve the Spring BOM has known friction with Gradle 9 @@ -56,9 +56,9 @@ current 8.8 is necessary regardless of whether Gradle 9 is chosen. * Gradle 8.14 (minimum for Spring Boot 4.0) * Gradle 9.x -### [Gradle 8.x](https://docs.gradle.org/8.8/release-notes.html) +### [Gradle 8.x](https://docs.gradle.org/8.14.5/release-notes.html) -The currently configured series (wrapper at 8.8, to be bumped to 8.14+ for Spring Boot 4.0). +The currently configured series (wrapper at 8.14.5, upgraded from 8.8 as part of the Spring Boot 4.0 migration). #### Advantages @@ -91,7 +91,7 @@ The current major release (9.5.1 as of May 2026), officially supported alongside - [openapi-generator-gradle-plugin](https://github.com/OpenAPITools/openapi-generator/issues/22084) has a known Gradle 9 issue in generated `build.gradle` files; impact on the spec-first code generation task specifically needs verification. -- [ktlint-gradle 12.1.1](https://github.com/JLLeitschuh/ktlint-gradle) Gradle 9 support +- [ktlint-gradle 14.2.0](https://github.com/JLLeitschuh/ktlint-gradle) Gradle 9 support needs verification. - Configuration Cache mandatory means any currently uncached plugin will break the build until updated, complicating initial setup. @@ -106,7 +106,7 @@ The main reasons: - Gradle 9 is technically compatible with Spring Boot 4.0, but the mandatory Configuration Cache creates unacceptable risk until the openapi-generator-gradle-plugin Gradle 9 compatibility is confirmed (see [issue #22084](https://github.com/OpenAPITools/openapi-generator/issues/22084)). -- ktlint-gradle 12.1.1 Gradle 9 support is unverified; a formatter breaking the build on day +- ktlint-gradle 14.2.0 Gradle 9 support is unverified; a formatter breaking the build on day one is a poor start. Upgrade trigger: revisit Gradle 9 once openapi-generator and ktlint-gradle confirm support. diff --git a/docs/adrs/0003-2026-06-09.spring-boot-version.md b/docs/adrs/0003-2026-06-09.spring-boot-version.md index b9c538d..4969960 100644 --- a/docs/adrs/0003-2026-06-09.spring-boot-version.md +++ b/docs/adrs/0003-2026-06-09.spring-boot-version.md @@ -2,11 +2,11 @@ **Status:** - proposed: 2026-06-09 -- accepted: - +- accepted: 2026-06-09 - rejected: - - superseded: - -**Decision [proposed]:** Spring Boot 4.0 — only actively-supported version; springmockk workaround via @TestConfiguration until a compatible release is published. +**Decision [accepted]:** Spring Boot 4.0.6 — only actively-supported version; springmockk 5.0.1 provides @MockkBean for Spring Boot 4.0. ## Context and Problem Statement @@ -34,12 +34,12 @@ Spring Boot 4.0 moves to Spring Framework 7 and Jakarta EE 11. The key ecosystem dependencies and their Spring Boot 4.0 compatibility status: -| Dependency | Role | Spring Boot 4.0 status | -|---|---|---| -| [picocli-spring-boot-starter](https://picocli.info/) | CLI wiring | Untested; last documented target is Spring Boot 3.1. The integration is minimal (one `IFactory` bean), so it likely works in practice. | -| [springmockk](https://github.com/Ninja-Squad/springmockk) | `@MockkBean` in tests | **Incompatible** — confirmed Spring Boot 3.x only. | -| [kotest-extensions-spring](https://github.com/kotest/kotest-extensions-spring) | Kotest + `@SpringBootTest` | Compatibility unconfirmed. | -| [wiremock-spring-boot](https://wiremock.org/docs/spring-boot/) | HTTP stubbing in tests | **Compatible** — Spring Boot 4.0 explicitly supported since January 2026. | +| Dependency | Version | Role | Spring Boot 4.0 status | +|---|---|---|---| +| [picocli-spring-boot-starter](https://picocli.info/) | 4.7.6 | CLI wiring | **Compatible** — `ApplicationContextTest` confirms context wiring works. | +| [springmockk](https://github.com/Ninja-Squad/springmockk) | 5.0.1 | `@MockkBean` in tests | **Compatible** — 5.x released specifically for Spring Boot 4.0; `@MockkBean` verified. | +| [kotest-extensions-spring](https://github.com/kotest/kotest-extensions-spring) | 6.1.5 | Kotest + `@SpringBootTest` | **Compatible** — 6.x (new groupId `io.kotest`) required; config moved to `io.kotest.provided.ProjectConfig`. | +| [wiremock-spring-boot](https://wiremock.org/docs/spring-boot/) | 4.2.1 | HTTP stubbing in tests | **Compatible** — Spring Boot 4.0 explicitly supported since January 2026. | ## Considered Options @@ -76,12 +76,9 @@ The current and only actively-supported major release. #### Disadvantages -- **springmockk is incompatible.** The `@MockkBean` / `@SpykBean` annotations are not available. - This matters in particular for `@WebMvcTest` (controller error-behaviour) and - `@DataJpaTest`-adjacent tests where collaborators need to be mocked in the Spring context. - The workaround is to register MockK mocks as Spring beans via `@TestConfiguration`, - which preserves full MockK syntax (`every { }`, `verify { }`) at the cost of one - inner configuration class per test file and an explicit `clearMocks()` call in `beforeEach`: +- **springmockk 5.x is required.** Version 4.x is Spring Boot 3.x only. Version 5.0.1 adds + Spring Boot 4.0 support; `@MockkBean` / `@SpykBean` work as expected. + As an alternative, register MockK mocks via `@TestConfiguration` without the springmockk dependency: ```kotlin @WebMvcTest(SomeController::class) @Import(SomeControllerTest.Mocks::class) @@ -93,17 +90,12 @@ The current and only actively-supported major release. @Autowired lateinit var someService: SomeService init { beforeEach { clearMocks(someService) } - // full MockK syntax available } } ``` - This is more verbose than `@MockkBean` but avoids mixing Mockito and MockK. - Revisit once springmockk publishes a Spring Boot 4.0-compatible release. -- **picocli-spring-boot-starter is untested against Spring Boot 4.0.** - The risk is low (the auto-configuration is a single `IFactory` bean with no framework-version-specific API), - but it cannot be assumed safe until verified by a running context test. -- **kotest-extensions-spring** compatibility needs verification before the first - `@SpringBootTest` test is written. +- **picocli-spring-boot-starter** context wiring confirmed via `ApplicationContextTest`. +- **kotest-extensions-spring 6.x** (groupId `io.kotest`) is required for Spring Boot 4.0. + The project config must be placed at `io.kotest.provided.ProjectConfig` (Kotest 6 discovery requirement). - Relatively new GA; fewer community examples for the 4.0 generation. ## Decision Outcome @@ -124,9 +116,4 @@ The main reasons: - kotest-extensions-spring compatibility must be verified before the first `@SpringBootTest` test is written. -Open items before the code is upgraded: -1. Bump Spring Boot to 4.0.6 and verify `ApplicationContextTest` still passes. -2. Confirm picocli-spring-boot-starter works under the new context. -3. Confirm kotest-extensions-spring works with Spring Boot 4.0. -4. Replace `@MockkBean` usages with the `@TestConfiguration` + `mockk()` pattern - until springmockk publishes a compatible release. +All ecosystem blockers are resolved. The codebase runs Spring Boot 4.0.6. diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a441313..4f5eb9d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt b/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt index 0b5fe66..286a2a6 100644 --- a/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt +++ b/src/main/kotlin/de/hoennig/gittally/GitTallyApplication.kt @@ -17,7 +17,8 @@ class GitTallyApplication class CliRunner( private val factory: IFactory, private val rootCommand: GitTallyCommand, -) : CommandLineRunner, ExitCodeGenerator { +) : CommandLineRunner, + ExitCodeGenerator { private var exitCode = 0 override fun run(vararg args: String) { diff --git a/src/main/kotlin/de/hoennig/gittally/GitTallyCommand.kt b/src/main/kotlin/de/hoennig/gittally/GitTallyCommand.kt index 22eace3..127e599 100644 --- a/src/main/kotlin/de/hoennig/gittally/GitTallyCommand.kt +++ b/src/main/kotlin/de/hoennig/gittally/GitTallyCommand.kt @@ -15,7 +15,5 @@ import picocli.CommandLine.Command description = ["Lightweight, declarative CI/CD system"], ) class GitTallyCommand : Runnable { - override fun run() { - throw CommandLine.ParameterException(CommandLine(this), "Specify a subcommand") - } + override fun run(): Unit = throw CommandLine.ParameterException(CommandLine(this), "Specify a subcommand") } diff --git a/src/test/kotlin/de/hoennig/gittally/KotestProjectConfig.kt b/src/test/kotlin/de/hoennig/gittally/KotestProjectConfig.kt deleted file mode 100644 index 13f7379..0000000 --- a/src/test/kotlin/de/hoennig/gittally/KotestProjectConfig.kt +++ /dev/null @@ -1,8 +0,0 @@ -package de.hoennig.gittally - -import io.kotest.core.config.AbstractProjectConfig -import io.kotest.extensions.spring.SpringExtension - -object KotestProjectConfig : AbstractProjectConfig() { - override fun extensions() = listOf(SpringExtension) -} diff --git a/src/test/kotlin/de/hoennig/gittally/framework/MockKSmokeTest.kt b/src/test/kotlin/de/hoennig/gittally/framework/MockKSmokeTest.kt index 068fd5d..f89569b 100644 --- a/src/test/kotlin/de/hoennig/gittally/framework/MockKSmokeTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/framework/MockKSmokeTest.kt @@ -10,14 +10,15 @@ interface Greeter { fun greet(name: String): String } -class MockKSmokeTest : FunSpec({ +class MockKSmokeTest : + FunSpec({ - test("mockk stubs and verifies") { - val greeter = mockk() - every { greeter.greet("world") } returns "hello, world" + test("mockk stubs and verifies") { + val greeter = mockk() + every { greeter.greet("world") } returns "hello, world" - greeter.greet("world") shouldBe "hello, world" + greeter.greet("world") shouldBe "hello, world" - verify(exactly = 1) { greeter.greet("world") } - } -}) + verify(exactly = 1) { greeter.greet("world") } + } + }) diff --git a/src/test/kotlin/de/hoennig/gittally/framework/SpringMockKSmokeTest.kt b/src/test/kotlin/de/hoennig/gittally/framework/SpringMockKSmokeTest.kt index 15dfc42..a52d184 100644 --- a/src/test/kotlin/de/hoennig/gittally/framework/SpringMockKSmokeTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/framework/SpringMockKSmokeTest.kt @@ -21,7 +21,6 @@ interface MessageService { @SpringBootTest @Import(SpringMockKSmokeTest.Mocks::class) class SpringMockKSmokeTest : FunSpec() { - @TestConfiguration class Mocks { @Bean @@ -47,7 +46,6 @@ class SpringMockKSmokeTest : FunSpec() { /** Verifies whether @MockkBean works with the current Spring Boot version (springmockk 4.0.2). */ @SpringBootTest class MockkBeanSmokeTest : FunSpec() { - @MockkBean lateinit var messageService: MessageService diff --git a/src/test/kotlin/de/hoennig/gittally/framework/TestcontainersSmokeTest.kt b/src/test/kotlin/de/hoennig/gittally/framework/TestcontainersSmokeTest.kt index 01282ba..653c39a 100644 --- a/src/test/kotlin/de/hoennig/gittally/framework/TestcontainersSmokeTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/framework/TestcontainersSmokeTest.kt @@ -5,13 +5,15 @@ import io.kotest.matchers.shouldBe import org.testcontainers.containers.GenericContainer import org.testcontainers.utility.DockerImageName -class TestcontainersSmokeTest : FunSpec({ +class TestcontainersSmokeTest : + FunSpec({ - test("Testcontainers starts a container") { - val container = GenericContainer(DockerImageName.parse("alpine:3")) - .withCommand("sh", "-c", "sleep 30") - container.start() - container.isRunning shouldBe true - container.stop() - } -}) + test("Testcontainers starts a container") { + val container = + GenericContainer(DockerImageName.parse("alpine:3")) + .withCommand("sh", "-c", "sleep 30") + container.start() + container.isRunning shouldBe true + container.stop() + } + }) diff --git a/src/test/kotlin/de/hoennig/gittally/framework/WireMockSmokeTest.kt b/src/test/kotlin/de/hoennig/gittally/framework/WireMockSmokeTest.kt index c85269e..042c9f3 100644 --- a/src/test/kotlin/de/hoennig/gittally/framework/WireMockSmokeTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/framework/WireMockSmokeTest.kt @@ -11,22 +11,24 @@ import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse -class WireMockSmokeTest : FunSpec({ +class WireMockSmokeTest : + FunSpec({ - val server = WireMockServer(options().dynamicPort()) + val server = WireMockServer(options().dynamicPort()) - beforeSpec { server.start() } - afterSpec { server.stop() } + beforeSpec { server.start() } + afterSpec { server.stop() } - test("WireMock stubs an HTTP endpoint") { - server.stubFor(get("/ping").willReturn(aResponse().withStatus(200).withBody("pong"))) + test("WireMock stubs an HTTP endpoint") { + server.stubFor(get("/ping").willReturn(aResponse().withStatus(200).withBody("pong"))) - val response = HttpClient.newHttpClient().send( - HttpRequest.newBuilder().uri(URI.create("http://localhost:${server.port()}/ping")).build(), - HttpResponse.BodyHandlers.ofString(), - ) + val response = + HttpClient.newHttpClient().send( + HttpRequest.newBuilder().uri(URI.create("http://localhost:${server.port()}/ping")).build(), + HttpResponse.BodyHandlers.ofString(), + ) - response.statusCode() shouldBe 200 - response.body() shouldBe "pong" - } -}) + response.statusCode() shouldBe 200 + response.body() shouldBe "pong" + } + }) diff --git a/src/test/kotlin/io/kotest/provided/ProjectConfig.kt b/src/test/kotlin/io/kotest/provided/ProjectConfig.kt new file mode 100644 index 0000000..9c941ef --- /dev/null +++ b/src/test/kotlin/io/kotest/provided/ProjectConfig.kt @@ -0,0 +1,8 @@ +package io.kotest.provided + +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.extensions.spring.SpringExtension + +object ProjectConfig : AbstractProjectConfig() { + override val extensions = listOf(SpringExtension()) +}