关于 tdd:test-driven-development
tdd:test-driven-development 是一项指导开发者先写失败测试、再编写最少实现并重构的编码技能。它强调确认测试先按预期失败,并避免只验证模拟对象的测试。
主要功能
- 01采用 Red-Green-Refactor 循环
- 02要求先确认测试失败
- 03编写通过测试所需的最少代码
- 04验证真实行为而非模拟行为
- 05提供 TDD 反模式指引
适用场景
- 01以测试优先开发新功能
- 02复现并修复程序错误
- 03借助回归测试进行重构
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ neolabhq/ context-engineering-kit --skill tdd:test-driven-development安装后在 Claude Code 中输入/tdd:test-driven-development确认已加载。
详细介绍
测试驱动开发(TDD)
概述
先写测试。看着它失败。编写最少的代码使其通过。
核心原则: 如果你没有亲眼看着测试失败,就不知道它是否测试了正确的内容。
违反规则的字面要求,就是违背规则的精神。
适用场景
始终适用:
- 新功能
- 错误修复
- 重构
- 行为变更
例外情况(请询问你的人工搭档):
- 一次性原型
- 生成的代码
- 配置文件
在想“这次就跳过 TDD”?停下。这是在为自己找借口。
铁律
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
先写了代码再写测试?删掉它。从头开始。
没有例外:
- 不要把它留作“参考”
- 不要在写测试时对它进行“改编”
- 不要看它
- 删除就是删除
从测试出发重新实现。就这样。
红-绿-重构
digraph tdd_cycle {
rankdir=LR;
red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"];
verify_red [label="Verify fails\ncorrectly", shape=diamond];
green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"];
verify_green [label="Verify passes\nAll green", shape=diamond];
refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"];
next [label="Next", shape=ellipse];
red -> verify_red;
verify_red -> green [label="yes"];
verify_red -> red [label="wrong\nfailure"];
green -> verify_green;
verify_green -> refactor [label="yes"];
verify_green -> green [label="no"];
refactor -> verify_green [label="stay\ngreen"];
verify_green -> next;
next -> red;
}
RED - 编写失败的测试
编写一个最简测试,展示应该发生什么。
```typescript test('retries failed operations 3 times', async () => { let attempts = 0; const operation = () => { attempts++; if (attempts < 3) throw new Error('fail'); return 'success'; };const result = await retryOperation(operation);
expect(result).toBe('success'); expect(attempts).toBe(3); });
名称清晰,测试真实行为,只测试一件事
</Good>
<Bad>
```typescript
test('retry works', async () => {
const mock = jest.fn()
.mockRejectedValueOnce(new Error())
.mockRejectedValueOnce(new Error())
.mockResolvedValueOnce('success');
await retryOperation(mock);
expect(mock).toHaveBeenCalledTimes(3);
});
名称含糊,测试的是模拟对象而非代码
要求:
- 一种行为
- 名称清晰
- 真实代码(除非无法避免,否则不要使用模拟对象)
验证 RED - 看着它失败
必须执行。绝不可跳过。
npm test path/to/test.test.ts
确认:
- 测试失败(不是报错)
- 失败消息符合预期
- 失败原因是缺少功能(而不是拼写错误)
测试通过了? 你测试的是现有行为。修正测试。
测试报错了? 修复错误,然后重新运行,直到它正确失败。
GREEN - 编写最少的代码
编写最简单的代码,让测试通过。
```typescript async function retryOperation(fn: () => Promise): Promise { for (let i = 0; i < 3; i++) { try { return await fn(); } catch (e) { if (i === 2) throw e; } } throw new Error('unreachable'); } ``` 足以通过即可 ```typescript async function retryOperation( fn: () => Promise, options?: { maxRetries?: number; backoff?: 'linear' | 'exponential'; onRetry?: (attempt: number) => void; } ): Promise { // YAGNI } ``` 过度设计不要添加功能、重构其他代码,也不要超出测试范围进行“改进”。
验证 GREEN - 看着它通过
必须执行。
npm test path/to/test.test.ts
确认:
- 测试通过
- 其他测试仍然通过
- 输出干净(没有错误或警告)
测试失败了? 修复代码,不要改测试。
其他测试失败了? 立即修复。
REFACTOR - 清理代码
只有通过后才能执行:
- 消除重复
- 改进命名
- 提取辅助函数
保持测试通过。不要添加行为。
重复
为下一个功能编写下一个失败的测试。
好的测试
| 质量 | 好 | 不好 |
|---|---|---|
| 最简 | 只测试一件事。名称里有“和”?拆分它。 | test('validates email and domain and whitespace') |
| 清晰 | 名称描述行为 | test('test1') |
| 表达意图 | 展示期望的 API | 模糊代码应该执行的操作 |
为什么顺序很重要
“我之后再写测试来验证它能否正常工作”
代码写完后再写的测试会立即通过。立即通过证明不了任何事:
- 可能测错了内容
- 可能测的是实现,而不是行为
- 可能漏掉你忘记考虑的边界情况
- 你从未看到测试捕获这个 Bug
先写测试会迫使你看到测试失败,从而证明它确实测到了某些内容。
“我已经手动测试过所有边界情况了”
手动测试是临时性的。你以为自己测试了所有情况,但实际上:
- 没有记录你测试过什么
- 代码变更后无法重新运行
- 有压力时很容易忘记测试某些情况
- “我试过时能用” ≠ 全面验证
自动化测试是系统性的。每次运行的方式都相同。
“删掉 X 小时的工作太浪费了”
这是沉没成本谬误。时间已经花掉了。现在你可以选择:
- 删除并用 TDD 重写(再花 X 小时,但信心十足)
- 保留代码并事后补上测试(30 分钟,但信心不足,而且可能有 Bug)
真正的“浪费”,是保留你无法信任的代码。没有真正测试的可运行代码就是技术债务。
“TDD 太教条了,务实就意味着灵活变通”
TDD 就是务实的做法:
- 在提交前发现 Bug(比事后调试更快)
- 防止回归(测试能立即发现问题)
- 记录行为(测试展示如何使用代码)
- 支持重构(可以自由修改,测试会发现问题)
所谓“务实”的捷径 = 在生产环境中调试 = 更慢。
“事后写测试也能达到同样的目标——关键是精神,而不是仪式”
不对。事后写的测试回答“这段代码做了什么?”,先写的测试回答“这段代码应该做什么?”
事后写的测试会受到实现的影响。你测的是自己构建的内容,而不是需求。你验证的是记得的边界情况,而不是发现的边界情况。
先写测试会迫使你在实现前发现边界情况。事后写的测试只能验证你是否记得所有情况(你并没有)。
事后花 30 分钟写测试 ≠ TDD。你能获得覆盖率,却无法证明测试有效。
常见的自我辩解
| 借口 | 现实 |
|---|---|
| “太简单,不值得测试” | 简单的代码也会出问题。测试只需 30 秒。 |
| “我之后再测试” | 测试立即通过证明不了任何事。 |
| “事后写测试也能达到同样的目标” | 事后写测试 = “这段代码做了什么?”先写测试 = “这段代码应该做什么?” |
| “我已经手动测试过了” | 临时测试 ≠ 系统测试。没有记录,也无法重新运行。 |
| “删掉 X 小时的工作太浪费了” | 这是沉没成本谬误。保留未经验证的代码就是技术债务。 |
| “留着作参考,先写测试” | 你会基于它调整代码。这就是事后测试。删除就要彻底删除。 |
| “我需要先探索一下” | 没问题。舍弃探索代码,然后从 TDD 开始。 |
| “测试很难写 = 设计不清晰” | 听听测试的反馈。难以测试 = 难以使用。 |
| “TDD 会拖慢我” | TDD 比调试更快。务实 = 先写测试。 |
| “手动测试更快” | 手动测试无法证明边界情况有效。每次修改后你都得重新测试。 |
| “现有代码没有测试” | 你正在改进它。为现有代码添加测试。 |
危险信号——停止并重新开始
- 先写代码,再写测试
- 实现完成后才写测试
- 测试立即通过
- 说不清测试为什么失败
- 测试“以后再加”
- 用“就这一次”来合理化
- “我已经手动测试过了”
- “事后写测试也能达到同样的目的”
- “关键是精神,而不是仪式”
- “留着作参考”或“基于现有代码调整”
- “已经花了 X 小时,现在删掉太浪费”
- “TDD 太教条了,我这是在务实”
- “这次情况不一样,因为……”
这些都意味着:删除代码。用 TDD 重新开始。
示例:修复 Bug
**Bug:**接受空电子邮件地址
RED
test('rejects empty email', async () => {
const result = await submitForm({ email: '' });
expect(result.error).toBe('Email required');
});
验证 RED
$ npm test
FAIL: expected 'Email required', got undefined
GREEN
function submitForm(data: FormData) {
if (!data.email?.trim()) {
return { error: 'Email required' };
}
// ...
}
验证 GREEN
$ npm test
PASS
重构 如有需要,为多个字段提取验证逻辑。
验证清单
标记工作完成前:
- 每个新函数/方法都有测试
- 实现前观察每个测试失败
- 每个测试都因预期原因失败(功能缺失,而非拼写错误)
- 编写最少的代码,让每个测试通过
- 所有测试均通过
- 输出干净(无错误、无警告)
- 测试使用真实代码(仅在无法避免时使用模拟)
- 覆盖边界情况和错误
无法勾选所有选项?说明你跳过了 TDD。重新开始。
遇到阻碍时
| 问题 | 解决方案 |
|---|---|
| 不知道如何测试 | 写出期望的 API。先写断言。询问你的人类伙伴。 |
| 测试太复杂 | 设计太复杂。简化接口。 |
| 必须模拟所有内容 | 代码耦合过紧。使用依赖注入。 |
| 测试设置过于庞大 | 提取辅助函数。还是很复杂?简化设计。 |
调试集成问题
发现 Bug?编写一个能复现问题的失败测试。遵循 TDD 循环。测试既能证明修复有效,也能防止回归。
修复 Bug 时绝不能不写测试。
最终规则
Production code → test exists and failed first
Otherwise → not TDD
未经你的人类伙伴许可,不得例外。
测试反模式
概述
测试必须验证真实行为,而不是模拟行为。模拟是用于隔离的手段,不是被测试的对象。
核心原则: 测试代码的行为,而不是模拟对象的行为。
遵循严格的 TDD 可以避免这些反模式。
铁律
1. NEVER test mock behavior
2. NEVER add test-only methods to production classes
3. NEVER mock without understanding dependencies
反模式 1:测试模拟对象的行为
违规做法:
// ❌ BAD: Testing that the mock exists
test('renders sidebar', () => {
render(<Page />);
expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();
});
错误原因:
- 你验证的是模拟对象是否正常,而不是组件是否正常
- 有模拟对象时测试通过,没有时则失败
- 无法让你了解真实行为
你的人类伙伴的纠正意见:“我们是在测试模拟对象的行为吗?”
修复方法:
// ✅ GOOD: Test real component or don't mock it
test('renders sidebar', () => {
render(<Page />); // Don't mock sidebar
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
// OR if sidebar must be mocked for isolation:
// Don't assert on the mock - test Page's behavior with sidebar present
Gate 函数
BEFORE asserting on any mock element:
Ask: "Am I testing real component behavior or just mock existence?"
IF testing mock existence:
STOP - Delete the assertion or unmock the component
Test real behavior instead
反模式 2:在生产代码中添加仅用于测试的方法
违规做法:
// ❌ BAD: destroy() only used in tests
class Session {
async destroy() { // Looks like production API!
await this._workspaceManager?.destroyWorkspace(this.id);
// ... cleanup
}
}
// In tests
afterEach(() => session.destroy());
错误原因:
- 生产类中混入了仅供测试使用的代码
- 如果在生产环境中被意外调用,会很危险
- 违反 YAGNI 和关注点分离原则
- 混淆对象生命周期与实体生命周期
修复方法:
// ✅ GOOD: Test utilities handle test cleanup
// Session has no destroy() - it's stateless in production
// In test-utils/
export async function cleanupSession(session: Session) {
const workspace = session.getWorkspaceInfo();
if (workspace) {
await workspaceManager.destroyWorkspace(workspace.id);
}
}
// In tests
afterEach(() => cleanupSession(session));
Gate 函数
BEFORE adding any method to production class:
Ask: "Is this only used by tests?"
IF yes:
STOP - Don't add it
Put it in test utilities instead
Ask: "Does this class own this resource's lifecycle?"
IF no:
STOP - Wrong class for this method
反模式 3:不了解情况就使用模拟
违规做法:
// ❌ BAD: Mock breaks test logic
test('detects duplicate server', () => {
// Mock prevents config write that test depends on!
vi.mock('ToolCatalog', () => ({
discoverAndCacheTools: vi.fn().mockResolvedValue(undefined)
}));
await addServer(config);
await addServer(config); // Should throw - but won't!
});
错误原因:
- 被模拟的方法有测试所依赖的副作用(写入配置)
- 为了“保险”而过度使用模拟会破坏实际行为
- 测试可能因错误原因通过,也可能莫名失败
修复方法:
// ✅ GOOD: Mock at correct level
test('detects duplicate server', () => {
// Mock the slow part, preserve behavior test needs
vi.mock('MCPServerManager'); // Just mock slow server startup
await addServer(config); // Config written
await addServer(config); // Duplicate detected ✓
});
Gate 函数
BEFORE mocking any method:
STOP - Don't mock yet
1. Ask: "What side effects does the real method have?"
2. Ask: "Does this test depend on any of those side effects?"
3. Ask: "Do I fully understand what this test needs?"
IF depends on side effects:
Mock at lower level (the actual slow/external operation)
OR use test doubles that preserve necessary behavior
NOT the high-level method the test depends on
IF unsure what test depends on:
Run test with real implementation FIRST
Observe what actually needs to happen
THEN add minimal mocking at the right level
Red flags:
- "I'll mock this to be safe"
- "This might be slow, better mock it"
- Mocking without understanding the dependency chain
反模式 4:模拟对象不完整
违规做法:
// ❌ BAD: Partial mock - only fields you think you need
const mockResponse = {
status: 'success',
data: { userId: '123', name: 'Alice' }
// Missing: metadata that downstream code uses
};
// Later: breaks when code accesses response.metadata.requestId
错误原因:
- 不完整的模拟对象会掩盖结构假设——你只模拟了已知的字段
- 下游代码可能依赖你未包含的字段——导致静默失败
- 测试通过但集成失败——模拟对象不完整,真实 API 却完整
- 产生虚假的信心——测试无法证明真实行为
铁律: 模拟现实中的完整数据结构,而不只是当前测试直接用到的字段。
修复方法:
// ✅ GOOD: Mirror real API completeness
const mockResponse = {
status: 'success',
data: { userId: '123', name: 'Alice' },
metadata: { requestId: 'req-789', timestamp: 1234567890 }
// All fields real API returns
};
Gate 函数
BEFORE creating mock responses:
Check: "What fields does the real API response contain?"
Actions:
1. Examine actual API response from docs/examples
2. Include ALL fields system might consume downstream
3. Verify mock matches real response schema completely
Critical:
If you're creating a mock, you must understand the ENTIRE structure
Partial mocks fail silently when code depends on omitted fields
If uncertain: Include all documented fields
反模式 5:把集成测试当作事后补充
违规做法:
✅ Implementation complete
❌ No tests written
"Ready for testing"
错误原因:
- 测试是实现的一部分,不是可有可无的后续工作
- TDD 本可以及时发现这个问题
- 没有测试,就不能声称工作已完成
修复方法:
TDD cycle:
1. Write failing test
2. Implement to pass
3. Refactor
4. THEN claim complete
模拟变得过于复杂时
警示信号:
- 模拟设置比测试逻辑还长
- 为了让测试通过而模拟一切
- 模拟对象缺少真实组件具有的方法
- 模拟对象一变,测试就会失败
你的人类伙伴会问:“这里真的需要使用模拟对象吗?”
可以考虑: 使用真实组件进行集成测试,往往比复杂的模拟对象更简单
TDD 可避免这些反模式
TDD 的益处:
- 先编写测试 → 促使你思考实际要测试什么
- 观察测试失败 → 确认测试覆盖的是真实行为,而不是模拟对象
- 最小化实现 → 不会逐渐加入仅供测试使用的方法
- 使用真实依赖项 → 在创建模拟对象之前,先了解测试实际需要什么
如果你在测试模拟对象的行为,就违背了 TDD——你在没有先观察测试针对真实代码失败的情况下就添加了模拟对象。
快速参考
| 反模式 | 修复方法 |
|---|---|
| 对模拟对象的元素进行断言 | 测试真实组件,或取消模拟 |
| 在生产代码中添加仅供测试使用的方法 | 移至测试工具中 |
| 未了解情况就创建模拟对象 | 先了解依赖项,尽可能少地创建模拟对象 |
| 不完整的模拟对象 | 完整复现真实 API |
| 事后才考虑测试 | 采用 TDD——先编写测试 |
| 过于复杂的模拟对象 | 考虑集成测试 |
危险信号
- 断言检查
*-mock测试 ID - 某些方法只在测试文件中调用
- 模拟设置占测试内容的 50% 以上
- 移除模拟对象后测试失败
- 无法解释为什么需要模拟对象
- “以防万一”而创建模拟对象
要点
模拟对象是用于隔离的工具,不是测试对象。
如果 TDD 让你发现自己在测试模拟对象的行为,那就说明方向错了。
修正方法:测试真实行为,或质疑自己为什么要创建模拟对象。