幕 III · 手艺CH 10进度 10/24

测试替身:dummy / stub / spy / mock / fake

你要测的代码,很少是孤零零一个纯函数。它通常依赖别的东西:一个数据库、一个网络客户端、一个发邮件的服务。第 5 章说过,单元测试不能碰这些真家伙(慢、不确定)。那怎么办?给它们找替身 —— 一个长得像、但由你掌控的假货。人们把替身笼统叫「mock」,但其实它们有五种,各有分工,混着叫会让你写出错误的测试。这一章把这五个词钉死:它们是一条从「什么都不会的哑巴」到「能真跑的仿制品」的光谱。

为什么要替身dummy/stub/spy/mock/fake状态 vs 交互MockKfake 被低估

为什么需要替身

假设你在测一个 UserService.register(),它做三件事:校验输入、把用户存进 UserRepository、通过 EmailSender 发一封欢迎邮件。

你想测的是 register逻辑。但如果直接跑,它会真的连数据库、真的发邮件 —— 慢、要联网、还真给人发垃圾邮件。你需要把 UserRepositoryEmailSender 换成替身:长得一样(实现同一个接口),但行为由你在测试里规定。

◆ 判词 · 替身让你「隔离被测对象」

替身的作用是把被测单元和它的协作者切开,让你能:① 控制协作者的行为(让 repo「假装」返回某个用户);② 观察被测对象怎么使用协作者(有没有调 send);③ 甩掉真协作者的慢和不确定。

这是「隔离」(第 5 章)的具体手段。前提当然是:你的代码得依赖注入这些协作者 —— 又一次,可测性是设计出来的。

五种替身:一条光谱

Gerard Meszaros 在《xUnit Test Patterns》里把替身分成五种。它们的区别,是「知道的越来越多、能力越来越强」。下面这个 Demo 把它们摆成一条光谱,点每一个看它的定义、能力和 MockK 写法。

从左到右,能力递增:

◆ 判词 · 五种替身,从「哑」到「有行为」
  • Dummy(占位):什么也不做,只为填一个参数位。这条路径根本不用它,但签名要求必须传。
  • Stub(桩):被问到时,回一个你事先准备好的答案。「无论怎么调,find(1) 永远返回 Ann」。用来固定输入
  • Spy(探子):照常干活(可能是真实现),但偷偷记下自己被怎么调用了,供事后查验。
  • Mock(模拟):预设期望 + 校验交互。如果被测对象没有按预期方式调用它,测试就 FAIL。
  • Fake(仿制):一个能真跑、但简化过的实现 —— 比如用 HashMap 冒充数据库。它有真实的行为(能存能取)。

最重要的分界:状态验证 vs 交互验证

五种替身里,stub 和 mock 的区别最关键,也最容易搞混。它们对应两种根本不同的验证哲学:

Stub → 状态验证Mock → 交互验证
它做什么提供输入(喂假数据进来)校验输出的行为(有没有正确调用)
你断言什么被测对象算出的结果被测对象调了谁、调了几次、传了什么
例子every { repo.find(1) } returns Ann
然后 assertEquals(..., service.getName(1))
verify { sender.send(...) }
断言「确实发了邮件」
◆ 判词 · 优先状态验证,慎用交互验证

状态验证问「结果对不对」—— 它不关心你内部怎么实现的,所以你重构时它不会碎。交互验证问「你有没有以某种方式调用别人」—— 它把测试绑在了实现细节上,你一改内部调用方式,它就红,哪怕行为完全没变。

所以规矩是:能用「检查返回值/状态」验证的,就别用「检查调用」验证。交互验证留给那些「副作用本身就是要点」的场景 —— 比如「注册后必须发一封邮件」,这里「有没有发」就是你要测的行为,除了 verify 没有别的办法。过度使用交互验证,是第 11 章过度 mock 的病根。

MockK 速览

Kotlin 里事实标准是 MockK。核心就三组 API:

// 1) stub:规定「被问到时回什么」
val repo = mockk<UserRepo>()
every { repo.find(1) } returns User(1, "Ann")
every { repo.find(any()) } returns null        // 兜底

// 2) verify:校验「有没有按预期被调用」
val sender = mockk<EmailSender>(relaxed = true)  // relaxed:没 stub 的调用返回默认值
service.register(User(1, "Ann"))
verify(exactly = 1) { sender.send(any()) }       // 断言:正好发了一次

// 3) slot:捕获「传进去的到底是什么」
val slot = slot<Email>()
verify { sender.send(capture(slot)) }
assertEquals("ann@x.com", slot.captured.to)      // 断言参数内容

relaxed = true 很好用:它让所有没显式 stub 的方法都返回「合理的默认值」(0、空串、空列表),省得你为每个用不到的方法都写一行 —— 那些其实就是 dummy。

被低估的 fake

大多数人一想到替身就 mock,但很多时候,一个 fake 是更好的选择:

