26、单元测试基础:JUnit5入门、断言方法、测试生命周期

单元测试,说白了就是给代码做「体检」。你写了一个函数,它能不能按预期跑?边界情况处理了没?异常抛得对不对?这些不能靠猜,得靠测试来验证。

我个人习惯,在写业务代码之前,先把测试骨架搭好。这不是什么教条,而是吃过亏之后的经验。有一次我重构一个支付模块,自认为逻辑很清晰,结果上线后才发现汇率换算有个精度问题——要是当时写了单元测试,这种低级错误根本不会流到线上。

好,咱们今天就从 JUnit5 开始,把单元测试的底子打扎实。

JUnit5 是什么?为什么选它?

JUnit5 是 Java/Kotlin 生态里最主流的测试框架。它比 JUnit4 更灵活,支持 Lambda、扩展模型也更强大。在 Compose 项目里,我们通常用它来测试 ViewModel、Repository 这些纯逻辑层。

你想想看,如果你的 ViewModel 里有个状态计算逻辑,不写单元测试,难道每次改完都跑一遍完整 UI 测试吗?那太慢了。单元测试跑得快,毫秒级反馈,这才是日常开发的主力。

JUnit5 由三个子项目组成:

组件 作用
JUnit Platform 启动测试引擎的基础平台
JUnit Jupiter 提供新的编程模型和扩展机制
JUnit Vintage 兼容 JUnit3/4 的旧测试

我们主要跟 JUnit Jupiter 打交道。它提供了 @Test@BeforeEach@AfterEach 这些注解,以及各种断言方法。

快速上手:第一个 JUnit5 测试

在 Kotlin 项目里,添加依赖很简单。build.gradle.kts 里加上:

testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")

然后写一个测试类:

class CalculatorTest {

    private val calculator = Calculator()

    @Test
    fun `加法应该返回正确结果`() {
        val result = calculator.add(2, 3)
        assertEquals(5, result)
    }

    @Test
    fun `减法应该返回正确结果`() {
        val result = calculator.subtract(5, 3)
        assertEquals(2, result)
    }
}

嗯,这里要注意:Kotlin 的测试方法名可以用反引号包起来,写中文描述。我个人觉得这样可读性更好,尤其是给非技术人员看测试报告时。

断言方法:不只是 assertEquals

JUnit5 提供了丰富的断言方法。我刚开始写测试时,只会用 assertEquals,后来发现很多场景有更合适的断言。

常用的断言方法:

方法 说明 示例
assertEquals 判断两个值相等 assertEquals(4, 2 + 2)
assertNotEquals 判断两个值不相等 assertNotEquals(5, 2 + 2)
assertTrue 判断条件为真 assertTrue(list.isEmpty())
assertFalse 判断条件为假 assertFalse(list.isNotEmpty())
assertNull 判断对象为 null assertNull(result)
assertNotNull 判断对象不为 null assertNotNull(result)
assertThrows 验证抛出了指定异常 assertThrows<IllegalArgumentException> { ... }
assertAll 组合多个断言,全部通过才算通过 assertAll({ ... }, { ... })

这里我想特别提一下 assertAll。它有什么用呢?假设你有一个对象,要同时验证它的多个字段:

@Test
fun `用户信息应该完整`() {
    val user = createTestUser()
    assertAll(
        { assertEquals("张三", user.name) },
        { assertEquals(25, user.age) },
        { assertNotNull(user.email) }
    )
}

如果不加 assertAll,第一个断言失败后测试就停了,后面的字段有没有问题你根本不知道。用了 assertAll,它会跑完所有断言,然后一次性告诉你哪些失败了。这在调试时能省不少时间。

测试生命周期:@BeforeEach、@AfterEach 等

测试不是孤立的。很多时候,每个测试方法执行前需要准备数据,执行后需要清理资源。JUnit5 提供了生命周期注解来管理这些。

核心注解:

注解 执行时机 典型用途
@BeforeEach 每个测试方法之前 初始化对象、准备测试数据
@AfterEach 每个测试方法之后 清理资源、关闭连接
@BeforeAll 所有测试方法之前(静态方法) 建立数据库连接、加载配置
@AfterAll 所有测试方法之后(静态方法) 关闭连接池、释放全局资源

看个例子就明白了:

class UserRepositoryTest {

    private lateinit var repository: UserRepository

    @BeforeEach
    fun setUp() {
        repository = UserRepository(InMemoryDatabase())
        repository.addUser(User("test", "test@example.com"))
    }

    @AfterEach
    fun tearDown() {
        repository.clear()
    }

    @Test
    fun `应该能根据邮箱找到用户`() {
        val user = repository.findByEmail("test@example.com")
        assertNotNull(user)
        assertEquals("test", user?.name)
    }

    @Test
    fun `不存在的邮箱应该返回null`() {
        val user = repository.findByEmail("notfound@example.com")
        assertNull(user)
    }
}

这里 @BeforeEach 每次测试前都会执行,保证每个测试用例的数据环境是干净的。我曾经遇到过一个问题:两个测试方法共享同一个列表,第一个测试往列表里加了数据,第二个测试没清理,结果断言就失败了。后来用 @BeforeEach 重新初始化,问题就解决了。

至于 @BeforeAll@AfterAll,它们只执行一次,适合做全局的准备工作。比如连接数据库、启动一个测试服务器等。注意它们必须写在 companion object 里:

class DatabaseTest {

    companion object {
        private lateinit var connection: DatabaseConnection

        @BeforeAll
        @JvmStatic
        fun initDatabase() {
            connection = DatabaseConnection.connect("test_db")
        }

        @AfterAll
        @JvmStatic
        fun closeDatabase() {
            connection.close()
        }
    }
}

测试生命周期流程图

为了让你更直观地理解整个流程,我画了一张图:

JUnit5 测试生命周期 开始 @BeforeAll(执行一次) @BeforeEach(每次执行) @Test 测试方法 @AfterEach(每次执行) 循环执行每个测试 @AfterAll(执行一次) 结束

从图里可以看得很清楚:@BeforeAll 只跑一次,然后每个测试方法都会经历 @BeforeEach → @Test → @AfterEach 这个循环。所有测试跑完后,再执行 @AfterAll

避坑指南:我踩过的几个坑

讲几个我实际遇到过的问题,你写测试时注意避开:

坑1:测试方法之间共享可变状态

我曾经在测试类里定义了一个 val list = mutableListOf(),然后在多个测试方法里往里面加数据。结果测试顺序不同,结果就不同。解决方案:用 @BeforeEach 重新初始化,或者用不可变对象。

坑2:断言信息不够明确

我见过有人写 assertEquals(expected, actual),失败了只显示「expected: 5, actual: 3」,根本不知道是哪个测试用例。建议加个描述:assertEquals("用户年龄应该为25", 25, user.age)

坑3:忽略异常测试

很多新手只测正常路径,不测异常路径。比如除法函数,除数为0时应该抛异常。用 assertThrows 把这个场景也覆盖到。

小结

JUnit5 是单元测试的基石。掌握断言方法、理解生命周期,你就能写出可靠的测试用例。记住:测试不是为了应付覆盖率指标,而是为了让你在改代码时能安心睡觉。

下一节我们会深入 Kotlin 特有的测试技巧,比如协程测试、Flow 测试等。先把今天的内容消化掉,动手写几个测试试试。

小技巧:在 Android Studio 里,你可以右键点击测试类或方法,选择「Run」来执行测试。绿色条表示通过,红色条表示失败。双击失败信息可以直接跳到出错的代码行。

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