关于 test-driven-development
面向软件开发任务的 TDD 技能,要求先编写并观察测试失败,再实现最小改动并重构。它通过逐项循环、运行模式纪律和验证清单,规范测试优先的开发过程。
主要功能
- 01遵循 Red-Green-Refactor 循环
- 02每次处理一个行为测试
- 03要求确认测试先失败再通过
- 04规范测试运行模式与进程清理
- 05提供测试设计和模拟指南
- 06包含覆盖率与完成检查清单
适用场景
- 01为新功能先建立行为测试
- 02修复缺陷并验证回归
- 03重构代码时逐步保持测试通过
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ romiluz13/ cc10x --skill test-driven-development安装后在 Claude Code 中输入/test-driven-development确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/integration-and-live-proof.mdtest-data-and-mocks.mdtesting-patterns.md详细介绍
测试驱动开发(TDD)
概述
先写测试。观察它失败。编写最少的代码使其通过。
核心原则: 如果你没有亲眼看到测试失败,就不知道它是否测试了正确的内容。
违反规则的字面要求,就是违背规则的精神。
参考文件
只阅读当前测试周期所需的参考资料:
references/testing-patterns.md,了解命名、AAA 结构、近似边界的负面测试、行为关注点和反模式检查references/test-data-and-mocks.md,了解工厂函数、模拟边界、常见边界模拟,以及环境变量和时间的处理references/integration-and-live-proof.md,当单元测试不足,或计划要求使用真实 API、种子数据、浏览器流程或压力验证时阅读
何时使用
始终适用:
- 新功能
- Bug 修复
- 重构
- 行为变更
例外情况(请咨询你的人工搭档):
- 一次性原型
- 生成的代码
- 配置文件
想“这次就跳过 TDD”?停下。这是在为自己找借口。
铁律
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
先写代码再写测试?删掉它。从头开始。
没有例外:
- 不要把它留作“参考”
- 写测试时不要“改编”它
- 不要看它
- 删除就是彻底删除
根据测试重新实现。就这样。
测试流程纪律(关键)
问题: 测试运行器(Vitest、Jest)默认启用监听模式,导致进程无限期挂起。
强制规则:
- 始终使用运行模式——绝不调用监听模式:
- Vitest:
npx vitest run(不要用npx vitest) - Jest:
CI=true npx jest或npx jest --watchAll=false - npm 脚本:
CI=true npm test或npm test -- --run
- Vitest:
- 所有测试命令优先使用
CI=true前缀:CI=true npm test - TDD 周期完成后,确认没有孤立进程:
pgrep -f "vitest|jest" || echo "Clean" - 如发现进程,则将其终止:
pkill -f "vitest" 2>/dev/null || true
红—绿—重构
┌─────────┐ ┌─────────┐ ┌───────────┐
│ RED │──────>│ GREEN │──────>│ REFACTOR │
│ (Fail) │ │ (Pass) │ │ (Clean) │
└─────────┘ └─────────┘ └───────────┘
^ │
│ │
└────────────────────────────────────┘
Next Feature
垂直切片(关键)
WRONG (horizontal — all tests then all code):
RED: test1, test2, test3, test4, test5
GREEN: impl1, impl2, impl3, impl4, impl5
RIGHT (vertical — one feature at a time):
RED->GREEN: test1->impl1
RED->GREEN: test2->impl2
RED->GREEN: test3->impl3
不要先写完所有测试,再写完所有实现。 这样会产生糟糕的测试:
- 批量编写的测试验证的是_想象中的_行为,而不是_实际的_行为
- 最终测试的是事物的_形式_,而不是面向用户的行为
- 测试会对真实变化失去敏感性——行为出问题时仍会通过,行为正常时反而失败
- 你会超前于实际进展,在理解实现之前就确定测试结构
正确做法: 一个测试 → 一个实现 → 重复。每个测试都应回应你从上一个周期中学到的内容。
RED - 编写失败的测试
编写一个最精简的测试,展示应该发生什么。
良好示例:
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);
});
名称清晰,测试真实行为,只测试一件事
不良示例:
test('retry works', async () => {
const mock = jest.fn()
.mockRejectedValueOnce(new Error())
.mockRejectedValueOnce(new Error())
.mockResolvedValueOnce('success');
await retryOperation(mock);
expect(mock).toHaveBeenCalledTimes(3);
});
名称含糊,测试的是模拟对象而非代码
要求:
- 一个行为
- 名称清晰
- 真实代码(除非无法避免,否则不使用模拟)
验证 RED - 观察它失败
必须执行。绝不能跳过。
CI=true npm test path/to/test.test.ts
确认:
- 测试失败(不是报错)
- 失败信息符合预期
- 失败原因是功能缺失(而非拼写错误)
测试通过了? 你测试的是已有行为。修正测试。
测试报错了? 修复错误,重新运行,直到它正确地失败。
GREEN - 编写最少的代码
编写能让测试通过的最简单代码。
良好示例:
async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
for (let i = 0; i < 3; i++) {
try {
return await fn();
} catch (e) {
if (i === 2) throw e;
}
}
throw new Error('unreachable');
}
只写足以通过测试的代码
不良示例:
async function retryOperation<T>(
fn: () => Promise<T>,
options?: {
maxRetries?: number;
backoff?: 'linear' | 'exponential';
onRetry?: (attempt: number) => void;
}
): Promise<T> {
// YAGNI - You Ain't Gonna Need It
}
过度设计
不要添加功能、重构其他代码,或超出测试范围进行“改进”。不要硬编码测试值——应实现适用于_所有_输入的通用逻辑。
验证 GREEN - 观察它通过
必须执行。
CI=true npm test path/to/test.test.ts
确认:
- 测试通过
- 其他测试仍然通过
- 输出干净(没有错误或警告)
测试失败? 修复代码,不要改测试。
其他测试失败? 立即修复。
REFACTOR - 清理
只有通过后才能进行:
- 消除重复
- 改进命名
- 提取辅助函数
保持测试通过。不要添加行为。
重复
为下一个功能编写下一个失败的测试。
优秀测试
| 质量 | 好 | 差 |
|---|---|---|
| 精简 | 聚焦一件事。名称里有「和」?拆分它。 | test('validates email and domain and whitespace') |
| 清晰 | 名称描述行为 | test('test1') |
| 体现意图 | 展示期望的 API | 让代码应该做什么变得模糊 |
如需深入了解测试结构、容易混淆的反向测试、行为与内部实现的区别,以及异味检查,请阅读 references/testing-patterns.md。
有关工厂、模拟对象以及环境变量和时间的处理,请阅读
references/test-data-and-mocks.md。
为什么顺序很重要
「我会在之后编写测试来验证它是否可用」
在代码之后编写的测试会立即通过。立即通过并不能证明任何事:
- 可能测试了错误的内容
- 可能测试的是实现,而非行为
- 可能遗漏了你忘记的边缘情况
- 你从未见过测试捕获这个错误
先编写测试,迫使你看到测试失败,从而证明它确实在测试某些内容。
「我已经手动测试过所有边缘情况了」
手动测试是临时性的。你以为自己测了所有情况,但:
- 没有测试记录
- 代码变更后无法重新运行
- 赶时间时很容易忘记某些情况
- 「我试过了,能用」≠ 全面测试
自动化测试是系统性的。每次运行的方式都相同。
「删除 X 小时的工作太浪费了」
这就是沉没成本谬误。时间已经花出去了。现在你有两个选择:
- 删除并用 TDD 重写(再花 X 小时,信心更足)
- 保留代码,之后再补测试(30 分钟,信心不足,还可能有漏洞)
真正的「浪费」是保留你无法信任的代码。没有真正测试的可用代码就是技术债务。
危险信号——停止并重新开始
如果你发现自己:
- 先写代码再写测试
- 实现后再写测试
- 测试立即通过
- 无法解释测试为什么失败
- 「之后」再添加测试
- 为「就这一次」找借口
- 「我已经手动测试过了」
- 「之后再写测试也能达到相同目的」
- 「重在精神,不在形式」
- 「保留作参考」或「改编现有代码」
- 「已经花了 X 小时,删除太浪费了」
- 「TDD 太教条了,我是在务实行事」
- 「这次情况不同,因为……」
以上任何一种情况都意味着:删除代码。用 TDD 重新开始。
防止自我辩解
| 借口 | 事实 |
|---|---|
| 「太简单,不值得测试」 | 简单代码也会出错。测试只需 30 秒。 |
| 「我之后再测」 | 测试立即通过并不能证明任何事。 |
| 「之后再写测试也能达到相同目的」 | 先写测试 =「这应该做什么?」之后写测试 =「这做了什么?」 |
| 「已经手动测试过了」 | 临时测试 ≠ 系统测试。没有记录,也无法重新运行。 |
| 「删除 X 小时的工作太浪费了」 | 沉没成本谬误。保留未经验证的代码就是技术债务。 |
| 「保留作参考,先写测试」 | 你会去改编它。这就是事后测试。删除就是删除。 |
| 「我得先探索一下」 | 没问题。丢弃探索代码,从 TDD 开始。 |
| 「测试很难 = 设计不清晰」 | 听听测试的反馈。难以测试 = 难以使用。 |
| 「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
重构 如有需要,为多个字段提取验证逻辑。
验证清单
标记工作完成前:
- 每个新增函数/方法都有测试
- 实现之前,亲眼看到每个测试失败
- 每个测试都因预期原因失败(缺少功能,而非拼写错误)
- 编写了能通过每个测试的最少代码
- 所有测试均通过
- 输出干净(没有错误或警告)
- 测试使用真实代码(仅在无法避免时使用模拟对象)
- 已覆盖边缘情况和错误
- 没有挂起的测试进程(pgrep -f "vitest|jest" 返回空结果)
无法勾选所有项?说明你跳过了 TDD。重新开始。
集成与在线验证
当已接受的计划或风险级别超出本地行为范围时,请阅读
references/integration-and-live-proof.md。
如果任务依赖以下内容,单元测试就不够了:
- 真实 API 调用
- 已预置或可重置的数据
- 浏览器或工作进程编排
- 跨服务副作用
- 负载或压力行为
在这些情况下,内环仍采用 TDD,并提高外层验证的深度。
覆盖率阈值(项目默认值)
目标:代码覆盖率达到 80% 以上,包括:
- 分支:80%
- 函数:80%
- 行:80%
- 语句:80%
验证方式: npm run test:coverage 或等效命令。
未达到阈值? 在声称完成之前,补充缺失的测试。
测试优先级
达到 80% 覆盖率需要有意识地确定测试优先级。优先投入精力测试:
- 面向用户的关键路径(身份验证、支付、数据完整性)
- 包含多个分支的复杂逻辑
- 曾经出过问题的代码(容易回归的区域)
不要因为代码“看起来简单”就跳过测试——简单的代码也会出错。80% 是底线,不是上限。
遇到阻碍时
| 问题 | 解决方案 |
|---|---|
| 不知道怎么测试 | 编写期望的 API。先写断言。询问你的人工伙伴。 |
| 测试太复杂 | 设计太复杂。简化接口。 |
| 必须模拟所有内容 | 代码耦合度太高。使用依赖注入。 |
| 测试设置过于庞大 | 提取辅助函数。还是太复杂?简化设计。 |
为可测试性而设计(测试难以编写时)
如果测试难以编写,就需要改进接口:
-
接收依赖,不要自行创建依赖
- 易于测试:
function processOrder(order, paymentGateway) {} - 难以测试:
function processOrder(order) { const gw = new StripeGateway(); }
- 易于测试:
-
返回结果,不要产生副作用
- 易于测试:
function calculateDiscount(cart): Discount {} - 难以测试:
function applyDiscount(cart): void { cart.total -= discount; }
- 易于测试:
-
缩小表面积——方法越少,需要的测试就越少;参数越少,设置就越简单
关注行为
测试对象如何协作,而不是它们包含什么。如果测试检查 .state、.length 或私有字段,那它测试的就是结构——当内部实现改变而行为不变时,这类测试就会失效。
| 测试目标 | 正确 | 错误 |
|---|---|---|
| 函数输出 | expect(calculate(input)).toBe(result) | expect(calculator.internalCache).toContain(...) |
| 组件行为 | expect(screen.getByText('Saved')).toBeTruthy() | expect(component.state.saved).toBe(true) |
| 服务交互 | expect(response.status).toBe(201) | expect(service.callCount).toBe(1) |
这已经隐含在“测试实现细节”这一测试异味中。应将其作为默认视角:每个断言都应该回答“用户/调用方观察到了什么?”,而不是“内部发生了什么?”
Agent 之间的测试契约
当 CC10x 将工作分配给多个 Agent(规划者编写测试规范、构建者实现、审查者验证)时,测试文件就是契约:
- 规划者在计划中通过测试名称和断言定义预期行为
- 构建者先编写测试,再实现功能直至测试通过——测试文件证明契约已履行
- 审查者重新运行相同的测试——通过表示契约已履行,失败表示契约已被破坏
不要用文字重复描述契约。如果测试文件已经表达了要求,那么测试文件就是要求本身。
输出格式
## TDD Cycle
### Requirements
[What functionality is being built]
### RED Phase
- Test: [test name]
- Command: `npm test -- --grep "test name"`
- Result: exit 1 (FAIL as expected)
- Failure reason: [function not defined / expected X got Y]
### GREEN Phase
- Implementation: [summary]
- File: [path:line]
- Command: `npm test -- --grep "test name"`
- Result: exit 0 (PASS)
### REFACTOR Phase
- Changes: [what was improved]
- Command: `npm test`
- Result: exit 0 (all tests pass)
最终规则
Production code → test exists and failed first
Otherwise → not TDD
未经你的人工伙伴许可,不得例外。