Kotlin测试基础:JUnit5配置、断言库与参数化测试
好,咱们正式开始聊Kotlin测试。说实话,很多Android开发者对单元测试的第一印象就是「麻烦」——配置半天跑不起来,断言写起来又啰嗦。但我想说,那是你没用对工具。今天这一章,我带你把这些基础打扎实。
JUnit5配置:从Gradle到第一个测试
JUnit5是目前Java/Kotlin生态里最主流的测试框架。相比JUnit4,它最大的变化是引入了扩展模型和动态测试。我个人习惯在项目里直接上JUnit5,因为它的参数化测试和生命周期管理实在太香了。
先看build.gradle.kts怎么配:
dependencies {
// JUnit5 核心
testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.0")
// 参数化测试需要这个
testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.0")
}
嗯,这里要注意:junit-jupiter-api是写测试用的,junit-jupiter-engine是运行时用的。如果你用Gradle 7.0以上,记得在tasks.test里加上useJUnitPlatform()。
断言库:AssertJ vs Hamcrest,我选哪个?
JUnit自带的assertEquals、assertTrue这些,说实话可读性一般。你想想看,assertEquals(42, result)和assertThat(result).isEqualTo(42),哪个更接近自然语言?
我个人更偏爱AssertJ。原因很简单:它支持链式调用,而且错误信息特别友好。比如:
// 用AssertJ
assertThat(user.name)
.isNotEmpty()
.startsWith("张")
.hasSize(3)
// 如果用Hamcrest,得写成
assertThat(user.name, allOf(
not(emptyString()),
startsWith("张"),
hasLength(3)
))
你看,AssertJ的链式调用是不是更直观?我在项目中遇到过好几次,团队里新来的同事看Hamcrest的allOf、anyOf组合时一脸懵。换成AssertJ后,上手快多了。
当然,Hamcrest也有它的优势——匹配器可以复用。如果你需要自定义复杂的匹配逻辑,Hamcrest的TypeSafeMatcher会更灵活。但日常80%的场景,AssertJ完全够用。
参数化测试:告别重复的测试方法
你有没有写过这样的代码?
@Test
fun testAdd_1_plus_2_equals_3() {
assertEquals(3, calculator.add(1, 2))
}
@Test
fun testAdd_2_plus_3_equals_5() {
assertEquals(5, calculator.add(2, 3))
}
// ... 再来10个?
说实话,这种代码我看着就头疼。参数化测试就是来解决这个问题的。JUnit5提供了@ParameterizedTest注解,配合各种数据源:
@ParameterizedTest
@CsvSource(
"1, 2, 3",
"2, 3, 5",
"10, -5, 5",
"0, 0, 0"
)
fun testAdd(a: Int, b: Int, expected: Int) {
assertEquals(expected, calculator.add(a, b))
}
你看,一个方法就搞定了。而且@CsvSource支持空值和注释,非常灵活。如果你有大量数据,还可以用@MethodSource从外部方法读取:
companion object {
@JvmStatic
fun addData() = listOf(
Arguments.of(1, 2, 3),
Arguments.of(2, 3, 5),
Arguments.of(10, -5, 5)
)
}
@ParameterizedTest
@MethodSource("addData")
fun testAddWithMethodSource(a: Int, b: Int, expected: Int) {
assertEquals(expected, calculator.add(a, b))
}
@MethodSource时,数据源方法必须是static的。在Kotlin里,需要放在companion object里,并且加上@JvmStatic注解。我曾经忘了加这个注解,结果测试一直报「找不到方法」,排查了半小时。
测试生命周期:@BeforeEach、@AfterEach 的正确用法
测试生命周期管理,说白了就是「跑测试前准备什么,跑完后清理什么」。JUnit5提供了几个关键注解:
| 注解 | 执行时机 | 典型用途 |
|---|---|---|
| @BeforeEach | 每个测试方法前 | 初始化对象、Mock数据 |
| @AfterEach | 每个测试方法后 | 清理资源、重置状态 |
| @BeforeAll | 所有测试前(只一次) | 建立数据库连接、加载配置 |
| @AfterAll | 所有测试后(只一次) | 关闭连接、释放资源 |
我个人的习惯是:能用@BeforeEach就别用@BeforeAll。为什么?因为测试之间应该相互独立。如果所有测试共享一个对象,一个测试改了状态,另一个测试就可能受影响。你想想看,这种「测试依赖」是最难排查的bug之一。
举个例子:
class CalculatorTest {
private lateinit var calculator: Calculator
@BeforeEach
fun setUp() {
calculator = Calculator() // 每个测试前都新建一个
}
@Test
fun testAdd() {
assertEquals(3, calculator.add(1, 2))
}
@Test
fun testSubtract() {
assertEquals(1, calculator.subtract(3, 2))
}
}
这样写,每个测试拿到的都是全新的Calculator实例,互不干扰。如果你在@BeforeAll里只创建一次,万一Calculator内部有缓存或状态,测试结果就可能飘忽不定。
@BeforeAll里初始化了一个数据库连接池,结果测试跑完后忘了在@AfterAll里关闭,导致CI服务器上连接数爆满。从那以后,我每次写@BeforeAll都会下意识检查有没有对应的@AfterAll。
测试命名规范:让测试方法自己说话
测试命名这件事,看似简单,其实很影响团队协作。我见过太多test1()、test2()这种命名,跑挂了根本不知道测的是啥。
我推荐两种命名风格:
- Given_When_Then 风格:适合行为驱动开发(BDD)
- Should_When 风格:更简洁,适合纯单元测试
举个例子:
// Given_When_Then 风格
@Test
fun `given user is admin, when check permission, then return true`() {
// ...
}
// Should_When 风格
@Test
fun `should return true when user is admin`() {
// ...
}
我个人更倾向Should_When风格。原因很简单:它更短,而且读起来像一句完整的话。你想想看,在测试报告里看到should return true when user is admin,是不是一眼就知道这个测试在测什么?
另外,Kotlin支持反引号包裹方法名,所以你可以用空格和标点符号。这个特性在测试里特别好用——方法名就是一句自然语言。
- 一看就知道测什么功能
- 一看就知道输入是什么
- 一看就知道期望结果是什么
小结
这一章我们聊了JUnit5的配置、断言库的选择、参数化测试、生命周期管理,还有测试命名规范。说实话,这些基础看似简单,但很多项目翻车就翻在这些细节上。
下一章,我们会深入Kotlin的Mock库——MockK。到时候我会分享一些我在实际项目中遇到的Mock陷阱,保证让你少走弯路。