关于 tdd-mock-object
面向 JVM 测试的技能,说明如何用测试替身隔离缓慢、外部或非确定性依赖。强调优先使用高保真替身、在边界处模拟,并避免过度模拟。
主要功能
- 01区分 Dummy、Fake、Stub、Spy 和 Mock
- 02提供替身选择启发
- 03说明如何隔离外部依赖
- 04强调契约测试与避免过度模拟
适用场景
- 01测试依赖数据库、网络或文件系统的代码
- 02隔离时间、随机数等非确定性输入
- 03验证通知等协作者是否被正确调用
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ yonatankarp/ software-design-skills --skill tdd-mock-object安装后在 Claude Code 中输入/tdd-mock-object确认已加载。
详细介绍
一句话概述
使用测试替身,替代那些速度太慢、过度依赖外部环境或非确定性太强、无法在测试中调用的真实协作者。谨慎使用——过度模拟会让测试依赖实现细节,而非行为。
何时使用此技能
- 否则,测试会访问数据库、网络、文件系统、消息代理或其他任何“JVM 之外”的资源。
- 否则,测试会依赖系统时间、随机性或其他非确定性来源。
- 否则,测试会调用速度慢、收费或有速率限制的第三方 API。
- 你想验证协作者是否被正确调用(例如,是否触发了通知器)。
何时不使用此技能
- 依赖速度快、行为确定且在进程内——直接使用真实对象即可。
- 你模拟的是自己编写、且其行为正是测试对象的类。这是在测试模拟对象,而非代码。
- 你模拟的是值对象或数据结构——这没有必要,直接构造真实对象即可。
- 你把所有东西都模拟了,测试现在大部分内容都是模拟设置。应先简化设计,再添加更多模拟。
核心内容
五种类型(Meszaros 的分类法,广泛使用):
- Dummy(占位对象)。 传入但不会实际使用真实值时使用的占位对象。(Kotlin 中的
null、Object()、Any()。) - Fake(伪实现)。 可用但经过简化的实现。保存
MutableMap<Id, Entity>的内存中Repository就是伪实现。 - Stub(桩)。 返回预设的答案;不验证调用方式。“请求
findById(42)时,返回这个预设的 User。” - Spy(间谍)。 会记录调用方式的桩,因此测试可以断言调用情况。
- Mock(模拟对象)。 在测试运行之前设置预期;如果预期的调用没有发生,测试就会失败。基于库的工具:Mockito、MockK、jMock。
日常交流中,大家会把这五种都称为“mock”。严谨讨论时,这些区别很重要:桩关注输入;模拟对象关注输出验证。
库(JVM):Mockito(Java 标准库)、MockK(符合 Kotlin 习惯,可处理 final 类和协程)、jMock(较老、严格)。Kotlin 项目选择 MockK;Java 项目可以使用 Mockito。
最大的风险:过度模拟。 模拟每个协作者的测试有两个问题:
- 它们测试的是实现,而非行为。重构若改变了调用哪个协作者,即便外部可观察到的行为完全相同,测试也会失败。
- 测试内容大多是模拟设置。读测试比读生产代码更费劲。
Beck 的“不要模拟自己不拥有的东西”原则。 模拟边界——你的代码与外部世界交互的接口。不要模拟外部世界本身。边界是你的,你可以一致地为它创建伪实现。外部世界是他们的;你的模拟会逐渐偏离真实行为,而你直到生产环境出问题才会察觉。
将模拟对象与契约测试配套使用。 如果你为单元测试伪造了 PaymentGateway,还要编写一个契约测试,分别针对伪实现和真实网关运行,并断言它们在重要用例中的行为一致。没有契约测试,伪实现就会逐渐偏离真实行为,甚至给出错误结果。
决策启发
- 默认应保持谨慎。真实对象 > 伪实现 > 桩 > 间谍 > 模拟对象。选择实际可行且保真度最高的方案。
- 在边界处模拟。在你的领域内部,优先使用真实对象。
- 对于时间:注入
Clock。对于随机性:注入Random。对于 HTTP:注入你自己拥有的小型接口,而不是直接注入第三方客户端。 - 不要轻易验证“调用次数”。
verify(mock, times(3))几乎总是在测试实现,而非行为。 - 如果测试中设置模拟对象的行数多于断言的行数,就说明设计需要调整。
反模式
- 模拟数据库。 很常见;但几乎总是错误的做法。使用内存实现(H2、搭配真实数据库的 Testcontainers,或伪仓库)。
- 模拟值对象 / 数据类。 使用
data class Money(BigDecimal, Currency)构造真实对象;不要mock<Money>()。 - 模拟框架。 测试验证是否调用了 Spring 的
@Transactional。你测试的是 Spring,而不是自己的代码。 - 脆弱的调用次数验证。 无害的重构将仓库调用从两次改为一次,行为没有差异,却导致
verify(repo, times(2))失败。 - 没有针对伪对象的契约测试。 伪对象的行为逐渐偏离真实对象;测试通过;生产环境出故障。
- 模拟自己不拥有的对象(直接模拟第三方 SDK)。 将他们的 SDK 封装在由你拥有的小型接口后面,并模拟该接口。
另请参阅
tdd-test-isolation— 模拟是实现隔离的一种手段。tdd-anti-patterns— 过度模拟和脆弱的验证是目录中列出的异味。kp-testing-coroutines—TestDispatcher是类似模拟对象的调度器替代品;同样的准则也适用。gof-kotlin-idioms— Kotlin 的 MockK 支持final类和挂起函数。
参考资料
- Kent Beck,Test-Driven Development By Example,第 27 章 — “Mock Object”条目及其 Self Shunt 变体。
- Gerard Meszaros,xUnit Test Patterns — 对测试替身分类的详尽介绍。
- Steve Freeman & Nat Pryce,Growing Object-Oriented Software, Guided by Tests — “模拟角色,而非对象”——正确实践的经典指导。
评论
支持 Markdown