一、JUnit5 核心概念与 Kotlin 集成
各位同学好,我是你们的老朋友。今天咱们来聊聊 JUnit5 在 Kotlin 项目中的实战。说实话,JUnit5 刚出来那会儿,我还在用 JUnit4 写测试,总觉得「够用就行」。直到有一次接手一个遗留项目,测试代码里全是 JUnit4 的 Vintage 模式,跑起来各种兼容性问题……嗯,从那以后我就下定决心,新项目一律上 JUnit5。
JUnit5 不是 JUnit4 的简单升级,它是一次架构上的重构。说白了,它把整个测试框架拆成了三个独立的部分,各司其职。我画了张图,帮你快速理解它的分层结构。
1.1 JUnit5 的三层架构
JUnit Platform 是地基。它负责发现测试、启动测试引擎、收集结果。说白了,它就是个调度中心,不关心你用什么编程模型。
JUnit Jupiter 是主角。我们平时写的 @Test、@BeforeEach、断言这些,全都在这一层。我个人习惯把 Jupiter 理解为「JUnit5 的新编程模型」。
JUnit Vintage 是兼容层。如果你有老项目还在用 JUnit4 甚至 JUnit3,Vintage 可以让你平滑过渡。我记得有一次在 Android 项目里混用 JUnit4 和 JUnit5,Vintage 帮我省了不少事。
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`,可读性极佳。
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 允许
internal或private的测试方法,但我建议统一用internal,保持一致性。
shouldBe、shouldThrow 等 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 提供了几十组测试数据,覆盖了各种边界情况。
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、@ParameterizedTest、assertThrows、assertEquals 等核心内容。你可以在自己的项目中直接套用这个模板。
好了,今天的内容就到这里。JUnit5 的核心概念其实不复杂,关键是多写、多练。下一章我们会深入 Kotlin 的测试进阶技巧,包括 Mock 和协程测试,敬请期待。