// 一个内存版 Repo:真有行为,能存能取
class FakeUserRepo : UserRepo {
    private val db = mutableMapOf<Int, User>()
    override fun save(u: User) { db[u.id] = u }
    override fun find(id: Int) = db[id]
    override fun all() = db.values.toList()
}
◆ 判词 · 一个 fake,常常胜过一堆 stub

当你的测试需要「存了再取、取了再改」这种有状态的交互,用 stub 会写得很痛苦(每一步都得 every { ... } 预设),而且极其脆弱。一个 fake 写一次,所有测试复用,行为真实(存进去真能取出来),测试读起来也像真实用法。

经验法则:依赖越是「有状态的数据容器」(Repository、Cache、DataStore),越该用 fake 而非 mock。依赖越是「一次性的动作」(发邮件、打点),用 mock/verify 更直接。

AI 与替身:它会 mock 上瘾

▲ AI 爱把一切都 mock 掉

让 AI 写带依赖的测试,它有个强烈倾向:把每一个协作者都换成 mock,然后大量 verify。结果是一种"什么都 mock、全靠 verify 交互"的测试 —— 看起来很专业、很全面,实则把测试死死绑在了当前实现上,重构一碰就碎,还抓不到真 bug。

更糟的是,它常常连「被测对象自己的返回值」都不 assert,只 verify「调了 repo.save」。这样的测试,把实现换成一个啥也不干的空壳(只调 save 不返回正确结果)照样绿。为什么?下一章。

MockK 的几个进阶点

日常够用之外,有几个高频场景值得知道:

// 协程:suspend 函数要用 coEvery / coVerify(不是 every / verify)
coEvery { repo.fetch(1) } returns User(1, "Ann")
coVerify { repo.fetch(1) }

// 校验"根本没被调用"—— 常被忘,但很有用
verify(exactly = 0) { sender.send(any()) }    // 断言:这条路径不该发邮件

// 校验调用顺序(慎用,容易变脆 —— 只在顺序真的是需求时)
verifyOrder { inventory.reserve(any()); payment.charge(any()) }

// 抛异常:测失败路径
every { repo.find(any()) } throws IOException("网络断了")

// 参数匹配:只关心某个字段
verify { sender.send(match { it.to == "ann@x.com" }) }

其中 verify(exactly = 0) 特别值得记 —— 「某件事不该发生」也是一种重要的断言。「取消订单时不该扣款」「游客访问不该写数据库」,这些「没做某事」的验证,只能靠交互验证。而 verifyOrder 则要克制:调用顺序往往是实现细节(第 11 章),除非顺序本身就是业务需求(必须先校验再扣款),否则别断言它,以免测试变脆。

一个场景,五种替身各就各位

把五种替身放进同一个测试里,你就彻底记住它们的分工了。测「用户注册」:

@Test fun `注册:存用户 + 发欢迎邮件`() = runTest {
    val logger = mockk<Logger>(relaxed = true)   // ① Dummy:占位,这条路径不关心日志
    val repo = FakeUserRepo()                    // ② Fake:内存版,真能存能取
    val validator = spyk(RealValidator())        // ③ Spy:真校验,但记下被调用情况
    val mailer = mockk<Mailer>()                  // ④ Mock:要校验"发了邮件"
    every { mailer.send(any()) } returns true    //    (顺带 stub 它的返回值 —— ⑤ Stub)

    val service = UserService(repo, validator, mailer, logger)
    service.register("ann@x.com", "pwd12345")

    assertEquals("ann@x.com", repo.find(1)?.email)   // fake:验证真的存进去了(状态)
    verify { mailer.send(match { it.to == "ann@x.com" }) } // mock:验证发了邮件(交互)
    verify { validator.isEmail("ann@x.com") }              // spy:验证走了校验
}

看清楚每种的角色:fake 让你能真的「存了再取」验证状态;mock 校验那个副作用(发邮件)真的发生了;spy 确认校验这一步没被跳过;stub(every...returns)固定 mailer 的返回,免得它真发;dummy(relaxed 的 logger)只是填参数位。一个测试,五种替身各司其职 —— 这就是「替身光谱」在真实代码里的样子。

✚ 动手

1. 回到 Demo,把 stub 和 mock 两张卡对照读。确认你能说清:一个是「喂输入」,一个是「查调用」。

2. 找一个你写的、mock 了一堆东西的测试,数它有几个 verify(交互验证)、几个 assertEquals(状态验证)。如果全是 verify,它可能太脆了。

3. 把一个被 mock 的 Repository 换成 fake,看测试是不是读起来更像真实用法、也更抗重构。

替身是把利器,但很容易用过头。下一章专门讲这个:当你把每个协作者都换成假的,你测的到底还是不是你的代码?这就是「伦敦学派 vs 底特律学派」那场持续了二十年的争论。