Ohhnews

分类导航

$ cd ..
DZone Java原文

用Kotlin测试Java与Spring应用:低风险的渐进引入路径

#kotlin#java#spring boot#单元测试#渐进式迁移

考虑采用或已承诺采用 Kotlin 的团队,常常把迁移规划为一系列重写:一次将一个微服务从 Java 替换为 Kotlin 对应实现。实践中,这种计划往往在产出任何已交付代码之前就停滞了。Kotlin 在 JVM 生态中的角色正在扩大,因为它并不要求这种程度的承诺。Netflix、Adobe 和 DoorDash 等公司都是增量式采用 Kotlin,与现有 JVM 系统并存,而不是进行全量重写。风险最低的起点是测试。测试能带来 Kotlin 的语言优势,包括空安全、数据类和富有表现力的 lambda,同时无需触碰任何生产环境运行的东西。

本指南将介绍如何仅针对测试源,将 Kotlin 添加到现有的 Java + Spring Boot 项目中。你将配置 Gradle、用 Kotlin 编写 JUnit 5 测试、使用 MockK 进行 Kotlin 原生模拟,并应用 Kotest 断言来提升测试可读性。你的生产服务不会改变,团队将在一个有边界、无风险的上下文中开始体验 Kotlin。

为什么测试是引入 Kotlin 最安全的地方

Kotlin 测试源与生产应用制品相互隔离,并且不需要在生产运行时中引入 Kotlin。它们仍可能影响 CI 和测试基础设施,但不会触及生产服务。这种与生产环境的分离,使得测试成为团队尚未承诺采用的语言的合适切入点。

在测试源中引入 Kotlin 意味着你现有的 Java 服务完全保持不变:无需重构、没有迁移风险,也不会在生产路径上引入回归。它还允许团队在本来就要编写的测试中试验真实的 Kotlin 语法,而不是在人为构造的示例上试验。当工程师编写的是他们真正愿意写的测试时,开发者的接受度会更快建立。

Kotlin 测试通常更短、更易读,在代码审查中也更容易快速浏览。领导和平台团队有充分理由对语言变更加以怀疑。“我们想用 Kotlin 写测试”这一提案在评估时几乎不带来风险,因为它不会触及生产环境或部署基础设施。这是一个干净、有边界的实验,而且该模式已被行业内的工程组织验证。

[LOADING...]

将 Kotlin 添加到现有的 Java + Spring Boot 项目

你不必为了在测试中使用 Kotlin 而迁移整个构建。如果你运行的是标准的 Spring Boot + Gradle 项目,只需添加 Kotlin JVM 插件,在测试作用域引入 kotlin("stdlib") 和 io.mockk:mockk,IntelliJ IDEA 就会自动将 src/test/kotlin 识别为源集。

在 IntelliJ IDEA 中打开项目并更新 build.gradle.kts,在 plugins 块中添加 Kotlin JVM 插件,并添加测试作用域依赖:

$ kotlin
plugins {
    java
    id("org.springframework.boot") version "4.1.0" // example version, check for latest
    id("io.spring.dependency-management") version "1.1.5" // example version, check for latest

    // Add the Kotlin JVM plugin alongside your existing Java plugin
    kotlin("jvm") version "2.4.10" // example version, check kotlinlang.org for latest stable
}

dependencies {
    // Your existing production dependencies remain untouched
    implementation("org.springframework.boot:spring-boot-starter-web")

    // Existing test dependency
    testImplementation("org.springframework.boot:spring-boot-starter-test")

    // Required for coroutines
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")

    // Kotlin stdlib (test scope only)
    testImplementation(kotlin("stdlib"))

    // MockK: Kotlin-native mocking library (check GitHub for latest stable)
    testImplementation("io.mockk:mockk:1.14.11")

    // SpringMockK: @MockkBean and @SpykBean for Spring context tests (check GitHub for latest stable)
    testImplementation("com.ninja-squad:springmockk:5.0.1")

    // Kotest assertions, no need for the full test runner (check kotest.io for latest stable)
    testImplementation("io.kotest:kotest-assertions-core:6.2.3")
}

tasks.withType<Test> {
    useJUnitPlatform()
}

