--- name: test-triage description: Use before writing, adding, or modifying any unit test in this repo — before creating a `*Test.kt` file or a `@Test` function, and before touching an existing test after a refactor. Triages whether the test should exist at all, and what must happen to it when the code it guards changes. --- # 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`——放错地方了,删掉。 ## 拦截力为零的四种形态 四类都归零,原因各不相同。给它们起了名字,方便在提交和评审里直接点名。 ### ① 回声测试 构造一个对象,再断言它等于自己刚塞进去的值。 ```kotlin val model = ToolSpec(name = "a", enabled = true) assertEquals("a", model.name) ``` 它拦的是字段改名导致的编译错误——而编译错误不需要断言兜着。唯一能让它变红的方式是改坏赋值,那时候编译器先红。 ### ② 钉文案 断言某个 `message` 等于一句写死的文案。 ```kotlin assertEquals("接口返回为空", error.message) ``` 文案不是行为:它进资源、换措辞、做本地化,测试就为一个字而碎,与功能正确性无关。 区分它和真测试:`assertEquals("Does one thing.", metadata.description)` 测的是**解析器**的输出,那是真行为。区别不在写法,在门禁那一问的答案。 ### ③ 钉影子 断言某个已经删掉或遗留的东西不存在。 ```kotlin assertNull(tool.ssh_terminal) // 这个字段早就没了 ``` 它钉的是过去的影子。功能早已不在代码里,拦不住任何东西。 ### ④ 钉未来 / 复读 断言"某字段以后仍然是 null"(拦一个尚不存在的变化),或整段复制另一个测试已经覆盖的契约。 ```kotlin @Test fun spec_nonButtonTokensRemainUnsetForNow() { ... } ``` 带 `ForNow`、`NotYet`、`RemainsUnset`、`ForFuture` 这类名字的测试,默认按这一类处理。 ## 被测代码重构之后 **触发条件**:你重命名、拆分、合并、替换了某个类或方法的职责;移除了一个旧方法或旧字段;或者你判定这段被测代码本身就是要被替换掉的遗留代码。 这时它对应的单测**必须重写或删除**,只有这两种处置。三条硬规则,按顺序判断: 1. **被测目标没了,或换了职责** → **删掉整个测试文件**。不要改到能跑,也不要留一半。 2. **被测目标还在,但内部结构变了** → **重写测试**:按新结构重新组织用例,重新走一遍门禁。不要改两行让它编译通过。 3. **重构后测试一行不改就通过** → 说明它根本没在测被重构的东西 → **删掉**。 同时清理这些"为了让旧测试活着"的痕迹: - 为了兼容旧测试而保留的旧方法签名、旧类名、adapter、`@Deprecated` 转发 - 测试里绕过新结构的写法(直接构造内部状态、反射、访问 private) - 旧测试里大量断言已经不存在的字段 **重构不是改测试的理由,是删测试的理由。** ## 通过门禁之后 - **文件顶部写一行它保护什么**:`// 保护:<哪个行为,什么输入会打破它>`。 写不出这一行,说明门禁没过,回去重判。 - **测试名描述被保护的行为**,不是被调用的方法名。`rejectsBlankEndpoint` 好过 `testValidate1`。 - **一个 `@Test` 回答一个问题**。一个测试里断言五个互不相关的点,红了以后没人知道是哪坏了。 - **断言数量**:一个测试 1~3 个断言。超过 5 个还在同一个 `@Test` 里,基本可以确定它该被拆开。 ## 完成判据 收工前逐条过一遍,任何一条不成立就不算完成: - [ ] 每个新增或修改的 `@Test`,我都能立刻说出门禁那一问的答案 - [ ] 答不上来的已经**删掉**,不是留着"反正不碍事" - [ ] 对照过「一票否决」表,没有一条踩中 - [ ] 保留下来的每个测试文件顶部有 `// 保护:...` 那一行 - [ ] 如果动了被测代码的结构,对应的测试是**重写或删除**的,不是打补丁修绿的 - [ ] 没有为了兼容旧测试而保留的旧签名、adapter、`@Deprecated` 转发