---
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` 转发
