单元测试:JUnit5、MockK、断言库、协程测试(runTest)
单元测试这件事,说实话,我入行前三年一直觉得它「性价比不高」。那时候赶项目进度,总觉得写测试是浪费时间。直到有一次,我改了一个工具类里的静态方法,结果导致三个模块同时报错,排查了整整两天……嗯,从那以后,我再也不敢不写单元测试了。
在 Kotlin 项目里,单元测试的生态已经非常成熟。今天我就带你过一遍最核心的四个部分:JUnit5 框架、MockK 模拟库、断言库的选择,以及协程测试的坑与解法。
1. JUnit5:测试框架的基石
JUnit5 是目前 Java/Kotlin 项目的标配。它比 JUnit4 多了不少好东西,比如参数化测试、嵌套测试、扩展模型。我个人习惯直接用 JUnit5 的 @Test 注解,配合 @DisplayName 给测试起个中文名,这样报告看起来一目了然。
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.DisplayName
class CalculatorTest {
@Test
@DisplayName("加法测试:1 + 1 应该等于 2")
fun testAdd() {
val result = Calculator().add(1, 1)
assert(result == 2)
}
}
这里有个小细节:JUnit5 的 @Test 注解不需要 public 修饰符,Kotlin 默认就是 public。你想想看,少写一个关键字,代码是不是清爽多了?
2. MockK:Kotlin 专属的 Mock 库
说到 Mock,Java 那边有 Mockito,但到了 Kotlin 这边,我强烈推荐 MockK。为什么?因为 Mockito 对 Kotlin 的 final class、object、扩展函数 支持得不好。MockK 是纯 Kotlin 写的,专门处理这些场景。
我在项目中遇到过最典型的场景:要 Mock 一个 object 单例。用 Mockito 你得加一堆配置,用 MockK 一行搞定。
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.verify
import org.junit.jupiter.api.Test
object UserRepository {
fun getUserName(id: Int): String = "真实用户"
}
class UserServiceTest {
@Test
fun `测试 Mock object`() {
mockkObject(UserRepository)
every { UserRepository.getUserName(1) } returns "Mock用户"
val name = UserRepository.getUserName(1)
assert(name == "Mock用户")
verify { UserRepository.getUserName(1) }
}
}
注意看,mockkObject 直接接管了单例对象,every 定义行为,verify 验证调用次数。这套 API 设计得很 Kotlin,链式调用读起来像自然语言。
coEvery 和 coVerify,专门用于挂起函数。后面协程测试会用到。
3. 断言库:选 Kotlin Test 还是 Hamcrest?
断言库的选择,说白了就是「你习惯哪种写法」。Kotlin 官方提供了 kotlin.test 包,里面有 assertEquals、assertTrue 这些基础断言。如果你觉得不够表达力,可以上 Hamcrest 或者 Kotlin 的 should 风格。
我个人偏好用 kotlin.test 配合 should 扩展函数,比如:
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class AssertionTest {
@Test
fun `字符串断言示例`() {
val name = "Kotlin"
assertEquals("Kotlin", name)
assertTrue(name.startsWith("K"))
}
}
如果你想要更丰富的匹配器,比如「集合包含某个元素」「字符串匹配正则」,可以用 Hamcrest:
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.*
import org.junit.jupiter.api.Test
class HamcrestTest {
@Test
fun `Hamcrest 断言示例`() {
val list = listOf("a", "b", "c")
assertThat(list, hasItem("b"))
assertThat("Hello Kotlin", containsString("Kotlin"))
}
}
嗯,这里要注意:Hamcrest 的 assertThat 第一个参数是实际值,第二个参数是匹配器。写反了会报错,我曾经在这上面浪费过十分钟……
4. 协程测试:runTest 是救星
协程测试是 Kotlin 单元测试里最容易被忽略的部分。很多新手直接写 runBlocking 来测挂起函数,这在简单场景下没问题,但一旦涉及 delay、withContext 或者多个协程并发,runBlocking 就会让测试变得又慢又不稳定。
Kotlin 官方提供了 kotlinx-coroutines-test 库,里面的 runTest 就是专门解决这个问题的。它会自动跳过 delay,让测试瞬间完成。
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
class CoroutineTest {
@Test
fun `测试挂起函数`() = runTest {
val result = fetchData()
assert(result == "数据加载完成")
}
private suspend fun fetchData(): String {
delay(1000) // runTest 会跳过这个 delay
return "数据加载完成"
}
}
你看,runTest 直接返回一个 TestScope,里面所有 delay 都会被虚拟时间替代。实际执行时间几乎为零。我在项目中用 runTest 替换掉 runBlocking 后,测试套件的执行时间从 3 分钟降到了 10 秒。
runTest 里直接调用 withContext(Dispatchers.IO),结果测试一直卡住。原因是 runTest 默认使用 StandardTestDispatcher,它不会自动触发其他调度器上的协程。解决方案是用 UnconfinedTestDispatcher 或者显式控制虚拟时间。
5. 知识体系总览
下面这张图把本章的核心知识点串了起来。你可以看到,单元测试从 JUnit5 框架出发,分别对接 MockK、断言库和协程测试,最终形成一个完整的测试闭环。
6. 实战组合:一个完整的测试案例
最后,我把上面所有知识点揉进一个真实的测试场景里。假设我们有一个 UserService,它依赖网络请求(挂起函数),我们需要 Mock 网络层,然后验证业务逻辑。
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
// 模拟的网络接口
interface ApiService {
suspend fun fetchUser(id: Int): String
}
class UserService(private val api: ApiService) {
suspend fun getUserDisplayName(id: Int): String {
val name = api.fetchUser(id)
return "用户: $name"
}
}
class UserServiceTest {
@Test
fun `测试 getUserDisplayName`() = runTest {
// 1. Mock 网络接口
val mockApi = mockk()
coEvery { mockApi.fetchUser(1) } returns "张三"
// 2. 创建被测对象
val service = UserService(mockApi)
// 3. 执行测试
val result = service.getUserDisplayName(1)
// 4. 断言
assertEquals("用户: 张三", result)
}
}
这个例子虽然简单,但涵盖了 MockK 的 coEvery、协程的 runTest、以及 assertEquals 断言。你想想看,如果不用 MockK,你要怎么模拟网络接口?要么写一个假的实现类,要么用 Mockito 加一堆配置。用 MockK 就是三行代码的事。