始终要在新项目中固定版本之前,查看 Kotlin 和相关库文档以获取最新稳定版本。如果你使用 Maven,则需要添加 kotlin-maven-plugin:

$ xml
<properties>
  <kotlin.version>2.4.10</kotlin.version>
</properties>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-stdlib</artifactId>
    <version>${kotlin.version}</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>io.mockk</groupId>
    <artifactId>mockk</artifactId>
    <version>1.14.11</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>io.kotest</groupId>
    <artifactId>kotest-assertions-core</artifactId>
    <version>6.2.3</version>
    <scope>test</scope>
  </dependency>
</dependencies>

<build>
  <plugins>
    <plugin>
      <groupId>org.jetbrains.kotlin</groupId>
      <artifactId>kotlin-maven-plugin</artifactId>
      <version>${kotlin.version}</version>
      <executions>
        <execution>
          <id>test-compile</id>
          <phase>test-compile</phase>
          <goals>
            <goal>test-compile</goal>
          </goals>
          <configuration>
            <sourceDirs>
              <sourceDir>src/test/kotlin</sourceDir>
              <sourceDir>src/test/java</sourceDir>
            </sourceDirs>
          </configuration>
        </execution>
      </executions>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
    </plugin>
  </plugins>
</build>

保存后,IntelliJ IDEA 会显示通知,提示重新加载 Gradle 项目。在 macOS 上使用 Shift+Cmd+I,在 Windows 和 Linux 上使用 Ctrl+Shift+O 触发同步。同步完成后,IntelliJ IDEA 会自动将 src/test/kotlin 识别为源集,让你可以在现有 Java 测试基础设施(如基类、testcontainer 设置、测试数据工厂等)旁使用 Kotlin。

在 Kotlin 中编写 JUnit 6 测试

用 Kotlin 编写 JUnit 6 测试可以消除大量测试设置中的摩擦。该语言的表现力让你能够专注于场景和预期行为。

考虑一个管理订单的 Spring 服务。测试代码往往花更多时间构造测试数据,而不是测试行为。在 Java 中,创建同一对象的多个变体可能需要反复调用 setter:

$ java
Order order = new Order();
order.setId(1L);
order.setCustomerId(42L);
order.setTotal(new BigDecimal("99.99"));
order.setStatus(OrderStatus.PENDING);

Order cancelledOrder = new Order();
cancelledOrder.setId(1L);
cancelledOrder.setCustomerId(42L);
cancelledOrder.setTotal(new BigDecimal("99.99"));
cancelledOrder.setStatus(OrderStatus.CANCELLED);

随着测试套件引入更多场景,设置会变得更加重复。例如,针对大额订单的测试仅为了更改总额,就不得不重复同样的样板代码。Kotlin 的命名参数和默认参数已经可以减少这种重复,但你还可以更进一步,创建一个小型测试数据 DSL,根据测试关心的场景来描述订单:

$ kotlin
fun orderScenario(block: OrderScenario.() -> Unit): Order = OrderScenario().apply(block).build()

class OrderScenario {
    var id: Long = 1L
    var customerId: Long = 42L
    var total: BigDecimal = BigDecimal("99.99")
    var status: OrderStatus = OrderStatus.PENDING

    fun pending() {
        status = OrderStatus.PENDING
    }

    fun confirmed() {
        status = OrderStatus.CONFIRMED
    }

    fun cancelled() {
        status = OrderStatus.CANCELLED
    }

    fun forCustomer(id: Long) {
        customerId = id
    }

    fun withTotal(amount: String) {
        total = BigDecimal(amount)
    }

    fun build() = Order(id, customerId, total, status)
}

现在,测试可以将所需数据描述为场景:

$ kotlin
val pendingOrder = orderScenario {
    forCustomer(42L)
    withTotal("99.99")
    pending()
}

val cancelledOrder = orderScenario {
    forCustomer(42L)
    cancelled()
}

val largeOrder = orderScenario {
    withTotal("999.99")
    pending()
}

该 DSL 使用 Kotlin 的带接收者 lambda,使 OrderScenario 的属性和函数可以直接在 orderScenario { ... } 块中使用。默认值提供合理的测试数据,而各个测试只覆盖与其场景相关的属性。最重要的是,生产环境中的 Order 类可以完全保持 Java 不变。

