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()
}
}
}
测试生命周期流程图
为了让你更直观地理解整个流程,我画了一张图:
从图里可以看得很清楚:@BeforeAll 只跑一次,然后每个测试方法都会经历 @BeforeEach → @Test → @AfterEach 这个循环。所有测试跑完后,再执行 @AfterAll。
避坑指南:我踩过的几个坑
讲几个我实际遇到过的问题,你写测试时注意避开:
我曾经在测试类里定义了一个 val list = mutableListOf(),然后在多个测试方法里往里面加数据。结果测试顺序不同,结果就不同。解决方案:用 @BeforeEach 重新初始化,或者用不可变对象。
我见过有人写 assertEquals(expected, actual),失败了只显示「expected: 5, actual: 3」,根本不知道是哪个测试用例。建议加个描述:assertEquals("用户年龄应该为25", 25, user.age)。
很多新手只测正常路径,不测异常路径。比如除法函数,除数为0时应该抛异常。用 assertThrows 把这个场景也覆盖到。
小结
JUnit5 是单元测试的基石。掌握断言方法、理解生命周期,你就能写出可靠的测试用例。记住:测试不是为了应付覆盖率指标,而是为了让你在改代码时能安心睡觉。
下一节我们会深入 Kotlin 特有的测试技巧,比如协程测试、Flow 测试等。先把今天的内容消化掉,动手写几个测试试试。
公众号:蓝海资料掘金营,微信deep3321