关于 testing-on-the-toilet
将 Google Testing on the Toilet 的测试原则整理为测试编写与审查指南,面向软件开发者。内容强调验证可观察行为、选择合适测试层级与测试替身,并检查测试能否发现真实缺陷。
主要功能
- 01识别只复述实现的测试
- 02按风险选择测试层级
- 03按需选择真实对象或测试替身
- 04编写清晰且聚焦的断言
- 05确保测试隔离且确定
- 06验证测试能暴露缺陷
适用场景
- 01审查现有测试是否验证行为
- 02为新功能选择测试层级
- 03减少脆弱的模拟对象测试
- 04检查测试的确定性与有效性
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ shamashel/ testing-on-the-toilet --skill testing-on-the-toilet安装后在 Claude Code 中输入/testing-on-the-toilet确认已加载。
附带文件references/episodes.md
详细介绍
厕所里的测试
摘编自 Google 的 Testing on the Toilet 系列文章(2007–2026)。文章索引:references/episodes.md。
每次新增测试和评审测试时,都运行这一套流程。完成标准:每个合入的测试都能在实际行为出现 bug 时失败,并能在不改变行为的重构后继续通过。
1. 变更检测器关卡
变更检测器只会复述生产代码的结构(尤其是按顺序 verify 每个协作者的调用),却不检查可观察结果。正确和错误的实现都能通过;任何重命名、提取或调整顺序都会导致整套测试失败。
改写测试,断言状态、返回值、渲染输出或持久化数据。如果说不出用户可见的契约,就删除测试并说明原因。不要通过机械更新几十个镜像测试来“修复”重构。
只有当调用本身就是契约时,才允许交互式 verify(例如,只发送一次邮件、不重复扣费、presenter 告知 view 显示 X)。如果不确定,就改为断言状态或返回值;如果做不到,就停下来询问该测试保护的是哪种可观察结果。
2. 选择仍能忠实反映行为的最便宜层级
先说出 bug 或风险。选择表格中最上方能捕获它的层级。只有当更便宜的层级无法捕获该风险时,才升级到更高层级(并说明会漏掉什么)。如果两个层级都能捕获风险,就用 SMURF(速度、可维护性、资源利用/成本、可靠性、忠实度)来权衡:优先选择更便宜、更快、更可靠的方案。
| 风险 | 层级 |
|---|---|
| 纯逻辑、解析、校验、reducer | 通过公开 API进行快速单元测试 |
| 你拥有的进程内协作者 | 真实对象或由所有者维护的 fake |
| 服务/HTTP 契约 | 所有者提供的 fake 或隔离式服务器——不要手写请求 mock |
| UI 连线问题(禁用、未绑定、隐藏) | 操作渲染后的控件(点击/输入),不要直接调用处理器 |
| 跨系统关键路径 | 少量 e2e 测试:每个用例一条路径,加上关键错误类别;断言系统结果,而不是文案/布局 |
在叠加多个层级之前,先集思广益,列出关键风险。覆盖率指标能发现缺口,但不能证明质量。要覆盖分支的两侧(隐式的 else 也算)。不要穷举所有组合;提取谓词并分别覆盖。
优先通过公开 API进行测试。为调用方永远不会传入的输入穷举私有辅助函数的测试,实质上仍是变更检测器。
3. 选择测试替身
顺序:真实对象 → fake → stub → mock。mock 放在最后。
- 如果真实对象速度快、结果确定且在进程内运行,就用真实对象。
- Fake:由你(或库的所有者)维护的简化可用实现。保持范围精简。如果 fake 会被多个地方共用,就针对真实对象进行契约测试。
- Stub:为查询提供预设返回值。不要对 getter 使用
verify。 - Mock:验证副作用调用,且该交互本身就是规范。
硬性规则:
- 不要 mock 你不拥有的类型。封装第三方 API;mock 这个封装层;再用真实库测试该封装层。
- mock 超过一两个协作者,或出现很长的
when/verify链,是 seam 异味——提取更精简的端口,并为它创建 fake。 - 在构造函数中注入长期存在的协作者;将每次调用的工作作为方法参数传入。不要在被测逻辑中使用隐藏的单例、无法 mock 的静态方法或
now()。
4. 编写测试(DAMP)
在测试中,DAMP(描述性且有意义的短语)胜过 DRY。测试本身没有测试——读者必须能在方法体中看到原因与结果相邻。
- 每个测试只验证一种行为。 命名为
unit_scenario_expectedOutcome(或项目中的等效形式)。testFoo不算名称。 - 在一个代码块中按 准备–执行–断言 编写。远离断言、内容混杂的共享
@Before,会让错误的预期值悄悄混进来。 - 辅助方法隐藏 无关的 构造细节。与场景相关的字段应在测试中 清晰可见。带默认值的构建器/工厂是好做法;默默断言辅助方法的默认值则不是。
- 使用 字面量 输入和预期输出。不要用可能与生产代码共享同一错误的循环/条件语句来计算预期结果。如果辅助方法必须包含逻辑,就为它编写单元测试。
- 精确断言:只断言待测字段,而不是整个对象/屏幕截图/哈希顺序。对于常见的正常对象,一次完整相等性检查就够了。
- 每个输入使用 不同的非默认值(
0、""、第一个枚举值可能与未初始化状态相同,从而造成假通过)。 - 可操作的失败信息:优先使用精确匹配器(如
containsEntry、带错误文本的isOk),而不是assertTrue(result.ok())。独立检查应继续执行(EXPECT),除非后续断言已无意义(文件未打开时使用ASSERT)。 - 浮点数:使用 容差,绝不要求精确相等。
- UI 定位器:使用稳定的测试 ID,而不是文案或脆弱的 XPath。
5. 保持隔离与确定性
单元测试中不使用真实时钟,不用 sleep 同步,不访问实时网络/磁盘,也不假定共享文件/数据行为空。
- 注入时钟,并让它 前进。
- 使用带超时的闩锁/事件等待,或推进伪调度器。
- 每个测试使用唯一的临时路径/隔离的测试夹具。
- 通过测试替身触发罕见故障(超时、抛出错误)——不能依赖实时基础设施。
6. 证明测试能够失败
在本地编写或编辑时:先运行并确认通过,再破坏生产代码(或反转断言),确认测试 失败。审查时若无法运行:写出这条断言能捕获的一个具体行为错误;如果写不出来,就把它视为变更检测器,不要批准。
重构测试时,先让生产代码处于已知错误状态,以便发现被删除的断言;然后恢复生产代码。未经这项检查,绝不进行测试的“绿到绿”清理。
如果剩下的唯一测试是同义反复、实现的模拟脚本,或没有逻辑的连线验证:不要添加它。应指出缺失的接缝,或说明应由哪一层承担该风险。
如果项目约定与此处的硬性规则冲突(要求模拟所有协作者、基于 sleep 的等待、只测试私有辅助方法):停止并指出冲突。不要默默遵循较弱的约定。
审查清单
愿意合并的测试:
- 会因真实的行为错误而失败,而不只是因重命名/提取而失败
- 断言结果/状态,而不是调用脚本(除非调用本身就是契约)
- 覆盖公开 API / 面向用户的路径,而不是无法到达的辅助方法
- 原因与结果清晰可见;名称说明了预期结果
- 具有确定性(时钟/IO/唯一性均已处理)
- 失败信息能直接指导修复,无需额外日志