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()

小技巧: 如果你同时有JUnit4的依赖,可能会冲突。我建议统一用JUnit5,别混着用。曾经有个项目因为混用了@Test注解,导致测试跑不起来,排查了半天。

断言库:AssertJ vs Hamcrest,我选哪个?

JUnit自带的assertEqualsassertTrue这些,说实话可读性一般。你想想看,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的allOfanyOf组合时一脸懵。换成AssertJ后,上手快多了。

当然,Hamcrest也有它的优势——匹配器可以复用。如果你需要自定义复杂的匹配逻辑,Hamcrest的TypeSafeMatcher会更灵活。但日常80%的场景,AssertJ完全够用。

我的建议: 新项目直接用AssertJ。老项目如果已经用了Hamcrest,也没必要强行迁移。工具是为人服务的,别为了换而换。

参数化测试:告别重复的测试方法

你有没有写过这样的代码?

@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()这种命名,跑挂了根本不知道测的是啥。

我推荐两种命名风格:

  1. Given_When_Then 风格:适合行为驱动开发(BDD)
  2. 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陷阱,保证让你少走弯路。