Test Triage
写测试前先分诊:这个测试该不该存在。通过了才谈怎么写。
一个测试的全部价值来自它的拦截力——删掉它,哪个真实 bug 会漏到用户手上?答不上来的测试拦截力为零:不要写,已经写了的删掉。
测试膨胀的根源不是测试写得不好,是没经过分诊就写下去了。
门禁
动手写 @Test 之前,先回答一句话:
删掉这个测试,哪一个真实发生过、或真实可能发生的改动,会让 bug 漏出去?
答案必须是一个具体的改动:谁改什么、怎么改坏、漏出去之后用户看到什么。答得出来 → 往下写;指不出来 → 不写。
"我想确认它是对的"不是答案。那是静态分析、类型系统、编译器的工作。
改已有测试,也要过同一道门禁
动任何一个已存在的 @Test 之前,拿同一句话再问它一遍,另外加一句:
如果这个测试今天要我从零写一遍,我还会写吗?
不会 → 删掉它,而不是改两行让它继续绿。
改测试只有两种正当理由:被测契约变了(该重写),或测试本身有缺陷(该重写)。"让它变绿"不是理由。
一票否决:看到这些,直接不写
不需要判断,也不需要"至少测一下"。
| 被测对象 | 为什么拦不住 bug | 处置 |
|---|---|---|
data class 的字段、copy()、equals/toString | 编译器已经保证 | 不写 |
| 常量、枚举成员、默认参数值 | 编译器已经保证 | 不写 |
| getter / setter / 纯委托的转发方法 | 没有分叉点 | 不写 |
| private 辅助方法的单独测试 | 通过 public 行为间接覆盖 | 不写 |
| 资源字符串、文案、提示语的措辞 | 文案不是行为 | 不写 |
| 日志有没有打、打成什么 | 不是行为 | 不写 |
| ServiceRegistry 装配、DI 注册、构造注入 | 装配错了启动就崩,不用断言 | 不写 |
| 断言某字段"以后仍然是 null / 仍然不会被赋值" | 拦的是一个尚不存在的变化 | 不写 |
| 断言某个已删除 / 遗留的东西不存在 | 拦的是过去的影子 | 不写 |
| 另一个测试已经完整覆盖的同一契约 | 两个测试守一个契约 = 只有一个 | 不写,删重复的那个 |
一票通过:这些是拦截力的来源
行为会分叉,而分叉点在静态分析里看不出来——这才是值得写的。
| 形态 | 为什么值得 |
|---|---|
| 状态机迁移,含非法迁移与出错后停在哪 | 迁移关系不在类型里 |
if / when 的条件分支、边界值、空集合、超长输入 | 分支覆盖不可静态推导 |
| 解析、序列化、编解码的畸形输入 | 真实数据会超出预期 |
| 权限、降级、兜底、重试路径 | 出错路径最容易写坏 |
| 并发、取消、顺序、重复调用的既定契约 | 时序不可静态推导 |
| 曾经真实回归过的点 | 已证实的拦截力,最高优先 |
| ViewModel / Reducer 的状态计算 | 是逻辑,不是渲染 |
UI 的硬边界
不给 UI 写测试。 渲染、布局、点击、滚动、主题、动画、文案换行——这些都是 UI,由人工和 QA 验证,不写 @Test。这条不开放讨论。
要测的是 UI 背后的状态机:ViewModel / Reducer / Controller 里那段"输入什么、状态变成什么"的纯计算。它和 Compose 无关,只是恰好被 UI 调用。
一个测试里如果出现了 Compose 的 @Composable、createComposeRule、semantics、onNodeWithText——放错地方了,删掉。
拦截力为零的四种形态
四类都归零,原因各不相同。给它们起了名字,方便在提交和评审里直接点名。
① 回声测试
构造一个对象,再断言它等于自己刚塞进去的值。
kotlinval model = ToolSpec(name = "a", enabled = true) assertEquals("a", model.name)
它拦的是字段改名导致的编译错误——而编译错误不需要断言兜着。唯一能让它变红的方式是改坏赋值,那时候编译器先红。
② 钉文案
断言某个 message 等于一句写死的文案。
kotlinassertEquals("接口返回为空", error.message)
文案不是行为:它进资源、换措辞、做本地化,测试就为一个字而碎,与功能正确性无关。
区分它和真测试:assertEquals("Does one thing.", metadata.description) 测的是解析器的输出,那是真行为。区别不在写法,在门禁那一问的答案。
③ 钉影子
断言某个已经删掉或遗留的东西不存在。
kotlinassertNull(tool.ssh_terminal) // 这个字段早就没了
它钉的是过去的影子。功能早已不在代码里,拦不住任何东西。
④ 钉未来 / 复读
断言"某字段以后仍然是 null"(拦一个尚不存在的变化),或整段复制另一个测试已经覆盖的契约。
kotlin@Test fun spec_nonButtonTokensRemainUnsetForNow() { ... }
带 ForNow、NotYet、RemainsUnset、ForFuture 这类名字的测试,默认按这一类处理。
被测代码重构之后
触发条件:你重命名、拆分、合并、替换了某个类或方法的职责;移除了一个旧方法或旧字段;或者你判定这段被测代码本身就是要被替换掉的遗留代码。
这时它对应的单测必须重写或删除,只有这两种处置。三条硬规则,按顺序判断:
- 被测目标没了,或换了职责 → 删掉整个测试文件。不要改到能跑,也不要留一半。
- 被测目标还在,但内部结构变了 → 重写测试:按新结构重新组织用例,重新走一遍门禁。不要改两行让它编译通过。
- 重构后测试一行不改就通过 → 说明它根本没在测被重构的东西 → 删掉。
同时清理这些"为了让旧测试活着"的痕迹:
- 为了兼容旧测试而保留的旧方法签名、旧类名、adapter、
@Deprecated转发 - 测试里绕过新结构的写法(直接构造内部状态、反射、访问 private)
- 旧测试里大量断言已经不存在的字段
重构不是改测试的理由,是删测试的理由。
通过门禁之后
- 文件顶部写一行它保护什么:
// 保护:<哪个行为,什么输入会打破它>。 写不出这一行,说明门禁没过,回去重判。 - 测试名描述被保护的行为,不是被调用的方法名。
rejectsBlankEndpoint好过testValidate1。 - 一个
@Test回答一个问题。一个测试里断言五个互不相关的点,红了以后没人知道是哪坏了。 - 断言数量:一个测试 1~3 个断言。超过 5 个还在同一个
@Test里,基本可以确定它该被拆开。
完成判据
收工前逐条过一遍,任何一条不成立就不算完成:
- 每个新增或修改的
@Test,我都能立刻说出门禁那一问的答案 - 答不上来的已经删掉,不是留着"反正不碍事"
- 对照过「一票否决」表,没有一条踩中
- 保留下来的每个测试文件顶部有
// 保护:...那一行 - 如果动了被测代码的结构,对应的测试是重写或删除的,不是打补丁修绿的
- 没有为了兼容旧测试而保留的旧签名、adapter、
@Deprecated转发


