别 mock 一切:伦敦学派 vs 底特律学派
上一章给了你一把利器,这一章讲怎么别拿它砍自己的脚。mock 有种上瘾性:它太方便了,方便到你会忍不住把每一个协作者都换成假的。但当一个测试里所有东西都是假的、你只剩下断言「A 有没有以我预想的方式调用 B、C、D」,一件危险的事就发生了 —— 你的测试变成了实现的一面镜子,你怎么写代码它就怎么长。重构一动,它全红;可它又抓不到真正的 bug。这场关于「该 mock 多少」的争论,有两个流派,吵了二十年。理解它们的分歧,你就知道自己的 mock 该停在哪。
两个学派
怎么写单元测试,历史上分裂成两派,分歧就在「对待协作者的态度」:
| 伦敦学派(mockist) | 底特律学派(classicist) | |
|---|---|---|
| 又叫 | mockist / 交互派 | classical / 状态派 |
| 对协作者 | 全部 mock 掉 | 用真的(或 fake),只在必要边界 mock |
| 「单元」是 | 一个类(严格隔离) | 一簇协作的类(一块行为) |
| 验证方式 | 交互验证(verify 调用) | 状态验证(assert 结果) |
| 重构时 | 容易碎 | 较稳 |
两派都有道理,也都有拥趸。但在实践里,纯粹的伦敦学派(什么都 mock)最容易出问题 —— 因为它把测试和实现焊得太紧。我们重点看它会长成什么样。
过度 mock 的味道
一个过度 mock 的测试,长这样:
@Test fun `下单`() {
val repo = mockk<OrderRepo>(relaxed = true)
val inventory = mockk<Inventory>(relaxed = true)
val payment = mockk<Payment>(relaxed = true)
val notifier = mockk<Notifier>(relaxed = true)
every { inventory.reserve(any()) } returns true
every { payment.charge(any()) } returns PaymentOk
service.placeOrder(order)
// 全是"你调了谁"的断言,没有一句"结果对不对"
verify { inventory.reserve(order.items) }
verify { payment.charge(order.total) }
verify { repo.save(any()) }
verify { notifier.send(any()) }
}
看出来了吗?这个测试逐行复述了 placeOrder 的实现:先 reserve、再 charge、再 save、再 notify。它没有验证任何结果(订单状态对不对、扣的钱对不对),只验证了「调用顺序和我写的一样」。
后果有两个,都很坏:
- 抗不住重构。你想优化,把「先扣款再预留库存」调个顺序、或者合并两次调用 —— 行为完全没变,但测试全红。测试成了重构的阻力,而不是保障。
- 抓不到真 bug。假如
placeOrder有个 bug:它 charge 了 100 但订单金额其实是 90。这个测试发现不了 —— 因为它只 verify「charge 被调了」,没 assert「charge 的金额对不对」,更没 assert 最终订单状态。
好测试验证的是「做了什么」(行为、结果),坏测试验证的是「怎么做的」(实现、调用序列)。前者给你重构的自由,后者把你锁死在当前写法上。
一个简单的自检:如果我在不改变行为的前提下重写这段实现,这个测试会不会红?会 → 它测的是实现,太脆了。不会 → 它测的是行为,健康。
那么,到底该 mock 什么?
不是「永不 mock」,而是只在正确的地方 mock。有一条流传很广、也很好用的规则:
mock 应该发生在架构边界上 —— 你的代码和外部世界(数据库、网络、第三方 SDK、文件系统)握手的地方。这些地方慢、不确定,必须替身。
而你内部的、自己拥有的协作者(你自己写的那些小类、值对象、纯逻辑),尽量用真的。把它们和被测对象放一起测,验证它们协作后的结果。这就是底特律学派的做法,它天然抗重构 —— 因为内部怎么拆分怎么协作,是实现细节,测试不该管。
用一张图记:
特别提醒最后半句「Don't mock what you don't own」的另一层意思:别直接 mock 第三方库的类(比如 Retrofit、Firebase 的类)。你不了解它们的内部契约,mock 出来的行为可能和真实的不一致 —— 你测得再绿,线上还是炸。正确做法是用一个你自己的接口把第三方包一层,然后 fake/mock 你那个接口。
fake 是那条中间路线
回到第 10 章的结论:很多时候,一个 fake 能同时拿到两派的好处 —— 它像底特律学派一样验证真实行为(能存能取),又像伦敦学派一样甩掉了真依赖的慢和不确定。这就是为什么 Google 的测试文化里,「优先用 fake,其次用真实现,mock 是最后手段」。一个内存版 Repository、一个假的时钟、一个假的网络层 —— 它们比一堆 stub/verify 稳得多。
AI 时代:它默认就是伦敦学派的极端版
让 AI 写带依赖的测试,它极大概率给你上面那种「全 mock + 全 verify」的版本。原因有二:一是这种模式在训练语料里最常见、最"模板化";二是它不理解你的架构边界 —— 它不知道哪些是你自己的内部类(该用真的)、哪些是外部依赖(该 mock),于是一视同仁全 mock。
结果就是一堆镜像实现的脆测试:它们在你没改代码时全绿(给你虚假的安全感),你一重构就全红(给你巨大的维护负担),而真 bug 照样漏过去。审查时的关键问题:这个测试断言了「结果」吗,还是只断言了「调用」?只有调用,就打回重写。
把过度 mock 的测试,改回来
光说「别过度 mock」太抽象,看一次具体的改造。前面那个「全 mock + 全 verify」的下单测试,改成底特律学派的样子:
// ✓ 改造后:内部协作者用真的/fake,只在边界(支付网关)mock,断言"结果"
@Test fun `下单后订单状态为已支付、金额正确`() {
val inventory = FakeInventory(stock = mapOf("book" to 5)) // fake,真有库存
val payment = mockk<PaymentGateway>() // 外部依赖,mock
every { payment.charge(90) } returns PaymentOk
val service = OrderService(inventory, RealPricer(), payment)
val order = service.placeOrder(cartOf("book" to 1)) // 真实计价、真实扣库存
assertEquals(OrderStatus.PAID, order.status) // 断言结果,不是断言"调了谁"
assertEquals(90, order.paidAmount)
assertEquals(4, inventory.stockOf("book")) // 库存真的减了
}
新版本:价格算错了(RealPricer 有 bug)会红、库存没扣会红、状态不对会红 —— 它真的在验证行为。而且你重构 placeOrder 内部的调用顺序,它不会红(只要最终结果对)。它只在真正的边界(支付网关这个外部依赖)上 mock,其余都用真的或 fake。这就是「测行为、在边界 mock」落地的样子 —— 它比原来那个"镜像实现"的版本,既更能抓 bug,又更抗重构。
测试逼你把设计改坏(test-induced design damage)
还有一个更深的信号,值得警惕。DHH 提出过一个词叫测试驱动的设计损伤:当你为了「方便 mock」而扭曲生产代码的设计 —— 加一堆没必要的接口、把简单的东西拆成层层可注入的碎片 —— 只是为了让测试能插进 mock。
如果你发现自己「为了测试」给一个本来很简单的类抽了五个接口、注入了七个协作者,停一下。这往往是过度 mock 的信号,而不是好设计的信号。真正需要抽象和注入的,是架构边界(数据库、网络);而内部的纯逻辑,直接用真的测就好,不需要为它造一堆可 mock 的缝。
一个健康的判断:如果一段代码难测,先怀疑它是不是把「该分开的关注点」粘在了一起(第 5 章),而不是「再加一层 mock 就好了」。前者是修设计,后者是给坏设计打补丁。
包一层:别直接 mock 第三方
「别 mock 你不拥有的」那句话,落地成一个具体动作:用你自己的接口把第三方包一层。
// ✗ 直接 mock Firebase 的类 —— 你不了解它的真实契约,mock 出来的行为可能失真
val auth = mockk<FirebaseAuth>()
// ✓ 用你自己的接口包一层,测试里 fake 你的接口
interface AuthClient { suspend fun signIn(email: String, pwd: String): Result<UserId> }
class FirebaseAuthClient(...) : AuthClient { /* 真实现,包住 Firebase */ }
class FakeAuthClient : AuthClient { /* 测试用,行为你完全掌控 */ }
好处有二:一是你 fake 的是自己定义的、简单的契约,行为可控且真实;二是哪天要换掉 Firebase(换成别的鉴权),只动那个实现类,你的一大堆测试一行都不用改 —— 因为它们依赖的是你的接口,不是 Firebase。这层薄薄的包装,同时买到了「可测」和「可换」。
1. 找你项目里 mock 最多的一个测试,做那个自检:「不改行为、只重写实现,它会红吗?」如果会,它测的是实现。
2. 数一个测试里 verify 和 assert 的比例。verify 远多于 assert,通常意味着它在测「怎么做」而非「做了什么」。
3. 挑一个被 mock 的内部小类,把它换成真的,看测试是否照样绿、还更抗改。你会发现很多 mock 根本没必要。
替身讲完了。接下来是单元测试里第二难搞的东西 —— 时间和异步。协程、Flow、回调,这些「不在当下完成」的代码,是测试脆弱的头号来源。下一章教你怎么把「等 5 秒」变成一瞬间,以及为什么 Thread.sleep 是测试里的原罪。