阿明第一天进“老灶台”后厨当帮厨。师父没让他碰锅,先递过一张验收单:红烧肉必须色泽红亮、肥而不腻、甜咸适中,二十分钟内上桌。阿明傻眼——菜还没做,怎么就定标准?师父说:正因为菜没做,标准才能指挥你怎么做。此后每试做一次,阿明就对着验收单试吃:不合格就回灶台调火候,合格就记下手法,再把菜谱整理得更清爽。一锅锅肉越做越稳。这就是测试驱动开发(TDD):先写下“什么算做好”的测试,再写让测试通过的最小实现,最后在测试全绿的保护下重构。红灯、绿灯、重构,循环往复,好味道就在这节奏里长出来。
学好 TDD,你会从“写完代码再补测试”的被动者,变成“用测试倒逼设计”的设计者——在 AI 生成代码的时代,这份验收单意识比以往更重要。
目录
可视化演示
展示TDD核心红-绿-重构循环、经典与伦敦学派策略对比、以及测试覆盖率随迭代增长曲线三大维度。
本节配有交互动效(进入视口后自动加载,约 3KB)。
红-绿-重构循环 红色:编写失败测试定义新行为 绿色:编写最小代码使测试通过 重构:改善代码结构保持测试绿色
核心思想:先有验收单
师父的验收单,本质是“先定义什么叫好,再动手做”。红烧肉还没下锅,“色泽红亮、入口软糯”这些标准就先立住了;代码也一样,函数还没实现,“输入 1 和 2 应返回 3”这样的行为契约先写下来。这个顺序一旦颠倒,验收就成了走过场。
TDD 的第一信念是“测试先行”:动手写实现之前,先写一个注定失败的测试。这个测试不是事后的检查员,而是行为的立法者——它规定了函数收什么、吐什么、遇到异常怎么办。测试通过的瞬间,行为契约就生效了。
为什么要把测试放最前面?因为缺陷越晚发现,修复成本越离谱。需求阶段的一个误解,改一行文字就能解决;拖到上线后,可能要动五个模块、发三次版本。测试先行等于在每个行为诞生的瞬间就验收,把 bug 摁在摇篮里。
那张验收单真正的作用不是“查菜”,而是“定菜”。后厨的每道菜因此有了灵魂,代码里的每个函数因此有了契约。这就是 TDD 的第一课:标准先于作品,作品才有方向。
- 修复成本 C_fix把一个缺陷从发现到修复所消耗的人力与时间折算成成本,越晚发现,涉及的模块和返工面越大。
- 时间 t缺陷从“出生”到“被发现”之间跨过的阶段数,包括需求、设计、编码、测试、上线。
- 平方正比关系成本不是线性上涨,而是随阶段平方级爆炸——上线后的一个 bug 可能是需求阶段的几十倍代价。
红绿重构:三拍节奏
阿明按验收单做出了第一锅肉,端到师父面前。师父只说了三个字:“不合格,肉不够糯。”阿明没有辩解,回灶台调了十五分钟火候,再端出来——过关。短短二十分钟,他经历了两次“红灯转绿灯”。
TDD 的核心循环就叫“红-绿-重构”。红灯:写下一个新测试,运行并确认它失败,因为实现还不存在或不对。绿灯:用最少量的代码让测试通过,功能契约成立。重构:在测试全绿的保护下,清理重复、提取函数、改善命名——行为不能变,结构可以变。
红灯是整支循环的灵魂。很多新手跳过“确认失败”直接写测试再写实现,结果测试永远一次通过——这等于没有验收单就宣布菜合格。只有亲眼看见红灯,你才知道这个测试真的在守护某种行为,而不是自说自话。
师父的节奏像节拍器:失败、通过、打磨,短促而稳定。每三到五分钟一轮,设计就往前走一小步。TDD 不是一口气写完再回头修,而是用小步快跑,让代码永远处于“刚通过测试”的可发布状态。
- Red 红灯写下一个描述期望行为的测试,立刻运行并确认它失败——失败证明测试真的在守护行为。
- 最小实现只写让当前红灯变绿的最少代码,不做超前设计,避免“你可能用不到”的过度工程。
- Green 绿灯测试通过,功能契约成立;此时代码可能很丑,没关系,下一步再收拾。
- 重构与新测试保持绿灯优化结构,再写下一个测试,进入新一轮红灯,循环往复形成节奏。
测试粒度:金字塔的取舍
阿明学聪明了,开始往验收单上狂加标准:肉要切 3 厘米见方、冰糖精确到 7 克、必须用第 3 号锅……师父一把抢过单子:“验收单要锁味道,不是锁手法!”测试粒度,就是这门平衡术。
软件测试世界有一座金字塔:底层是数量最多、跑得最快的单元测试,验证单个函数或类的行为;中层是集成测试,验证模块之间的协作;顶层是稀少的端到端测试,模拟用户完整的操作旅程。TDD 的主战场在底层——快、稳、一出错就知道毛病在哪。
判断粒度合不合适,标准是看“行为”还是“动作”:好测试描述“输入什么、输出什么”,坏测试断言“内部调了哪个私有方法、中间变量等于几”。前者允许你自由重构,后者让你动一行代码碎一片测试。
好的验收单有弹性:它锁住“味道”,但绝不锁死“手法”。单元、集成、端到端的配比像调料——底层打底,中层调味,顶层提香,比例对了,菜才稳。
- 单元测试数 N_unit直接验证单个函数或类的用例,毫秒级执行,是整个金字塔的地基。
- 集成测试数 N_integration验证模块之间协作的用例,需要组装环境,数量居中。
- 端到端测试数 N_e2e模拟用户完整流程的用例,最慢最贵,必须少而精。
- 远大于 ≫三层数量严格递减,底层越厚,回归成本越低;数量关系绝不能倒置。
设计驱动:接口先行
为了单独验收“炒糖色”这一步,阿明发现必须把糖色锅从大灶上拆出来,单独支一个小灶——原本缠在一起的后厨流程,被验收单逼着重排了。这正是 TDD 最反直觉的回报:写测试不直接产出功能,却逼迫你把设计想清楚。
因为要先写测试,你就必须先回答三个问题:这个函数叫什么?参数是什么?返回什么?接口先于实现被确定,这就是“接口先行”。实现可以后写,契约必须先立。
可测试性说到底,是依赖能不能被替换。如果函数内部直接 new 一个数据库连接、直接读全局配置,测试就没法隔离。如果依赖通过参数注入,测试就能传入替身。TDD 像水流,总往阻力小的地方走——长期下来,代码自然长成依赖注入、面向接口、低耦合的样子。
师父望着重排后的后厨感慨:“不是验收单管着菜,是验收单逼着后厨变得井井有条。”测试先行的人,往往也是架构清晰的人——设计不是画出来的,是测出来的。
- 可注入依赖数 N_injectable能通过参数或构造器传入、测试时可替换成替身的依赖个数。
- 依赖总数 N_total被测代码直接使用的全局变量、静态方法、网络与数据库连接等外部依赖的总数。
- 可测试性 T_testable两者相除得到一个 0 到 1 的比值,TDD 会逼着这个比值不断变大,设计随之解耦。
双派之争:真材实料还是替身
后厨来了第二位师父,两位在“怎么验收”上较上了劲。大师傅说:整锅端上来,用真材实料试味道。二师父说:每步单独验收,用假糖假醋也能判断工序对不对。这正是软件界经典 TDD 与 London 学派的路线之争。
经典 TDD(又称底特律学派)主张从内到外:测试被测对象的真实行为,连它的协作者(数据库、其他类)也一起用真的,通过“状态验证”判断结果对不对。London 学派主张由外向内:先定对外接口,把每个协作者都替换成替身,通过“交互验证”检查消息有没有按预期传递。
两派的分歧,可以浓缩成一个数字:测试里替身的数量。经典派替身趋近于零,相信真实协作能暴露集成问题;London 派替身数等于协作者数,追求完全隔离,故障一发生立刻知道是哪个环节。前者更真实,后者更精准。
两位师父最终和解:小灶台用真材实料,大型宴席用标准化流程。真实项目里两种流派经常混用——核心业务用真实协作保真,外围边界用替身隔离提速。门派不重要,守住“行为有验收”才是根本。
- 替身数量 k_mock测试中用来顶替真实协作者(数据库、外部服务、其他类)的 mock/stub 个数。
- 经典 TDD 取 0使用真实协作者组装测试,做状态验证——检查“结果对不对”。
- London 学派取 m_collab由外向内设计接口,把每个协作者逐一替换成替身,做交互验证——检查“消息传没传对”。
AI工程应用:给黑盒立规矩
如今后厨多了一位“AI 大厨”——一个训练好的模型。它做的菜(预测结果)连师父都说不清为什么好吃。面对黑盒,TDD 的逻辑反而更值钱:无法理解它,就去验收它。
AI 工程里,TDD 落在三张验收单上。第一张给数据管线:清洗、去重、切分、单位换算是确定性的普通代码,完全可以用红绿重构驱动。第二张给模型评估:精确率、召回率、F1 达到业务阈值才算绿灯。第三张给提示词:把典型输入与期望输出写成回归测试,每次改 prompt 先跑一遍,防止旧能力退化。
在 AI 生成代码成为日常的今天,TDD 又多了一个角色——人给 AI 立的验收单。让 AI 写实现之前,人先写测试;AI 的产出通不过测试,红灯亮起,返回重写。这比肉眼 Review 更客观,也比“AI 说什么都对”更可靠。
师父看着 AI 大厨说:“黑盒不可怕,可怕的是没有验收单的黑盒。”给 AI 立规矩,TDD 就是那张规矩单。模型会迭代、prompt 会改版,有了验收单,每次变化都看得见是变好还是变坏。
- 精确率 P预测为正的样本里真正例占多少,对应“端上桌的菜中合格的比例”。
- 召回率 R所有真正例中被找出的比例,对应“所有合格的菜都被端上桌的比例”。
- F1 调和平均P 与 R 的调和平均,两项任一偏低都会拉低总分,是 AI 测试最常用的红灯/绿灯判据。
遗留代码策略:先拓片再动刀
阿明被派去接管一家老店。后厨的菜谱东一张西一张,那道传了三代的老汤,谁都不敢碰。老店不能停业重来,只能边营业边改造——这正是遗留代码的日常。
软件界给遗留代码下过一个扎心定义:没有测试的代码。改造它的安全流程分三步:第一步写特征测试,像考古拓片一样,把当前行为原样记录下来——先不评价对错,先记录味道;第二步找接缝,在不动行为的前提下找到可以插入测试点的地方;第三步增量覆盖,每改一小块就多一层测试保护。
特征测试与 TDD 的常规测试方向相反:TDD 先写期望再写实现,特征测试先读现状再记录现状。它不回答“应该是什么”,只锁定“现在是什么”。有了拓片,老汤才敢动,动了才知道有没有变味。
老店改造像考古:先拓片(特征测试),再开凿(接缝改造),最后修复(重构)。每一步都有保护网,老店越改越年轻。对遗留代码,最大的尊重不是推倒重来,而是带着测试小心翼翼地为它续命。
- 已执行分支 b_executedif/else、switch、三目运算等分支中,被测试真实走过的分支数量。
- 总分支数 b_total静态分析出的全部分支路径数量。
- 分支覆盖率 B两者相除得到覆盖率,特征测试的目标就是先把旧代码的覆盖率安全垫高。
实践陷阱:护栏要对着悬崖
阿明学了一个月,开始走火入魔:给每一行代码写测试,给每个私有方法立验收单,写测试的时间比炒菜还长。师父敲了敲他的头:“验收单不是越多越好,护栏要对着悬崖,而不是围着平地。”
TDD 的实践陷阱主要有四类。一是过度测试:为 getter、setter 和琐碎逻辑写测试,维护成本超过价值;二是测试实现细节:断言内部变量和调用顺序,重构时测试大面积崩塌;三是红绿颠倒:先写实现再补测试,测试永远绿灯,失去驱动力;四是用例腐化:测试从不维护,和代码不同步,红灯连片也没人管。
判断测试值不值的公式很朴素:维护成本约等于测试数量乘以测试与实现的耦合度。高耦合的测试最可怕——它们把重构锁死,把精力耗干。好测试是行为的纪录片,坏测试是实现细节的自传。
师父最后说:“测试不是业绩,是护栏;护栏的密度要看悬崖在哪里。”把测试用在刀刃上,TDD 是神器;用错了地方,它只是自我感动式的仪式。红绿灯要管住的是风险,不是每一寸代码。
- 测试数量 N_test测试用例总量,数量膨胀并不等于质量提升。
- 测试与实现耦合度 S_coupling测试断言了多少实现细节(内部变量、私有方法、调用顺序),耦合越高,实现一变测试就崩。
- 维护成本 C_maintain总维护成本约等于两者乘积——两个“高数量但低耦合”的测试,可能比二十个坏测试更轻省。
AI 应用场景
提示词回归测试:给 prompt 立验收单
阶段:模型评估与测试
解决的问题:传统方式修改 prompt 全凭手感,改完常用问题变差也没人发现。用 TDD 把“典型输入→期望输出”写成测试集,每次改动前先跑一遍,红灯亮了就知道哪类能力退化了。
具体案例:某客服机器人的 FAQ 检索 prompt 要升级措辞,团队先为 20 个高频问题写断言测试(红灯:新用例加入后当前实现部分失败)→ 调整 prompt(绿灯)→ 把公共约束抽成模板变量(重构)。此后每次 prompt 改动都自动回归,旧能力再也没悄悄丢失过。
数据管线 TDD:清洗逻辑不裸奔
阶段:数据工程
解决的问题:数据清洗、去重、单位换算等函数是普通确定性代码,一旦改错,全部下游训练数据悄悄被污染。传统做法靠人工抽查,治标不治本。
具体案例:某推荐系统团队对“时间戳统一为 UTC”“金额保留两位小数”“异常值剔除”等函数一一编写单元测试,纳入 CI 每次提交自动执行。一次某同事把时区换算写反,CI 红灯拦下,避免了一整天的错误数据灌进训练集。
遗留推荐服务的特征测试
阶段:存量系统改造
解决的问题:接手一个没人敢动的老模型服务,参数、特征、逻辑纠缠不清,任何小改动都可能让线上效果崩盘。
具体案例:团队先取 100 条历史样本,记录当前模型的预测结果作为特征测试基线;然后给特征工程代码加接缝、插测试点,把每个特征函数逐一用特征测试锁定行为;最后才开始重构。每次改动跑一遍基线,输出偏离立即红灯,改完三个迭代,老服务被稳稳地现代化了。
Python 演示
"""TDD 实战演示: 红-绿-重构三步循环. 场景: 为"满减 + 会员折扣"的订单计价器写测试.顺序严格遵守 TDD:红灯: 测试先行(先描述行为, 此时实现尚未完成)绿灯: 补最小实现, 全部测试通过重构: 保持行为不变, 优化代码结构"""import unittest class OrderCalculator: """订单计价器(由 TDD 驱动构建).""" def __init__(self, discount=0.1): self.discount = discount # 会员折扣率, 默认 10% def total(self, items, is_member=False): """先算原价, 再算会员折扣, 最后触发满减.""" raw = sum(items) if is_member: raw = raw * (1 - self.discount) if raw >= 100: raw = raw - 20 return round(raw, 2) class TestOrderCalculator(unittest.TestCase): """验收单: 先描述行为, 跑出红灯, 再让实现变绿.""" def setUp(self): self.calc = OrderCalculator() def test_plain_sum(self): self.assertEqual(self.calc.total([10, 20, 30]), 60) def test_minus_20_when_over_100(self): self.assertEqual(self.calc.total([60, 60]), 100) def test_member_discount(self): self.assertEqual(self.calc.total([60, 60], True), 88.0) def test_discount_before_full_reduction(self): self.assertEqual(self.calc.total([50, 60], True), 99.0) def run_tdd_cycle(): """模拟 TDD 三阶段: 红 -> 绿 -> 重构.""" print("第一步【红灯】: 先运行测试, 此时实现若缺失会失败") suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestOrderCalculator) result = unittest.TextTestRunner(verbosity=2).run(suite) print("第二步【绿灯】: 最小实现完成, 全部用例通过") print("第三步【重构】: 保持测试全绿, 把阈值提取为常量") return result.wasSuccessful() if __name__ == "__main__": ok = run_tdd_cycle() print("全部通过!" if ok else "存在失败用例, 回到红灯阶段") raise SystemExit(0 if ok else 1)
class TestOrderCalculator(unittest.TestCase):验收单本体,用 unittest 描述“应该发生什么”,实现缺失时它会红灯。self.assertEqual(self.calc.total([60, 60], True), 88.0)覆盖“先打折再满减”的边界行为,专治顺序写反的 bug。raw = sum(items)绿灯阶段的最小实现,先满足最简单的那条纯求和用例。if raw >= 100: raw = raw - 20第二个绿灯动作:实现满减规则,让对应测试转绿。result.wasSuccessful()把测试结果变成布尔值,失败时返回 False,模拟“红灯报警”。
常见误区
误区我以为 TDD 就是把所有测试先写完,然后一次性把实现全部写出来。反正都是“先测试后实现”,顺序对了就行。
正解TDD 的精髓是小步循环:每写一个测试→看到它失败(红)→写最小实现让它通过(绿)→重构(保持绿)→再写下一个测试。一次攒一堆测试再实现,会丢掉红灯的指导,也无法获得持续反馈——就像把验收单写完却不看结果就闷头炒菜。
为何常见:把 TDD 误解成“时间顺序”而非“节奏循环”,忽略了“亲眼看见失败”才是驱动设计的燃料。
误区覆盖率高就代表测试好,我会想办法把覆盖率冲到 100%,包括给 getter/setter 也写测试。
正解覆盖率只说明哪些代码行被执行了,不说明行为有没有被正确断言。为了数字强行测私有方法和琐碎逻辑,会让测试高耦合、高维护,反而锁死重构。质量取决于断言的价值:一个断言关键行为的好测试,胜过一百个凑覆盖率的空测试。覆盖率是结果,不是目标。
为何常见:覆盖率是唯一能量化的测试指标,KPI 驱动下容易“为了数字而数字”,忘了测试的本职是守护行为。
误区我把测试写得很“深入”,断言私有变量的中间值和方法调用顺序,觉得这样才算把代码测透了。
正解测试应锁定外部可观察的行为契约,而非内部动作。测实现细节会让测试与实现强耦合:你只是把变量名改得更清楚,几十个测试跟着碎掉。行为不变时测试应当全绿——这正是重构安全网的前提。想测私有逻辑,正确的做法是把它提取成独立的公有函数再直接测它。
为何常见:把“测得多”当成“测得准”,分不清“行为”与“动作”——就像验收单写“必须用 3 号勺”而不是“味道要甜咸适中”。
误区先写测试再写实现,等于同一份功能写两遍,太浪费时间了。直接写代码跑起来再说。
正解短期看,TDD 确实多了一步;但缺陷修复成本随时间平方增长,TDD 把缺陷拦截在诞生瞬间,省下的是大量调试、返工和线上事故的时间。对复杂逻辑和高变需求,TDD 反而更快,因为它用测试把“理解”固化下来,改起来才敢下手。
为何常见:只用“敲代码的时间”衡量效率,忽略了“调试+回归+救火”的巨大隐性成本——就像阿明觉得写验收单耽误炒菜,却忘了返工重做的代价更大。
面试题
Q1. TDD 红绿重构循环中,“红灯”状态的含义是?
- A.代码风格检查未通过
- B.新写的测试正在失败← 正确答案
- C.生产环境出现线上事故
- D.CI 流水线资源不足
答案:B。红灯指新测试运行后失败的状态,此时实现尚未满足测试描述的行为。A 是代码风格问题,C 是运维事故,D 是基础设施问题,都与 TDD 中“用失败测试驱动实现”的红灯无关。
Q2. TDD 中“先写测试”的直接动机是?
- A.提高覆盖率数字好看
- B.用测试书写行为规格← 正确答案
- C.取代人工代码评审
- D.让测试部门无事可做
答案:B。测试先行是为了把“期望行为”固化成可执行规格,让实现有明确的验收标准。覆盖率是副产品而非目的,A 颠倒了因果;C 和 D 明显与 TDD 无关。
Q3. 经典 TDD(底特律学派)与 London 学派的核心分歧是?
- A.是否采用强类型系统
- B.测试中替身的使用程度← 正确答案
- C.是否使用版本控制管理
- D.能否用 AI 自动生成
答案:B。经典派倾向用真实协作者做状态验证,London 派倾向用替身隔离全部协作者做交互验证,分歧核心就是测试替身的使用程度。其余选项与两派之争无关。
Q4. 测试金字塔建议的用例数量结构是?
- A.单元约等于集成与端到端
- B.端到端最多集成次之单元最少
- C.单元最多集成次之端到端最少← 正确答案
- D.集成最多单元次之端到端最少
答案:C。金字塔底层是单元测试,最便宜最快,数量最多;越高层越贵越慢,数量越少,所以 C 正确。A 的均等结构不成立,B 是倒金字塔会拖垮回归,D 把集成放在最多也不符合成本规律。
Q5. 对遗留代码(legacy code)实施 TDD,第一步通常应做什么?
- A.推倒全部重写代码
- B.编写特征测试锁定当前行为← 正确答案
- C.把覆盖率强行冲到百分百
- D.直接删除没有测试的功能
答案:B。特征测试像考古拓片,先把当前行为原样记录下来作为安全网,是改造遗留代码的第一步。A 重写风险极高,C 直接追覆盖率容易落入无效测试,D 删除功能可能丢失业务价值。
Q6. 下列哪项最适合作为 AI 模型的“绿灯验收线”?
- A.训练集损失降到零
- B.F1 达到业务阈值← 正确答案
- C.模型参数量做得最大
- D.推理时延压到最低
答案:B。F1 综合精确率与召回率,能衡量模型在正负样本上的整体表现,适合当验收线。训练损失为 0 是过拟合信号,参数最大和时延最低只代表资源与速度,不代表质量达标。
Q7. 哪些做法违背了 TDD 的原则?
- A.先写实现再补测试← 正确答案
- B.测试锁定外部行为
- C.为私有方法大量写测试← 正确答案
- D.攒齐一批测试再写实现← 正确答案
答案:ACD。先实现后补测试(A)失去红灯驱动;为私有方法写测试(C)导致高耦合高维护;攒一批测试再实现(D)丢掉小步反馈。B 是正确的 TDD 做法:测试锁定外部行为契约。
Q8. TDD 循环的三个阶段包括?
- A.红灯阶段测试失败← 正确答案
- B.绿灯阶段最小实现← 正确答案
- C.重构保持行为不变← 正确答案
- D.发布部署上线运行
答案:ABC。TDD 循环是“红-绿-重构”:先让测试失败(A),再用最小实现让它通过(B),最后保持全绿地优化结构(C)。发布部署不属于 TDD 循环本身。
Q9. 关于测试粒度,下列说法正确的是?
- A.单元测试应快且隔离← 正确答案
- B.集成测试验证模块协作← 正确答案
- C.端到端测试越多越好
- D.粒度要锁行为而非动作← 正确答案
答案:ABD。单元测试要快、要能隔离运行(A 对);集成测试验证模块间的协作(B 对);端到端测试昂贵脆弱,“越多越好”错误(C 错);粒度应锁定外部行为而非实现动作(D 对)。
Q10. AI 工程中“提示词回归测试”的核心思路是?
- A.改 prompt 前先跑断言测试← 正确答案
- B.用更大的模型替换旧模型
- C.删除所有历史 prompt 版本
- D.发布之后才人工抽查
答案:A。提示词回归测试把典型输入与期望输出写成断言测试,每次修改 prompt 先运行一遍防回归,这正是 TDD 在 AI 场景的落地。B 是模型升级策略,C 删除历史是反向操作,D 事后抽查缺乏预防性。