upgrade to Spring Boot 4.2.1

This commit is contained in:
Michael Hoennig
2026-06-09 16:05:12 +02:00
parent a030f7abb6
commit 117caf12a9
13 changed files with 103 additions and 102 deletions
+18 -6
View File
@@ -48,7 +48,7 @@ All production code lives under `de.hoennig.gittally`. Commands are in the `comm
## Testing Conventions ## 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 ```kotlin
class MyTest : FunSpec() { class MyTest : FunSpec() {
@@ -63,7 +63,21 @@ Use `shouldBe`, `shouldNotBe`, `shouldThrow` etc. from `io.kotest.matchers`.
### Mocking in Spring Slice Tests ### 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 ```kotlin
@WebMvcTest(SomeController::class) @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: 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) - **Test framework**: Kotest + MockK + WireMock + Testcontainers (ADR 0001)
- **Gradle**: 8.14 target (currently 8.8; upgrade pending Spring Boot 4.0 migration) (ADR 0002) - **Gradle**: 8.14.5 (ADR 0002)
- **Spring Boot**: 4.0 target (currently 3.3.4 — pending upgrade; 3.3.x is EOL) (ADR 0003) - **Spring Boot**: 4.0.6 (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.
+15 -15
View File
@@ -1,11 +1,11 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
id("org.springframework.boot") version "3.3.4" id("org.springframework.boot") version "4.0.6"
id("io.spring.dependency-management") version "1.1.6" id("io.spring.dependency-management") version "1.1.7"
id("org.jlleitschuh.gradle.ktlint") version "12.1.1" id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
kotlin("jvm") version "1.9.25" kotlin("jvm") version "2.2.0"
kotlin("plugin.spring") version "1.9.25" kotlin("plugin.spring") version "2.2.0"
} }
group = "de.hoennig" group = "de.hoennig"
@@ -29,26 +29,26 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
// Kotest // Kotest
testImplementation("io.kotest:kotest-runner-junit5:5.9.1") testImplementation("io.kotest:kotest-runner-junit5:6.1.5")
testImplementation("io.kotest:kotest-assertions-core:5.9.1") testImplementation("io.kotest:kotest-assertions-core:6.1.5")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0") testImplementation("io.kotest:kotest-extensions-spring:6.1.5")
// MockK // MockK
testImplementation("io.mockk:mockk:1.13.11") testImplementation("io.mockk:mockk:1.13.11")
testImplementation("com.ninja-squad:springmockk:4.0.2") testImplementation("com.ninja-squad:springmockk:5.0.1")
// WireMock // 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) // Testcontainers (versions managed by Spring Boot BOM)
testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:testcontainers") testImplementation("org.testcontainers:testcontainers")
} }
tasks.withType<KotlinCompile> { kotlin {
kotlinOptions { compilerOptions {
freeCompilerArgs += "-Xjsr305=strict" freeCompilerArgs.addAll("-Xjsr305=strict")
jvmTarget = "21" jvmTarget = JvmTarget.JVM_21
} }
} }
+7 -7
View File
@@ -2,11 +2,11 @@
**Status:** **Status:**
- proposed: 2026-06-09 - proposed: 2026-06-09
- accepted: - - accepted: 2026-06-09
- rejected: - - rejected: -
- superseded: - - 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 ## Context and Problem Statement
@@ -41,7 +41,7 @@ It has a direct dependency chain:
- Plugin ecosystem readiness varies: the - Plugin ecosystem readiness varies: the
[openapi-generator Gradle plugin](https://github.com/OpenAPITools/openapi-generator/issues/22084) [openapi-generator Gradle plugin](https://github.com/OpenAPITools/openapi-generator/issues/22084)
has a known Gradle 9 issue in its generated build files, and 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. needs verification.
- The [`io.spring.dependency-management` plugin](https://github.com/spring-gradle-plugins/dependency-management-plugin) - 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 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 8.14 (minimum for Spring Boot 4.0)
* Gradle 9.x * 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 #### 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) - [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 has a known Gradle 9 issue in generated `build.gradle` files; impact on the spec-first
code generation task specifically needs verification. 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. needs verification.
- Configuration Cache mandatory means any currently uncached plugin will break the build - Configuration Cache mandatory means any currently uncached plugin will break the build
until updated, complicating initial setup. 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 - 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 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)). 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. one is a poor start.
Upgrade trigger: revisit Gradle 9 once openapi-generator and ktlint-gradle confirm support. Upgrade trigger: revisit Gradle 9 once openapi-generator and ktlint-gradle confirm support.
@@ -2,11 +2,11 @@
**Status:** **Status:**
- proposed: 2026-06-09 - proposed: 2026-06-09
- accepted: - - accepted: 2026-06-09
- rejected: - - rejected: -
- superseded: - - 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 ## 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: The key ecosystem dependencies and their Spring Boot 4.0 compatibility status:
| Dependency | Role | Spring Boot 4.0 status | | Dependency | Version | 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. | | [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) | `@MockkBean` in tests | **Incompatible**confirmed Spring Boot 3.x only. | | [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) | Kotest + `@SpringBootTest` | Compatibility unconfirmed. | | [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/) | HTTP stubbing in tests | **Compatible** — Spring Boot 4.0 explicitly supported since January 2026. | | [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 ## Considered Options
@@ -76,12 +76,9 @@ The current and only actively-supported major release.
#### Disadvantages #### Disadvantages
- **springmockk is incompatible.** The `@MockkBean` / `@SpykBean` annotations are not available. - **springmockk 5.x is required.** Version 4.x is Spring Boot 3.x only. Version 5.0.1 adds
This matters in particular for `@WebMvcTest` (controller error-behaviour) and Spring Boot 4.0 support; `@MockkBean` / `@SpykBean` work as expected.
`@DataJpaTest`-adjacent tests where collaborators need to be mocked in the Spring context. As an alternative, register MockK mocks via `@TestConfiguration` without the springmockk dependency:
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`:
```kotlin ```kotlin
@WebMvcTest(SomeController::class) @WebMvcTest(SomeController::class)
@Import(SomeControllerTest.Mocks::class) @Import(SomeControllerTest.Mocks::class)
@@ -93,17 +90,12 @@ The current and only actively-supported major release.
@Autowired lateinit var someService: SomeService @Autowired lateinit var someService: SomeService
init { init {
beforeEach { clearMocks(someService) } beforeEach { clearMocks(someService) }
// full MockK syntax available
} }
} }
``` ```
This is more verbose than `@MockkBean` but avoids mixing Mockito and MockK. - **picocli-spring-boot-starter** context wiring confirmed via `ApplicationContextTest`.
Revisit once springmockk publishes a Spring Boot 4.0-compatible release. - **kotest-extensions-spring 6.x** (groupId `io.kotest`) is required for Spring Boot 4.0.
- **picocli-spring-boot-starter is untested against Spring Boot 4.0.** The project config must be placed at `io.kotest.provided.ProjectConfig` (Kotest 6 discovery requirement).
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.
- Relatively new GA; fewer community examples for the 4.0 generation. - Relatively new GA; fewer community examples for the 4.0 generation.
## Decision Outcome ## Decision Outcome
@@ -124,9 +116,4 @@ The main reasons:
- kotest-extensions-spring compatibility must be verified before the - kotest-extensions-spring compatibility must be verified before the
first `@SpringBootTest` test is written. first `@SpringBootTest` test is written.
Open items before the code is upgraded: All ecosystem blockers are resolved. The codebase runs Spring Boot 4.0.6.
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.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
@@ -17,7 +17,8 @@ class GitTallyApplication
class CliRunner( class CliRunner(
private val factory: IFactory, private val factory: IFactory,
private val rootCommand: GitTallyCommand, private val rootCommand: GitTallyCommand,
) : CommandLineRunner, ExitCodeGenerator { ) : CommandLineRunner,
ExitCodeGenerator {
private var exitCode = 0 private var exitCode = 0
override fun run(vararg args: String) { override fun run(vararg args: String) {
@@ -15,7 +15,5 @@ import picocli.CommandLine.Command
description = ["Lightweight, declarative CI/CD system"], description = ["Lightweight, declarative CI/CD system"],
) )
class GitTallyCommand : Runnable { class GitTallyCommand : Runnable {
override fun run() { override fun run(): Unit = throw CommandLine.ParameterException(CommandLine(this), "Specify a subcommand")
throw CommandLine.ParameterException(CommandLine(this), "Specify a subcommand")
}
} }
@@ -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)
}
@@ -10,14 +10,15 @@ interface Greeter {
fun greet(name: String): String fun greet(name: String): String
} }
class MockKSmokeTest : FunSpec({ class MockKSmokeTest :
FunSpec({
test("mockk stubs and verifies") { test("mockk stubs and verifies") {
val greeter = mockk<Greeter>() val greeter = mockk<Greeter>()
every { greeter.greet("world") } returns "hello, world" 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") }
} }
}) })
@@ -21,7 +21,6 @@ interface MessageService {
@SpringBootTest @SpringBootTest
@Import(SpringMockKSmokeTest.Mocks::class) @Import(SpringMockKSmokeTest.Mocks::class)
class SpringMockKSmokeTest : FunSpec() { class SpringMockKSmokeTest : FunSpec() {
@TestConfiguration @TestConfiguration
class Mocks { class Mocks {
@Bean @Bean
@@ -47,7 +46,6 @@ class SpringMockKSmokeTest : FunSpec() {
/** Verifies whether @MockkBean works with the current Spring Boot version (springmockk 4.0.2). */ /** Verifies whether @MockkBean works with the current Spring Boot version (springmockk 4.0.2). */
@SpringBootTest @SpringBootTest
class MockkBeanSmokeTest : FunSpec() { class MockkBeanSmokeTest : FunSpec() {
@MockkBean @MockkBean
lateinit var messageService: MessageService lateinit var messageService: MessageService
@@ -5,13 +5,15 @@ import io.kotest.matchers.shouldBe
import org.testcontainers.containers.GenericContainer import org.testcontainers.containers.GenericContainer
import org.testcontainers.utility.DockerImageName import org.testcontainers.utility.DockerImageName
class TestcontainersSmokeTest : FunSpec({ class TestcontainersSmokeTest :
FunSpec({
test("Testcontainers starts a container") { test("Testcontainers starts a container") {
val container = GenericContainer(DockerImageName.parse("alpine:3")) val container =
.withCommand("sh", "-c", "sleep 30") GenericContainer(DockerImageName.parse("alpine:3"))
container.start() .withCommand("sh", "-c", "sleep 30")
container.isRunning shouldBe true container.start()
container.stop() container.isRunning shouldBe true
} container.stop()
}) }
})
@@ -11,22 +11,24 @@ import java.net.http.HttpClient
import java.net.http.HttpRequest import java.net.http.HttpRequest
import java.net.http.HttpResponse import java.net.http.HttpResponse
class WireMockSmokeTest : FunSpec({ class WireMockSmokeTest :
FunSpec({
val server = WireMockServer(options().dynamicPort()) val server = WireMockServer(options().dynamicPort())
beforeSpec { server.start() } beforeSpec { server.start() }
afterSpec { server.stop() } afterSpec { server.stop() }
test("WireMock stubs an HTTP endpoint") { test("WireMock stubs an HTTP endpoint") {
server.stubFor(get("/ping").willReturn(aResponse().withStatus(200).withBody("pong"))) server.stubFor(get("/ping").willReturn(aResponse().withStatus(200).withBody("pong")))
val response = HttpClient.newHttpClient().send( val response =
HttpRequest.newBuilder().uri(URI.create("http://localhost:${server.port()}/ping")).build(), HttpClient.newHttpClient().send(
HttpResponse.BodyHandlers.ofString(), HttpRequest.newBuilder().uri(URI.create("http://localhost:${server.port()}/ping")).build(),
) HttpResponse.BodyHandlers.ofString(),
)
response.statusCode() shouldBe 200 response.statusCode() shouldBe 200
response.body() shouldBe "pong" response.body() shouldBe "pong"
} }
}) })
@@ -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())
}