一、JUnit5 核心概念与 Kotlin 集成

各位同学好,我是你们的老朋友。今天咱们来聊聊 JUnit5 在 Kotlin 项目中的实战。说实话,JUnit5 刚出来那会儿,我还在用 JUnit4 写测试,总觉得「够用就行」。直到有一次接手一个遗留项目,测试代码里全是 JUnit4 的 Vintage 模式,跑起来各种兼容性问题……嗯,从那以后我就下定决心,新项目一律上 JUnit5。

JUnit5 不是 JUnit4 的简单升级,它是一次架构上的重构。说白了,它把整个测试框架拆成了三个独立的部分,各司其职。我画了张图,帮你快速理解它的分层结构。

JUnit5 架构分层 JUnit Platform 启动测试引擎 · 发现测试 · 运行测试 · 报告结果 JUnit Jupiter 新编程模型 · @Test · 断言 · 参数化 JUnit Vintage 向后兼容 · 运行 JUnit3/JUnit4 测试 测试运行器 / IDE / 构建工具 Gradle · Maven · IntelliJ IDEA · Android Studio

1.1 JUnit5 的三层架构

JUnit Platform 是地基。它负责发现测试、启动测试引擎、收集结果。说白了,它就是个调度中心,不关心你用什么编程模型。

JUnit Jupiter 是主角。我们平时写的 @Test@BeforeEach、断言这些,全都在这一层。我个人习惯把 Jupiter 理解为「JUnit5 的新编程模型」。

JUnit Vintage 是兼容层。如果你有老项目还在用 JUnit4 甚至 JUnit3,Vintage 可以让你平滑过渡。我记得有一次在 Android 项目里混用 JUnit4 和 JUnit5,Vintage 帮我省了不少事。

💡 核心要点: 在 Kotlin 项目中,我们主要跟 Jupiter 打交道。Platform 和 Vintage 由 Gradle 插件自动管理,你基本不用操心。

1.2 常用注解:从 @Test 到生命周期

先看一个最基础的 Kotlin 测试类长什么样:

class CalculatorTest {

    private lateinit var calculator: Calculator

    @BeforeEach
    fun setUp() {
        calculator = Calculator()
        println("每个测试方法前执行")
    }

    @AfterEach
    fun tearDown() {
        println("每个测试方法后执行")
    }

    @Test
    fun `加法测试`() {
        val result = calculator.add(2, 3)
        assertEquals(5, result)
    }

    @Test
    fun `减法测试`() {
        val result = calculator.subtract(5, 3)
        assertEquals(2, result)
    }
}

这里有几个细节我想强调一下:

  • @BeforeEach / @AfterEach:每个测试方法前后都会执行。适合做初始化、清理资源。
  • @BeforeAll / @AfterAll:整个测试类只执行一次。注意方法必须是静态的,在 Kotlin 里要写在 companion object 中。
  • 测试方法命名:Kotlin 支持反引号,我强烈建议你用中文或自然语言命名。比如 `用户登录成功时返回token`,可读性极佳。
⚠️ 避坑指南: 我曾经在 @BeforeAll 里忘记加 companion object,结果方法一直没执行,排查了半天。Kotlin 的静态方法必须放在 companion object 里,这点跟 Java 不一样。

1.3 断言机制:不只是 assertEquals

断言是测试的灵魂。JUnit5 提供了丰富的断言方法,我挑几个最常用的说说:

断言方法 作用 Kotlin 示例
assertEquals 判断两个值相等 assertEquals(4, 2 + 2)
assertTrue 判断条件为真 assertTrue(list.isEmpty())
assertFalse 判断条件为假 assertFalse(user.isBlocked)
assertNull 判断对象为 null assertNull(result)
assertThrows 验证抛出了指定异常 assertThrows<IllegalArgumentException> { ... }

来看一个 assertThrows 的实战场景:

@Test
fun `除数为零时抛出异常`() {
    val exception = assertThrows<ArithmeticException> {
        calculator.divide(10, 0)
    }
    assertEquals("/ by zero", exception.message)
}

为什么会用 assertThrows?因为有时候你不仅要验证异常被抛出,还要验证异常的类型和消息。我曾在项目中遇到一个坑:方法抛出了 RuntimeException 而不是自定义异常,测试没覆盖到,上线后直接崩溃。

1.4 Kotlin 与 JUnit5 的互操作

Kotlin 和 JUnit5 配合起来非常丝滑,但有几个小细节要注意:

  • 空安全:Kotlin 的 String?String 是不同类型。断言时注意类型匹配,否则编译不过。
  • Lambda 表达式:JUnit5 的 assertThrows 接受一个 Executable 接口,Kotlin 可以直接传 lambda,非常简洁。
  • 测试方法可见性:JUnit5 允许 internalprivate 的测试方法,但我建议统一用 internal,保持一致性。
💡 小技巧: 在 Kotlin 中,你可以用 shouldBeshouldThrow 等 Kotlin 测试库的扩展函数,但 JUnit5 原生的断言已经够用了。我个人习惯在团队中统一使用 JUnit5 原生断言,避免引入过多依赖。

1.5 参数化测试:告别重复代码

你想想看,如果我要测试一个加法方法,是不是得写好几个测试用例?比如 2+3、-1+5、0+0……每个都写一个 @Test 方法,太累了。

参数化测试就是来解决这个问题的:

class ParameterizedTestExample {

    @ParameterizedTest
    @ValueSource(ints = [1, 2, 3, 4, 5])
    fun `测试数字是否为正数`(number: Int) {
        assertTrue(number > 0)
    }

    @ParameterizedTest
    @CsvSource(
        "1, 2, 3",
        "10, 20, 30",
        "0, 0, 0"
    )
    fun `测试加法`(a: Int, b: Int, expected: Int) {
        assertEquals(expected, a + b)
    }
}

这里我用了两种数据源:

  • @ValueSource:适合单个参数的简单场景。
  • @CsvSource:适合多个参数,用逗号分隔。

还有 @MethodSource,可以从一个静态方法获取数据。我曾在测试用户登录逻辑时,用 @MethodSource 提供了几十组测试数据,覆盖了各种边界情况。

💡 核心要点: 参数化测试能大幅减少重复代码,提高测试覆盖率。但要注意,数据源方法必须是静态的,在 Kotlin 中要放在 companion object 里。

1.6 实战:一个完整的 Kotlin 测试类

最后,我把今天讲的内容整合成一个完整的示例:

import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource

class UserServiceTest {

    private lateinit var userService: UserService

    @BeforeEach
    fun setUp() {
        userService = UserService()
    }

    @Test
    fun `用户名为空时抛出异常`() {
        val exception = assertThrows<IllegalArgumentException> {
            userService.register("", "password123")
        }
        assertEquals("用户名不能为空", exception.message)
    }

    @ParameterizedTest
    @CsvSource(
        "user1, pass123, true",
        "admin, admin123, true",
        "test, 123, false"
    )
    fun `密码长度校验`(username: String, password: String, expected: Boolean) {
        val result = userService.isPasswordValid(username, password)
        assertEquals(expected, result)
    }

    @AfterEach
    fun tearDown() {
        // 清理资源,比如关闭数据库连接
        println("测试完成,清理资源")
    }
}

这个例子涵盖了 @BeforeEach@Test@ParameterizedTestassertThrowsassertEquals 等核心内容。你可以在自己的项目中直接套用这个模板。

好了,今天的内容就到这里。JUnit5 的核心概念其实不复杂,关键是多写、多练。下一章我们会深入 Kotlin 的测试进阶技巧,包括 Mock 和协程测试,敬请期待。

公众号:蓝海资料掘金营,微信deep3321