随着测试套件增长,这种方法尤其有用。测试不再需要累积 cancelledOrder()、largePendingOrder() 或 confirmedOrderForCustomer() 这类专用工厂方法,DSL 为测试提供了一套小型词汇,让测试自行组合这些场景。测试描述的是它需要什么状态,而不重复构造 Java 对象的机制。

一个完整的 JUnit 6 测试随后可以结合场景 DSL、MockK 和 Kotest 断言:

$ kotlin
import io.kotest.matchers.shouldBe
import io.kotest.assertions.throwables.shouldThrow
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test

internal class OrderServiceTest {
    private val repository = mockk<OrderRepository>()
    private val orderService = OrderService(repository)

    @Test
    fun `returns order when found by id`() {
        val order = orderScenario {
            forCustomer(42L)
            withTotal("99.99")
            pending()
        }

        every { repository.findById(order.id) } returns order

        val result = orderService.getOrder(order.id)

        result shouldBe order
        verify(exactly = 1) { repository.findById(order.id) }
    }

    @Test
    fun `throws when order is not found`() {
        every { repository.findById(any()) } returns null

        shouldThrow<NoSuchElementException> {
            orderService.getOrder(99L)
        }
    }
}

用反引号分隔的测试名称(returns order when found by id)让你可以直接在函数声明中编写描述性测试名,因此该名称会自然地出现在测试运行器输出中。当你想要可读的测试名称时,这也能让你不必使用 JUnit 的 @Named 注解。当 Java 仓库方法返回可空的 Order? 时,Kotlin 编译器会强制测试代码在使用结果之前处理空值情况。这样可以在编译时暴露测试与 Java API 契约之间的不匹配,而不是在测试执行期间以意外的 NullPointerException 形式出现。完整指南见文档。

使用 MockK 进行符合 Kotlin 习惯的模拟

在向 Java 应用添加 Kotlin 测试时,Mockito 仍然是可行选择,但 MockK 提供了围绕 Kotlin 语法设计的 API。其简洁的 every { ... } returns ... 和 verify { ... } 结构让模拟交互更易读,同时允许 Kotlin 测试在不修改生产代码的情况下模拟现有 Java 类。在测试使用 Reactor 的 Java Spring 应用时,这尤其有用。生产应用可以完全保持 Java,并继续返回 Mono 或 Flux。Kotlin 仅引入到测试套件中。MockK 负责处理被模拟的依赖,而 Reactor 的 StepVerifier 负责验证生成的发布者。

考虑一个以响应式方式返回订单的 Java 服务:

$ java
public Mono<Order> findOrder(Long id) {
    return repository.findById(id);
}

可以在不修改其实现的情况下从 Kotlin 测试该服务:

$ kotlin
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import reactor.core.publisher.Mono
import reactor.test.StepVerifier

internal class OrderServiceTest {
    private val repository = mockk<OrderRepository>()
    private val orderService = OrderService(repository)

    @Test
    fun `returns order when found`() {
        val order = Order(
            id = 1L,
            customerId = 42L,
            total = BigDecimal("99.99"),
            status = OrderStatus.CONFIRMED
        )

        every { repository.findById(1L) } returns Mono.just(order)

        val result = orderService.findOrder(1L)

        StepVerifier.create(result)
            .expectNext(order)
            .verifyComplete()

        verify(exactly = 1) { repository.findById(1L) }
    }
}

生产服务和仓库可以保持 Java。Kotlin 测试使用 MockK 对 Kotlin 友好的语法来定义模拟行为,而 StepVerifier 检查返回的 Mono 是否发出预期值并成功完成。同样的模式也适用于返回 Flux 的 API。例如,测试可以验证多个发出的值,而无需手动订阅发布者:

$ kotlin
every { repository.findAll() } returns Flux.just(order1, order2)

StepVerifier.create(orderService.findAll())
    .expectNext(order1, order2)
    .verifyComplete()

