TDD 测试驱动开发
Test-Driven Development:先写测试,再写实现。 核心不是「多写测试」,而是用测试来驱动设计——在写代码之前,先明确「它应该做什么」。
一、什么是 TDD
1.1 定义
TDD 是一种开发节奏:在写产品代码之前,先写一个会失败的测试,然后写刚好能让它通过的最简代码,最后在测试的保护下重构代码结构。
1.2 红-绿-重构循环
┌──────────────────────────────┐ │ │ ▼ │ 🔴 红:写一个失败的测试 │ │ │ ▼ │ 🟢 绿:用最简代码让它通过 │ │ │ ▼ │ 🔵 重构:在测试保护下改进结构 │ │ │ └──────────────────────────────┘ (几分钟一轮)| 阶段 | 目标 | 关键纪律 |
|---|---|---|
| 🔴 红 | 确认测试确实会失败 | 先看它失败,且失败原因是预期的(不是语法错或环境问题) |
| 🟢 绿 | 让它通过,越简单越好 | 允许「丑陋但正确」,禁止顺手实现下一个需求 |
| 🔵 重构 | 消除重复、改善命名 | 全程测试必须保持绿;不改行为,只改结构 |
1.3 与传统开发的区别
| 维度 | 传统「后补测试」 | TDD |
|---|---|---|
| 顺序 | 写代码 → 想测试 | 写测试 → 写代码 |
| 测试的作用 | 验证「已完成的代码」 | 驱动设计、约束接口 |
| 接口设计 | 写完才发现难测 | 先写测试时就被迫想清楚调用方式 |
| 覆盖率 | 通常只覆盖 happy path | 天然覆盖边界与异常 |
| 重构信心 | 低(改完不知道坏没坏) | 高(测试即安全网) |
| 调试成本 | 出问题靠打日志猜 | 定位到具体断言 |
1.4 最容易被误解的一点
TDD 的第一价值不是「保证质量」,而是迫使你先想清楚接口和边界。
写测试时你会立刻遇到这些问题:
- 这个方法该叫什么名字?参数是什么?
- 空输入怎么办?异常怎么抛?
- 返回结构是什么?
这些问题在写代码前想清楚,比写完再改便宜得多。
二、核心流程详解
2.1 红灯阶段
动作:写一个针对「下一个最小行为」的测试要求: 1. 测试代码要能编译/运行(不是伪代码) 2. 测试必须失败 3. 失败原因必须是「功能未实现」,而不是「测试写错了」反例(无效红灯):
// 测试本身有语法错误 → 失败原因是语法错,不是功能缺失 → 无效test('计算折扣', () => { expect(calculateDiscount(100).toBe(10) // 少了右括号})验证技巧:写完测试先跑一遍,看报错是否符合预期。若是 TypeError: calculateDiscount is not a function —— 这是有效的红灯;若是 SyntaxError —— 是测试自己写错了。
2.2 绿灯阶段
动作:写刚好能让当前失败测试通过的最小实现禁止: ✗ 顺手实现下一个需求 ✗ 提前优化性能 ✗ 过度抽象允许: ✓ 硬编码返回值(是的,先硬编码) ✓ 丑陋的实现为什么要允许硬编码?因为下一个测试会逼你把它变通用。这是 TDD 里最重要的技巧:让测试来驱动抽象,而不是提前设计抽象。
// 第 1 轮绿灯(可以就这么"丑陋")function calculateDiscount(amount) { return 0 // 唯一通过的测试是「0 元无折扣」}// 第 2 轮测试加入后,被迫变成真实逻辑function calculateDiscount(amount) { if (amount >= 200) return 30 if (amount >= 100) return 10 return 0}2.3 重构阶段
动作:在测试全绿的前提下改善代码允许改:命名、结构、抽取函数、消除重复禁止改:对外行为(测试不应需要修改)判断标准:如果重构导致测试需要修改,说明你改动了行为——那不是重构,是功能变更。
2.4 循环节奏
| 粒度 | 单轮耗时 | 说明 |
|---|---|---|
| 正常 | 2–10 分钟 | 一个断言级的小行为 |
| 过长 | > 30 分钟 | 说明这一轮切得太大,应拆小 |
| 过短 | < 30 秒 | 可能在做纯格式化改动,不需要 TDD |
三、TDD 三条定律
Robert C. Martin(Uncle Bob)提出的经典约束:
| # | 定律 |
|---|---|
| 1 | 不允许写任何产品代码,除非是为了让一个失败的测试通过 |
| 2 | 不允许写多于一个的失败测试 |
| 3 | 不允许写多于刚好让当前失败测试通过的产品代码 |
三条定律的作用
- 第 1 条:杜绝「先写代码再补测试」
- 第 2 条:保证每一轮足够小,不会一次测试七八个行为
- 第 3 条:杜绝过度实现,防止代码里出现「暂时没人用的逻辑」
现实中的弹性执行
三条定律是训练纪律,不是教条。实际项目中合理的偏离:
| 场景 | 合理偏离 |
|---|---|
| 探针式验证(不熟悉的技术方案) | 先写几十行 spike 代码探索,探索完丢弃,再正式 TDD |
| 纯 UI 布局调整 | 不强求 TDD,靠视觉验收或 E2E |
| 数据迁移脚本 | 用一次性校验脚本而非单测 |
| 学习新库的用法 | 可以先跑官方示例 |
判断原则:逻辑复杂度高、易出错、会被反复修改的代码,值得 TDD;一次性、纯展示、无分支的代码,收益有限。
四、测试金字塔
╱╲ E2E(端到端) ~5% 慢、脆、成本高 ╱ ╲ ╱────╲ 集成测试 ~15% 中等 ╱ ╲ ╱────────╲ 单元测试 ~80% 快、稳、TDD 主战场 ╱__________╲| 层级 | 测什么 | 速度 | 稳定性 | TDD 适用度 |
|---|---|---|---|---|
| 单元测试 | 单个函数/类的行为 | 毫秒级 | 高 | ★★★★★ 主力 |
| 集成测试 | 模块间协作(DB、HTTP、消息) | 百毫秒–秒级 | 中 | ★★★☆☆ 部分 |
| E2E | 用户视角的完整流程 | 秒–分钟级 | 低 | ★☆☆☆☆ 少用 |
关键认知
- TDD 主要作用在单元层。集成与 E2E 更多是「补验证」,不追求 TDD 循环。
- 测试越多越好是错的。大量脆弱的 E2E 会拖垮 CI,维护成本反噬开发效率。
- 倒金字塔是反模式:E2E 为主、单元为空 —— 特征是「测试跑得很慢、经常莫名其妙失败、没人愿意改」。
五、实战示例:优惠券折扣计算
用一个真实业务场景完整走 6 轮红绿重构。规则如下:
折扣规则:- 订单金额 < 100:无折扣- 100 ≤ 金额 < 200:减 10- 金额 ≥ 200:减 30- VIP 用户:在满减基础上再打 95 折- 折后金额不低于 0第 1 轮:零金额返回 0
🔴 红灯(JavaScript / Jest)
// discount.test.jsconst { calculateDiscount } = require('./discount')describe('calculateDiscount', () => { test('零金额订单返回 0 折扣', () => { expect(calculateDiscount(0)).toBe(0) })})运行失败:TypeError: calculateDiscount is not a function ✅ 有效红灯。
🟢 绿灯(最简实现)
// discount.jsfunction calculateDiscount(amount) { return 0}module.exports = { calculateDiscount }🔵 重构:无需重构。
第 2 轮:不满 100 无折扣
🔴 红灯
test('金额 99 时无折扣', () => { expect(calculateDiscount(99)).toBe(0)})注意:这个测试当场就通过了(因为上一轮硬编码 return 0)。 这没关系——它的价值是锁住行为,防止后续实现把 99 也算成满减。
🟢 绿灯:无需改动。
第 3 轮:满 100 减 10
🔴 红灯
test('金额 100 时减 10', () => { expect(calculateDiscount(100)).toBe(10)})test('金额 150 时减 10', () => { expect(calculateDiscount(150)).toBe(10)})🟢 绿灯
function calculateDiscount(amount) { if (amount >= 100) return 10 return 0}第 4 轮:满 200 减 30(边界驱动)
🔴 红灯——重点测边界,不是测中心值:
test('金额 199.99 时仍减 10', () => { expect(calculateDiscount(199.99)).toBe(10)})test('金额 200 时减 30', () => { expect(calculateDiscount(200)).toBe(30)})🟢 绿灯
function calculateDiscount(amount) { if (amount >= 200) return 30 if (amount >= 100) return 10 return 0}测试顺序很重要:先写 200 的测试会让实现变成
if (amount >= 200), 再写 100 的测试才补上第二档。若反过来,容易写出嵌套判断。
第 5 轮:VIP 额外 95 折
🔴 红灯
test('VIP 用户满 200 时:先减 30 再打 95 折', () => { // (200 - 30) * 0.95 = 161.5 expect(calculateDiscount(200, { vip: true })).toBe(38.5)})🟢 绿灯
function calculateDiscount(amount, options = {}) { let discount = 0 if (amount >= 200) discount = 30 else if (amount >= 100) discount = 10 if (options.vip) { // 满减后再打 95 折,等价于折扣额增加 5% discount = amount - (amount - discount) * 0.95 } return Math.round(discount * 100) / 100 // 避免浮点误差}🔵 重构:把「满减规则」抽成独立函数,消除 if 链:
function baseDiscount(amount) { if (amount >= 200) return 30 if (amount >= 100) return 10 return 0}function calculateDiscount(amount, options = {}) { const base = baseDiscount(amount) const final = options.vip ? (amount - base) * 0.95 : amount - base return Math.round((amount - final) * 100) / 100}重构后再跑一遍所有测试,全绿才继续。
第 6 轮:折后金额不低于 0
🔴 红灯
test('极小额订单折扣不为负', () => { expect(calculateDiscount(0, { vip: true })).toBe(0)})test('折扣额永不超过订单金额', () => { const discount = calculateDiscount(50, { vip: true }) expect(discount).toBeGreaterThanOrEqual(0) expect(discount).toBeLessThanOrEqual(50)})🟢 绿灯
function calculateDiscount(amount, options = {}) { if (amount <= 0) return 0 const base = baseDiscount(amount) const final = options.vip ? (amount - base) * 0.95 : amount - base const diff = Math.round((amount - final) * 100) / 100 return Math.max(0, Math.min(diff, amount))}最终测试文件全貌
describe('calculateDiscount', () => { describe('基础满减档位', () => { test('零金额返回 0', () => expect(calculateDiscount(0)).toBe(0)) test('99 元无折扣', () => expect(calculateDiscount(99)).toBe(0)) test('100 元减 10', () => expect(calculateDiscount(100)).toBe(10)) test('150 元减 10', () => expect(calculateDiscount(150)).toBe(10)) test('199.99 元仍减 10', () => expect(calculateDiscount(199.99)).toBe(10)) test('200 元减 30', () => expect(calculateDiscount(200)).toBe(30)) }) describe('VIP 折扣', () => { test('VIP 满 200:38.5', () => expect(calculateDiscount(200, { vip: true })).toBe(38.5)) test('VIP 未满 100:5% 优惠', () => { // 50 * 0.05 = 2.5 expect(calculateDiscount(50, { vip: true })).toBe(2.5) }) }) describe('边界保护', () => { test('零金额 VIP 不为负', () => expect(calculateDiscount(0, { vip: true })).toBe(0)) test('负数金额返回 0', () => expect(calculateDiscount(-10)).toBe(0)) })})这个示例体现的 TDD 特征
| 现象 | TDD 中的意义 |
|---|---|
先硬编码 return 0 |
绿灯阶段允许最简实现 |
| 第 4 轮先写 199.99 再写 200 | 边界测试驱动出正确的判断顺序 |
第 5 轮才发现需要 options 参数 |
接口由需求逐步演化,而非提前设计 |
| 重构时测试一行不改 | 重构的判据就是「测试不需要动」 |
六、多语言对照
同样一轮 TDD(测「满 100 减 10」),三种语言的写法:
JavaScript(Jest / Vitest)
test('金额 100 时减 10', () => { expect(calculateDiscount(100)).toBe(10)})Java(JUnit 5)
@Test@DisplayName("金额 100 时减 10")void shouldReturn10ForAmount100() { assertEquals(10, DiscountCalculator.calculate(100));}PHP(PHPUnit)
public function testAmount100Returns10(): void{ $this->assertSame(10, DiscountCalculator::calculate(100));}框架速查
| 语言 | 主流程框架 | 断言 / Mock | 生命周期 |
|---|---|---|---|
| JavaScript | Jest / Vitest / Mocha | 内置 expect / jest.fn() |
beforeEach / afterAll |
| Java | JUnit 5 | AssertJ / Mockito | @BeforeEach / @AfterAll |
| PHP | PHPUnit / Pest | 内置断言 / Mockery | setUp() / tearDown() |
| Python | Pytest | 内置 assert / unittest.mock |
fixture |
常用断言对照
| 语义 | Jest | JUnit 5 | PHPUnit |
|---|---|---|---|
| 相等 | toBe(10) |
assertEquals(10, x) |
assertSame(10, $x) |
| 近似相等 | toBeCloseTo(38.5) |
assertEquals(38.5, x, 0.01) |
assertEqualsWithDelta() |
| 真值 | toBeTruthy() |
assertTrue(x) |
assertTrue($x) |
| 抛异常 | toThrow(Error) |
assertThrows(...) |
expectException(...) |
| 集合包含 | toContain(x) |
assertThat(list).contains(x) |
assertContains($x, $arr) |
| 空值 | toBeNull() |
assertNull(x) |
assertNull($x) |
七、测试替身(Test Doubles)
7.1 五种替身
| 类型 | 作用 | 类比 |
|---|---|---|
| Dummy | 仅占位,从不使用 | 会议里凑人数的椅子 |
| Stub | 返回固定值,不做验证 | 自动回复机器人 |
| Spy | 记录调用情况(参数、次数) | 录音笔 |
| Mock | 预设期望,验证交互是否发生 | 验收员 |
| Fake | 有真实逻辑的简化实现(如内存数据库) | 模拟驾驶舱 |
7.2 什么时候该用替身
应该替身的外部依赖:
| 依赖 | 原因 |
|---|---|
| 数据库 | 慢、需环境、状态互相污染 |
| HTTP 第三方接口 | 不稳定、要花钱、结果不可控 |
| 当前时间 | 不可重现(new Date() 每次不同) |
| 随机数 / UUID | 结果不可预测 |
| 文件系统 / 消息队列 | 慢、需清理 |
不应该替身的:
| 对象 | 原因 |
|---|---|
| 被测代码自己的业务逻辑 | mock 掉它等于测试没测任何东西 |
| 纯函数工具类 | 直接调用更快更真实 |
7.3 时间与随机数:注入而非 mock
反面写法(难测):
function isExpired(coupon) { return coupon.expireAt < new Date() // 依赖系统时钟,测试无法固定}正确写法(注入时钟):
function isExpired(coupon, now = new Date()) { return coupon.expireAt < now}// 测试test('过期优惠券', () => { const coupon = { expireAt: new Date('2026-01-01') } expect(isExpired(coupon, new Date('2026-06-01'))).toBe(true)})依赖注入 > Mock。可注入的参数比 mock 全局对象更清晰、更稳定。
7.4 各语言 Mock 示例
JavaScript(Jest)
jest.mock('./mailer')const { sendMail } = require('./mailer')test('注册成功后发送邮件', async () => { sendMail.mockResolvedValue({ ok: true }) await register({ email: 'a@b.com' }) expect(sendMail).toHaveBeenCalledWith( expect.objectContaining({ to: 'a@b.com' }) )})Java(Mockito)
@ExtendWith(MockitoExtension.class)class UserServiceTest { @Mock MailSender mailSender; @InjectMocks UserService userService; @Test void shouldSendMailAfterRegister() { userService.register("a@b.com"); verify(mailSender).send(argThat(m -> m.getTo().equals("a@b.com"))); }}PHP(PHPUnit + Mockery)
public function testSendsMailAfterRegister(): void{ $mailer = Mockery::mock(Mailer::class); $mailer->shouldReceive('send')->once()->with('a@b.com'); $service = new UserService($mailer); $service->register('a@b.com');}八、测试的命名与结构
8.1 AAA 三段式
每个测试内部按三段组织,用空行分隔:
test('VIP 用户满 200 减 30 后打 95 折', () => { // Arrange(准备) const amount = 200 const options = { vip: true } // Act(执行) const discount = calculateDiscount(amount, options) // Assert(断言) expect(discount).toBe(38.5)})8.2 命名规范
好的测试名应描述行为,而非描述实现:
| 好的命名 | 坏的命名 |
|---|---|
金额为 200 时减 30 |
测试 calculateDiscount 函数的第二个 if |
用户名重复时抛出业务异常 |
测试第 45 行 |
未登录用户访问返回 401 |
测试守卫 |
推荐格式:当<条件>时,应<预期结果>
describe('优惠券应用', () => { test('当订单金额为 0 时,折扣为 0', ...) test('当用户是 VIP 且金额满 200 时,折扣为 38.5', ...)})8.3 目录组织
src/├── services/│ ├── discount.js│ └── discount.test.js ← 同目录(推荐,就近维护)└── utils/ ├── date.js └── date.test.js或集中式(大型项目):
tests/├── unit/│ └── services/discount.test.js├── integration/└── e2e/单元测试推荐与源码同目录——改代码时测试就在旁边,不易遗漏。
九、五个常见误区
误区 1:测实现,而非行为
// ❌ 测了内部实现:一旦重构(改用 Map)就失败,但行为没变test('使用数组存储', () => { const store = new UserStore() expect(store._users).toBeInstanceOf(Array)})// ✅ 测行为:重构后仍然通过test('添加用户后可以按 id 查到', () => { const store = new UserStore() store.add({ id: 1, name: 'A' }) expect(store.getById(1).name).toBe('A')})判据:如果重构(不改行为)导致测试失败,说明测的是实现。
误区 2:过度 mock
// ❌ 全部 mock:这个测试什么都没验证jest.mock('./validator')jest.mock('./repository')test('创建订单', async () => { validator.validate.mockReturnValue(true) repository.save.mockResolvedValue({ id: 1 }) const result = await createOrder({}) expect(result.id).toBe(1) // 只是在验证 mock 的返回值})判据:mock 的数量超过断言的数量,通常说明测错了东西。
误区 3:测试脆弱
| 脆弱表现 | 原因 | 修复 |
|---|---|---|
| 单独跑通过、全量跑失败 | 测试间共享状态 | 每个测试独立初始化数据 |
| 换个机器就失败 | 依赖本地时区 / 路径 / 网络 | 固定时区、用相对路径、禁网 |
| 随机失败 | 依赖执行顺序、并发、时间 | 注入时钟,避免顺序依赖 |
| 断言字符串完全相等 | 输出格式微调就挂 | 断言关键字段,用 toContain / objectContaining |
误区 4:只测 happy path
一个成熟测试套件应覆盖:
- ✅ 正常路径(happy path)
- ✅ 边界值(0、负数、最大值、临界点 99/100/199.99/200)
- ✅ 异常路径(空值、类型错误、权限不足)
- ✅ 状态异常(重复提交、并发冲突、资源不存在)
误区 5:追求 100% 覆盖率
覆盖率是发现遗漏的工具,不是质量指标。
| 覆盖率 | 真实情况可能 |
|---|---|
| 100% | 每行都执行到了,但没有任何断言(expect 都没写) |
| 80% | 核心逻辑覆盖,边界清晰 |
| 40% | 只测了 happy path |
建议:核心业务逻辑追求 80%+;配置类、纯 UI、生成代码不强求。不要为了数字写无断言的测试。
十、TDD vs BDD vs SDD
| 实践 | 层次 | 核心 | 产物 |
|---|---|---|---|
| SDD | 需求 | 规格先行,约束解空间 | spec.md / plan.md / tasks.md |
| BDD | 行为 | 用业务语言描述场景 | Gherkin(Given-When-Then) |
| TDD | 代码 | 测试先行,驱动实现 | 单元测试代码 |
如何组合
SDD:定边界 —— 要做什么、验收标准、错误码 ↓BDD:定场景 —— 用户视角的关键路径(可选,多角色协作时有用) ↓TDD:定实现 —— 函数级红绿重构循环| 层级 | 我该用哪个 |
|---|---|
| 新功能(跨多文件/跨端) | SDD 先出规格 |
| 需求需与产品/业务对齐 | 中间加 BDD |
| 具体函数、算法、边界逻辑 | TDD 驱动实现 |
十一、老项目如何引入 TDD
从 0 覆盖率的遗留项目直接上 TDD 是不现实的。推荐渐进路径:
步骤 1:先写「特征测试」锁住现状
对即将改动的老代码,先写测试记录它当前的实际行为(哪怕这行为看起来是 bug):
// 现状可能是错的,但先锁住,避免重构时意外改变行为test('[特征测试] 购物车满减当前实现:99 元不优惠', () => { expect(legacyCart.total(99)).toBe(99)})步骤 2:只在改动时补测试(童子军原则)
离开营地时,让它比你来时更干净。
- 不改动的老代码:暂不写测试
- 要改动的老代码:先补测试锁行为,再动手改
- 新写的代码:一律 TDD
步骤 3:优先补关键路径
| 优先级 | 模块 |
|---|---|
| 高 | 支付、订单、金额计算、权限 |
| 中 | 用户注册登录、核心业务流程 |
| 低 | 展示型接口、配置读取、后台管理 |
步骤 4:把测试纳入 CI
# 示例:CI 中强制测试通过 + 覆盖率不下降- run: npm test -- --coverage- run: npx coverage-check --min 70 # 门槛设在当前值,只许升不许降覆盖率门槛设在当前值,而不是一步到位 80%。让团队先习惯「只能升不能降」。
渐进路径总表
| 阶段 | 目标 | 时间 |
|---|---|---|
| 1 | CI 跑通全部现有测试 | 第 1 周 |
| 2 | 关键路径补特征测试 | 第 2-4 周 |
| 3 | 新代码强制 TDD | 持续 |
| 4 | 覆盖率门槛逐步抬升 | 每月 +5% |
| 5 | 重构老模块时补测试 | 常态化 |
十二、常见问题(FAQ)
Q1:没时间写测试怎么办?
时间成本是前置的,收益是后置的。真实账目:
| 项 | 后补测试 | TDD |
|---|---|---|
| 写功能耗时 | 快 20% | 慢 20%(多花在写测试) |
| 调试耗时 | 多 50%+ | 少(断言直接定位) |
| 回归 Bug | 多 | 少 |
| 重构信心 | 低 | 高 |
短期看 TDD 慢,一个月的项目周期内通常持平,三个月以上明显划算。项目越长期、需求变更越频繁,TDD 收益越大。
Q2:前端怎么 TDD?
分类型处理:
| 代码类型 | 建议 |
|---|---|
| 纯函数 / 工具函数 | ✅ 标准 TDD |
| 状态管理(Pinia/Redux store) | ✅ 测 action 与 getter |
| 组件逻辑(props → 渲染) | ⚠️ 用 Testing Library 测用户可见行为,不测 DOM 结构 |
| 样式 / 布局 | ❌ 不 TDD,靠视觉回归或人工验收 |
| 交互流程 | ⚠️ 用 E2E(Playwright)覆盖关键路径 |
Q3:测试代码也要维护,成本不是更高吗?
测试维护成本主要来自脆弱测试,而非测试本身。减少成本的做法:
- 测行为不测实现(重构不用改测试)
- 少用 mock,多用依赖注入
- 断言关键字段,不做全字符串比对
- 一个测试只断言一件事
Q4:UI 和数据库相关代码,TDD 起来很别扭?
因为它们属于集成层,不适合严格的 TDD 循环。做法是:业务逻辑抽到纯函数/服务层做 TDD,集成层用少量集成测试兜底。这也是 TDD 推动良好架构(逻辑与 IO 分离)的体现。
Q5:团队没人写测试,怎么推动?
从三件事入手:
- 不要求全量:只要求「新代码 + 改动的老代码」有测试
- CI 卡口:测试不过不许合并(比口头要求有效得多)
- 用 Bug 反推:每出现一个线上 Bug,先补一个复现该 Bug 的测试,再修
Q6:测试要写到多细?
| 粒度 | 建议 |
|---|---|
| 每个 public 方法 | ✅ 至少 happy path + 一个异常路径 |
| 每个分支 | ✅ 边界值必须覆盖 |
| 每个私有方法 | ❌ 不单独测,通过 public 接口间接覆盖 |
| 每行代码 | ❌ 不为覆盖率而测 |
十三、最佳实践清单
写测试时
- 测试名描述行为,不是描述实现
- 一个测试只验证一个行为(失败时能一眼定位)
- 遵循 AAA 三段式,段间空行分隔
- 覆盖边界值(0、负数、临界点)与异常路径
- 断言关键字段,避免全量字符串比对
设计时
- 业务逻辑与 IO 分离(便于单测)
- 时间、随机数通过参数注入,而非 mock 全局
- 只 mock 外部依赖,不 mock 自己的业务逻辑
- 函数尽量纯(同输入同输出)
维护时
- 重构导致测试失败 = 测试测错了实现,应改测试
- 每个线上 Bug 先补复现测试,再修
- 测试单独跑与全量跑结果一致(无共享状态)
- 测试执行快(单元测试整体 < 10 秒)
落地时
- CI 强制测试通过才能合并
- 覆盖率门槛设在当前值,只升不降
- 老代码只在改动时补测试(童子军原则)
- 优先覆盖高价值模块(支付、权限、金额)
十四、总结
TDD 的本质不是「测试」,而是一种控制节奏的开发方式:
- 红-绿-重构是唯一必须掌握的循环——先看它失败,再让它通过,最后在安全网下改结构。
- 测试驱动的是设计,不只是验证。写测试的过程逼你想清楚接口、边界和异常。
- TDD 不是全项目通吃的——它是逻辑密集型代码的工具:支付、金额、权限、算法值得;纯 UI、一次性脚本不值得。
- 覆盖率不是目标,行为覆盖才是。100% 覆盖率可能一行断言都没有。
与 SDD 的关系可以这样理解:
SDD 保证「做对的事」(规格与验收标准)TDD 保证「事做对了」(每一段逻辑都有测试兜底)两者结合,就是你在 AI 协作开发中最需要的那层确定性:规格说清楚要什么,测试证明真的做到了。
完