单元测试: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 classobject扩展函数 支持得不好。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,链式调用读起来像自然语言。

小技巧:MockK 还支持 coEverycoVerify,专门用于挂起函数。后面协程测试会用到。

3. 断言库:选 Kotlin Test 还是 Hamcrest?

断言库的选择,说白了就是「你习惯哪种写法」。Kotlin 官方提供了 kotlin.test 包,里面有 assertEqualsassertTrue 这些基础断言。如果你觉得不够表达力,可以上 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 来测挂起函数,这在简单场景下没问题,但一旦涉及 delaywithContext 或者多个协程并发,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、断言库和协程测试,最终形成一个完整的测试闭环。

Kotlin 单元测试 JUnit5 框架 MockK 模拟库 断言库 协程测试 @Test / @DisplayName 参数化测试 / 嵌套测试 mockkObject / every / verify coEvery / coVerify(挂起函数) kotlin.test / Hamcrest assertThat / should 风格 runTest / TestScope 虚拟时间 / 跳过 delay

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 就是三行代码的事。

核心总结:单元测试不是「写不写」的问题,而是「怎么写才高效」的问题。JUnit5 搭框架,MockK 处理依赖,断言库验证结果,runTest 搞定协程。这四样东西组合起来,你的 Kotlin 项目测试覆盖率想低都难。
公众号:蓝海资料掘金营,微信deep3321