MockK 并不会替代 Reactor 的测试工具。每个库负责测试中的不同部分。MockK 提供简洁的模拟,而 StepVerifier 提供针对 Reactor 发布者的断言。Kotlin 将这些 API 汇聚到一个紧凑的测试中,聚焦于被验证的行为。在实践中,这意味着你可以只在测试源集中采用 Kotlin,使用 MockK 模拟现有 Java 依赖,并测试响应式 Java 代码,而无需将 Kotlin 或协程引入生产应用。

用于更清晰测试意图的 Kotest 断言

你不需要 Kotest 的完整测试运行器,也能从其断言库中受益。kotest-assertions-core 依赖添加了中缀匹配器(shouldBe、shouldNotBe、shouldThrow、shouldContain),它们叠加在标准 JUnit 5 测试之上。比较 JUnit 的断言风格与 Kotest:

$ kotlin
// JUnit 5 assertions
Assertions.assertEquals(OrderStatus.CONFIRMED, result.status)
Assertions.assertNotNull(result.id)

// Kotest matchers (same assertions, different shape)
result.status shouldBe OrderStatus.CONFIRMED
result.id shouldNotBe null

Kotest 版本的阅读顺序与描述预期的句子一致。这种顺序让意图一目了然,而无需解析 assertEquals 的参数顺序。shouldThrow 对测试 Spring 服务的异常路径尤其有用:

$ kotlin
val exception = shouldThrow<NoSuchElementException> {
    orderService.getOrder(99L)
}
exception.message shouldBe "Order 99 not found"

该匹配器会返回抛出的异常,因此你可以在其属性上继续链式断言,而无需额外的 try/catch 块。

使用 @MockkBean 的 Spring Boot 集成测试

对于 Spring 切片测试(@WebMvcTest、@DataJpaTest 或部分 @SpringBootTest),你可以用 springmockk 库中的 @MockkBean 替换 Spring 的 @MockBean。它与 @MockBean 一样集成到 Spring 的测试上下文中,但会创建 MockK 实例:

$ kotlin
package com.example.demo.controller

import com.example.demo.model.Order
import com.example.demo.model.OrderStatus
import com.example.demo.service.OrderService
import io.mockk.every
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import com.ninjasquad.springmockk.MockkBean
import java.math.BigDecimal
import java.util.NoSuchElementException

@WebMvcTest(OrderController::class)
internal class OrderControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockkBean
    private lateinit var orderService: OrderService

    @Test
    fun `GET order returns 200 with order data`() {
        val order = Order(
            1L,
            42L,
            BigDecimal("99.99"),
            OrderStatus.CONFIRMED
        )

        every { orderService.getOrder(1L) } returns order

        mockMvc.get("/orders/1")
            .andExpect {
                status { isOk() }
                jsonPath("$.id") { value(1) }
                jsonPath("$.customerId") { value(42) }
                jsonPath("$.total") { value(99.99) }
                jsonPath("$.status") { value("CONFIRMED") }
            }
    }

    @Test
    fun `GET missing order returns 500`() {
        every { orderService.getOrder(99L) } throws NoSuchElementException("Order 99 not found")

        mockMvc.get("/orders/99")
            .andExpect {
                status { is5xxServerError() }
            }
    }
}

@MockkBean 注解在测试期间替换 Spring 上下文中的服务。IntelliJ IDEA 可以解析 MockMvc 的 Kotlin DSL(mockMvc.get(...))并提供完整的自动补全支持,从而消除查找链式方法名带来的摩擦。

[LOADING...]

在测试中开始使用 Kotlin

在任何生产代码变更之前,通过测试源引入 Kotlin,是一种经过验证的增量策略。工具链已经成熟,Gradle 配置只需几行,第一个 Kotlin 测试可以在你配置依赖的当天就交付。

随着时间推移,编写 Kotlin 测试可能会改变团队对这门语言的看法。花几个月编写 Kotlin 测试的开发者会开始问:为什么不能在生产环境中使用数据类?他们会怀念 Java 服务代码中的空安全。渐进式采用会自然发生,由真实的偏好驱动,而不是自上而下的强制要求。

JetBrains 与 Spring 团队建立了官方战略合作,专注于改善后端工程师的开发者体验。如果你想探索 Kotlin 在测试套件之外还能为团队做什么,kotlinlang.org 是合适的起点。