第四讲:注解驱动的 Mock:@MockK、@RelaxedMockK、@InjectMockKs

用注解来声明 Mock,说白了就是少写那几行重复代码。我个人刚开始用 MockK 时还是习惯手动 mockk<Foo>(),后来发现注解确实能让测试类更干净。不过注解背后有它自己的脾气,踩过坑才懂。

1. 三个核心注解

先看张图,把三兄弟的关系理清楚:

注解驱动 Mock 的核心流程 测试类 @MockK 严格 Mock @RelaxedMockK 宽松 Mock @InjectMockKs 自动注入 严格 Mock 对象 宽松 Mock 对象 注入后的 SUT 三者可组合使用:@MockK + @InjectMockKs 是最常见的搭配。 @RelaxedMockK 适合只想记录调用、不关心返回值的场景。 记得在测试类上标注 @MockKTest 或 @ExtendWith(MockKExtension::class)

上面流程你琢磨一下:测试类通过三个注解分别产出不同类型 Mock 对象,最后 @InjectMockKs 把它们喂给被测试类。这比手动 every { ... } 还是省事不少。

2. @MockK:严格模式是默认值

用 @MockK 声明的 mock,默认是严格模式。什么叫严格?就是没被 every 或 verify 覆盖的方法调用——直接抛异常。

class OrderServiceTest {
    @MockK
    private lateinit var paymentGateway: PaymentGateway

    @Test
    fun `正常支付`() {
        every { paymentGateway.charge(100.0) } returns true
        val result = paymentGateway.charge(100.0)
        assertTrue(result)
    }
}

我个人习惯用 @MockK 来写核心行为。它逼你把所有依赖的交互都显式声明出来,测试读起来就像一篇说明书。

注意:如果漏写了 every,而代码里又调了那个方法,测试会崩。我曾在 CI 上被这玩意坑过一次——本地跑全绿,合并后同事的机器上挂了一大片,最后发现是他少导入了一个注解。

3. @RelaxedMockK:宽松模式解千愁

宽松模式就宽容多了。没被定义的方法返回「空值」——数字返回 0,字符串返回空串,集合返回空列表。你想想看,如果有个日志记录器,每次调 log.info() 你都懒得 mock,那直接用 @RelaxedMockK 最合适。

class UserServiceTest {
    @RelaxedMockK
    private lateinit var logger: Logger

    @RelaxedMockK
    private lateinit var userRepo: UserRepository

    @Test
    fun `查询用户`() {
        // 只关心 userRepo 的查询行为
        every { userRepo.findById(1) } returns User("Alice")
        val result = userRepo.findById(1)
        assertEquals("Alice", result.name) 
        // logger 没被定义,不会抛异常
    }
}

我记得有个项目引入了第三方 SDK,里头一堆回调方法。用 @MockK 的话得写几十行 every,换成 @RelaxedMockK 后,测试代码缩水了三分之二。说白了,宽松模式就是「我不想管的别管我」。

经验之谈:@RelaxedMockK 并不代表它不会验证——你仍然可以 verify 它。只是没声明的方法调用不会炸而已。

4. @InjectMockKs:把 mock 塞进被测对象

这个注解是依赖注入的便利工具。它会自动寻找被注解字段的类型,然后通过构造函数、setter 或字段直接赋值的方式注入进去。

class OrderControllerTest {
    @MockK
    private lateinit var orderService: OrderService

    @RelaxedMockK
    private lateinit var auditLog: AuditLog

    @InjectMockKs
    private lateinit var controller: OrderController

    @Test
    fun `下单成功写入日志`() {
        every { orderService.create(any()) } returns Order(1)
        controller.submitOrder(...)
        verify { auditLog.log(any()) }
    }
}

这里 @InjectMockKs 把 orderService 和 auditLog 都注入到 OrderController 里。它支持按名称匹配、按类型匹配,甚至还支持 setter 注入。不过要注意——如果被测对象依赖的某个字段找不到匹配的 mock,它会留 null。嗯,这里要注意。

@InjectMockKs 的工作机制

  • 优先通过构造函数注入(最安全)
  • 如果没合适的构造器,尝试 setter 注入
  • 最后尝试反射直接写字段(可能会破坏封装)

5. 三者对比一览

注解 模式 未定义行为 典型场景
@MockK 严格 抛异常 核心业务依赖,需精确控制
@RelaxedMockK 宽松 返回默认值 日志、配置、非关键依赖
@InjectMockKs 纯注入 不适用 将被注解的 mock 自动注入到 SUT

6. 避坑指南:我曾经踩过的几个雷

先说最典型的:

  • 忘记开启 MockK 扩展——必须在测试类上加 @ExtendWith(MockKExtension::class)(JUnit5)或继承 MockKTest,否则注解不会生效,字段全是 null。我第一次用就掉进去了,debug 了半天才发现。
  • @InjectMockKs 注入顺序——如果被测对象有两个同类型的依赖,它默认按字段名匹配。我曾经把两个同名不同的 mock 混了,结果一直报空指针。后来改成显式 every 才定位到问题。
  • 不要在 @InjectMockKs 上再加 @MockK——这俩注解不能叠到同一个字段上,会直接报初始化异常。
警告:@InjectMockKs 对 Kotlin 的 nullable 类型处理不完美。如果 SUT 的构造参数声明为 Foo?,而字段里存在匹配的 @MockK,注入后会是 mock 对象;如果没找到匹配,字段会被注入 null。建议构造参数都用非空类型。

7. 最佳实践

说了这么多,我把它总结成几条原则:

  1. 核心依赖用 @MockK——让测试成为行为的契约。
  2. 辅助依赖用 @RelaxedMockK——排除噪音,聚焦关键。
  3. 被测对象用 @InjectMockKs——减少手写构造代码。
  4. 别迷信自动注入——遇到复杂的依赖图,手动 new + 传入 mock 反而更可控。

举个例子,我的一个支付模块测试类里,有 6 个 @MockK、3 个 @RelaxedMockK、1 个 @InjectMockKs,组合使用下来每个测试函数都控制在 10 行以内。你想想看,这种代码维护起来多省心。

小技巧:IntelliJ IDEA 里可以通过 ⌘ + N 快速生成 @MockK 字段,配合 Live Template “mockk” 能省不少打字时间。我一般还会把 @InjectMockKs 的字段标注为 private lateinit var,这样 Kotlin 不会抱怨未初始化。