第四讲:注解驱动的 Mock:@MockK、@RelaxedMockK、@InjectMockKs
用注解来声明 Mock,说白了就是少写那几行重复代码。我个人刚开始用 MockK 时还是习惯手动 mockk<Foo>(),后来发现注解确实能让测试类更干净。不过注解背后有它自己的脾气,踩过坑才懂。
1. 三个核心注解
先看张图,把三兄弟的关系理清楚:
上面流程你琢磨一下:测试类通过三个注解分别产出不同类型 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 后,测试代码缩水了三分之二。说白了,宽松模式就是「我不想管的别管我」。
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——这俩注解不能叠到同一个字段上,会直接报初始化异常。
Foo?,而字段里存在匹配的 @MockK,注入后会是 mock 对象;如果没找到匹配,字段会被注入 null。建议构造参数都用非空类型。
7. 最佳实践
说了这么多,我把它总结成几条原则:
- 核心依赖用 @MockK——让测试成为行为的契约。
- 辅助依赖用 @RelaxedMockK——排除噪音,聚焦关键。
- 被测对象用 @InjectMockKs——减少手写构造代码。
- 别迷信自动注入——遇到复杂的依赖图,手动 new + 传入 mock 反而更可控。
举个例子,我的一个支付模块测试类里,有 6 个 @MockK、3 个 @RelaxedMockK、1 个 @InjectMockKs,组合使用下来每个测试函数都控制在 10 行以内。你想想看,这种代码维护起来多省心。
小技巧:IntelliJ IDEA 里可以通过 ⌘ + N 快速生成 @MockK 字段,配合 Live Template “mockk” 能省不少打字时间。我一般还会把 @InjectMockKs 的字段标注为 private lateinit var,这样 Kotlin 不会抱怨未初始化。