关于 test-first
test-first 是引导开发者通过失败测试、最小实现和重构来完成代码变更的技能。它适用于功能开发、修复缺陷、重构和行为修改,少数例外需经人工同意。
主要功能
- 01先写失败测试,再写生产代码
- 02验证预期失败与通过结果
- 03仅实现通过测试所需的最少代码
- 04在测试持续通过时进行重构
- 05用测试复现并防止缺陷
适用场景
- 01按测试优先步骤开发新功能
- 02用复现测试修复缺陷
- 03通过测试检查重构回归
- 04通过已验证测试修改行为
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ noobygains/ godmode --skill test-first安装后在 Claude Code 中输入/test-first确认已加载。
附带文件testing-anti-patterns.md
详细介绍
测试优先开发
概述
先编写测试。观察它失败。然后编写能通过测试的最少代码。
核心原则: 如果你没有亲眼看到测试失败,就没有证据证明它验证了正确的行为。
没有例外。没有变通办法。没有捷径。
适用场景
必须采用:
- 新功能
- Bug 修复
- 重构
- 行为变更
例外情况(请与人工协作者确认):
- 一次性原型
- 生成的代码
- 配置文件
是不是很想说“就这一次,跳过测试优先”?停下。这是在自我开脱。
首要指令
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
先写了代码,再写测试?删掉它。从头开始。
零容忍:
- 不要把它留作“参考资料”
- 不要在编写测试时对它做“调整”
- 不要看它
- 删除就是删除
从测试开始重新编写。到此为止。
红—绿—重构
digraph tdd_loop {
rankdir=LR;
red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"];
check_red [label="Fails\ncorrectly?", shape=diamond];
green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"];
check_green [label="All tests\npass?", shape=diamond];
refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"];
next [label="Next", shape=ellipse];
red -> check_red;
check_red -> green [label="yes"];
check_red -> red [label="wrong\nfailure"];
green -> check_green;
check_green -> refactor [label="yes"];
check_green -> green [label="no"];
refactor -> check_green [label="stay\ngreen"];
check_green -> next;
next -> red;
}
RED - 编写失败的测试
编写一个描述预期行为的最小测试。
```typescript test('retries failed operations up to 3 times', async () => { let callCount = 0; const unstableOp = () => { callCount++; if (callCount < 3) throw new Error('transient failure'); return 'done'; };const outcome = await withRetry(unstableOp);
expect(outcome).toBe('done'); expect(callCount).toBe(3); });
名称清晰,测试实际行为,只关注一个方面
</Good>
<Bad>
```typescript
test('retry works', async () => {
const spy = jest.fn()
.mockRejectedValueOnce(new Error())
.mockRejectedValueOnce(new Error())
.mockResolvedValueOnce('done');
await withRetry(spy);
expect(spy).toHaveBeenCalledTimes(3);
});
名称含糊,验证的是模拟对象的交互,而非实际行为
标准:
- 每个测试只验证一种行为
- 名称清晰
- 使用真实代码(仅在无法避免时使用模拟对象)
验证 RED - 观察测试失败
必须执行。绝不可跳过。
npm test path/to/test.test.ts
确认:
- 测试失败(而非报错)
- 失败消息符合预期
- 失败是因为缺少该功能(而非拼写错误)
测试通过了? 你测试的是现有行为。重写测试。
测试报错了? 修复错误,重新运行,直到测试正确地失败。
GREEN - 编写最少代码
编写能让测试通过的最简单代码。
```typescript async function withRetry(fn: () => Promise): Promise { for (let attempt = 0; attempt < 3; attempt++) { try { return await fn(); } catch (e) { if (attempt === 2) throw e; } } throw new Error('unreachable'); } ``` 只写足以通过测试的代码 ```typescript async function withRetry( fn: () => Promise, opts?: { maxAttempts?: number; backoffStrategy?: 'linear' | 'exponential'; onAttempt?: (n: number) => void; } ): Promise { // YAGNI } ``` 过度设计不要添加功能、重构无关代码,也不要超出测试要求进行“改进”。
验证 GREEN - 观察测试通过
必须执行。
npm test path/to/test.test.ts
确认:
- 测试通过
- 其他测试仍然通过
- 输出干净(无错误、无警告)
测试失败了? 修复代码,而不是测试。
其他测试失败了? 立即修复。
REFACTOR - 清理代码
仅在测试通过后:
- 消除重复
- 改进命名
- 提取辅助函数
保持测试通过。不要添加新行为。
重复
针对下一个行为,编写下一个失败的测试。
质量标准
| 质量 | 好 | 坏 |
|---|---|---|
| 专注 | 一次只做一件事。名称里有“和”?拆开它。 | test('validates email and domain and whitespace') |
| 描述清晰 | 名称说明所验证的行为 | test('test1') |
| 有明确目的 | 展示期望的 API | 掩盖代码应该做什么 |
为什么顺序很重要
“我稍后再写测试,确认它能正常运行”
代码写完后再编写的测试会立即通过。立即通过什么也证明不了:
- 可能验证了错误的内容
- 可能验证的是实现细节,而不是行为
- 可能遗漏了你忽视的边界情况
- 你从未观察过测试捕获失败的过程
先写测试会迫使你观察失败,从而证明测试确实验证了某些内容。
“我已经手动测试了所有边界情况”
手动测试是临时随意的。你以为自己面面俱到,但实际上:
- 没有测试内容的记录
- 代码变更后无法重新运行
- 有压力时很容易漏掉某些情况
- “我试过了,能用”并不全面
自动化测试是系统化的。每次执行的结果都相同。
“删掉 X 小时的工作太浪费了”
这就是沉没成本谬误。时间已经花出去了。现在你可以选择:
- 删除并采用测试先行的方式重做(再花 X 小时,但信心十足)
- 保留代码再补上测试(30 分钟,信心不足,还可能存在缺陷)
真正的浪费,是保留自己无法信任的代码。没有正当测试的可运行代码就是技术债务。
“测试先行太教条了,务实就该灵活调整”
测试先行确实很务实:
- 在提交前发现缺陷(比之后调试更快)
- 防止回归(测试会立即捕捉到破坏)
- 记录行为(测试展示代码如何运行)
- 支持重构(可以随意修改,测试会捕捉问题)
“务实”的捷径 = 在线上调试 = 更慢。
“事后编写测试也能达到同样的目的——重在精神,而非仪式”
不对。事后测试回答“这段代码做什么?”,先写测试回答“这段代码应该做什么?”
事后测试会受到你的实现影响。你验证的是自己构建的内容,而不是实际需求。你检查的是自己记得的边界情况,而不是后来才发现的情况。
先写测试会迫使你在实现之前发现边界情况。事后测试只能验证你是否记得所有情况(你没有)。
花 30 分钟事后写测试不等于测试先行。你能获得覆盖率,但无法证明测试有效。
认知陷阱
| 自我辩解 | 实际情况 |
|---|---|
| “太简单了,不值得测试” | 简单代码也会出错。编写测试只需 30 秒。 |
| “我稍后再测试” | 测试立即通过什么也证明不了。 |
| “事后测试也能达到同样的目的” | 事后测试 =“这段代码做什么?”先写测试 =“这段代码应该做什么?” |
| “我已经手动测试过了” | 临时测试并不系统。没有记录,也无法重新运行。 |
| “删掉 X 小时的工作太浪费了” | 沉没成本谬误。保留未经验证的代码就是技术债务。 |
| “保留代码作参考,先写测试” | 你会根据它调整实现。这就是事后测试。删除就意味着删除。 |
| “得先探索一下” | 可以。丢弃探索的成果,然后从测试先行开始。 |
| “难以测试 = 我应该跳过测试” | 听从测试的反馈。难以测试 = 难以使用。简化设计。 |
| “测试先行会拖慢我” | 测试先行比调试更快。务实 = 测试先行。 |
| “手动测试更快” | 手动测试无法证明边界情况正确。每次修改后都得重新测试。 |
| “现有代码没有测试” | 你正在改进它。为现有代码添加测试。 |
防护措施——停止并重新开始
- 先写代码,再写测试
- 实现完成后再写测试
- 测试立即通过
- 无法解释测试为什么失败
- “稍后”再添加测试
- 为“就这一次”找理由
- “我已经手动测试过了”
- “事后测试的作用一样”
- “重在精神,而非仪式”
- “保留代码作参考”或“调整现有代码”
- “已经投入 X 小时,删除太浪费了”
- “测试先行太教条了,我是在务实”
- “这次情况不同,因为……”
这些情况都意味着:删除代码,从测试先行重新开始。
示例:修复 Bug
Bug: 表单接受空电子邮件地址
RED
test('rejects empty email', async () => {
const result = await processForm({ email: '' });
expect(result.error).toBe('Email required');
});
验证 RED
$ npm test
FAIL: expected 'Email required', got undefined
GREEN
function processForm(data: FormData) {
if (!data.email?.trim()) {
return { error: 'Email required' };
}
// ...
}
验证 GREEN
$ npm test
PASS
重构 如果需要,提取一个用于多个字段的验证流程。
完成检查清单
宣布工作完成前:
- 每个新增的函数/方法都有测试
- 实现前观察每个测试失败
- 每个测试都因预期原因失败(缺少功能,而不是拼写错误)
- 编写通过每个测试所需的最少代码
- 所有测试均通过
- 输出干净(没有错误或警告)
- 测试使用真实代码(只有在无法避免时才使用模拟)
- 覆盖边界情况和错误路径
无法勾选每一项?你跳过了测试优先。重新开始。
遇到困难时
| 问题 | 解决方案 |
|---|---|
| 不知道如何编写测试 | 编写你希望存在的 API。先写断言。向你的人类搭档求助。 |
| 测试太复杂 | 设计太复杂。简化接口。 |
| 必须模拟所有内容 | 代码耦合过紧。使用依赖注入。 |
| 测试设置过于庞大 | 提取辅助函数。还是太繁琐?简化设计。 |
集成调试
发现 Bug?编写一个能复现它的失败测试。遵循测试优先循环。该测试既能验证修复,也能防止回归。
绝不在没有测试的情况下修复 Bug。
测试反模式
添加模拟或测试工具时,请阅读 @testing-anti-patterns.md,避免常见陷阱:
- 验证模拟行为,而非真实行为
- 在生产类中添加仅供测试使用的方法
- 未了解依赖链就进行模拟
最终规则
Production code -> test exists and failed first
Otherwise -> not test-first
未经你的人类搭档许可,不得破例。