测试驱动开发(TDD)到底行不行?拿两年真实项目和传统开发对比,说一说我的偏见

🔑 关键词:TDD,测试驱动开发,单元测试,软件开发,重构

📖 摘要:从强制TDD到放弃再到局部使用,一个普通程序员对测试驱动开发的真实复盘。没有万能方法论,只有场景与成本。

我想聊测试驱动开发(TDD)。在写这个之前我搜了很多文章,基本都是两种口气,一种是用完就灵丹妙药,一种是一无是处。但我觉得在真实项目里,TDD更像一把手术刀,你不看场合就捅自己身上,真的会疼。先交代一下背景,我写代码五年多,不是什么大牛,在一家几十人的技术团队待过,也在大厂边缘混过。TDD 在我手里成过也败过,下面这些全是我自己的事,不一定对,但保证是真的。

图片

第一份工作是在做支付对接。当时公司搞“质量月”,要求所有新功能必须测试先行,还定了Jacoco覆盖率最低80%。我分配到的任务是对接一个银行接口,那边文档烂到没法看,字段含义都是猜的。没办法,我先写Mock框架,再写单元测试,把接口入参、出参、异常分支全部列出来,花了两天时间把测试骨架搭好。然后呢?覆盖率确实好看,92%,提交代码之后心里美滋滋。结果上线第一天支付超时率飙到100%,最后发现原因是生产环境银行地址没有走配置中心,我在代码里写死了测试环境的值。测试全绿,因为单测里根本没验证“配置来源”。这个锅不能全算TDD,但起码说明了,TDD不能解决你没想到的问题。

第二次我被强制TDD搞烦了。是另一个项目,做一个新的订单中心。PM自己需求变来变去,今天说要支持“取消”,明天又说“取消要分客服取消和用户取消”,后天说“取消单不能直接退,先锁库存再退款”。我按第一版的需求把测试全写好了,然后需求变一次,我改一次测试,再重写一次实现。最后我数了数,原本计划7天的功能,做到第10天还没上线。测试代码倒是留下了,但很多都是后面补的假回归测试。那时候我差点把“TDD”拉黑。

图片

转折出现在上一家的清结算系统。那个系统状态特别多,比如在线支付会有 created -> paid -> refunding -> refunded -> cancelled 之类的状态,每一笔钱都不能出错,不然对账就芭比Q了。我一开始也没用TDD,写了一个状态机,结果测试环境联调的时候就发现了至少三个状态流转 bug。后来我冷静下来,干了一件事:把所有合法状态流转画成一张表,然后把每个流转写成一个测试方法。当时大概写了65个JUnit参数化用例,一个用例对应一条从旧状态到新状态的路径,断言状态变更后带出来的事件、金额和版本号。运行一次不到20秒。后来产品提出要支持“部分退款后再次全额退款”这个新需求,我第一反应不是直接改代码,而是先加测试让它红,再回头改状态机,很快稳了。上线半年,状态相关的 Bug 是 0。这个成功经验让我意识到,TDD 不是玄学,它对“状态复杂且可枚举”的模块简直是护身符。

图片

为什么同样是TDD,结果差别这么大?我试图对比一下TDD和传统“先代码后补测试”的工作流。传统流程往往是:需求明白得七七八八就开始写代码,快下班的时候想起测试还没写,于是补一点覆盖主流程的测试,再来个异常分支 happy path 就结束了。这种测试质量通常很差,因为它被你实现代码的思路“带跑”了,很容易照着实现逻辑来断言,等于自己验证自己。TDD的优势是设计层面——写测试前你得先定义函数名、参数、返回值和边界,相当于把需求翻译成可执行的设计文档。但TDD的前提是需求本身足够明确。如果需求模糊,或者你根本不知道该测什么,那你写的测试全是空中楼阁,改起来分分钟想删库跑路。

还有大家最爱的覆盖率。不少人以为65%和90%天差地别,我却见过单纯为了把行覆盖率刷到80%,把私有方法用反射测一遍,加一堆毫无意义的断言。这种覆盖率和路边贴膜镀金没什么区别。我的个人经验是:行覆盖率到70%以后,再往上提升的成本和收益严重不成比例。真正需要关注的是分支覆盖率和异常注入。比如你测一个订单模块,能不能覆盖到金额为负数、商品库存为0、幂等键重复这些case?如果能,哪怕行覆盖率只有60%,也比那种只有快乐路径的100%有用得多。

图片

然后说说我现在的流程,可能对你有参考。第一步,先判断这个模块值不值得TDD。满足这几点我才会严格按红绿重构来:

  1. 核心业务规则复杂;
  2. 状态转换多;
  3. 需求相对稳定;
  4. 未来大概率会被反复改动。

反之,如果是纯CRUD、一次性脚本、技术预研、UI交互原型,我真的不想强行“测试先行”。第二步,如果决定用 TDD,我会:

  1. 先写一个“失败清单”,把要覆盖的场景用自然语言写在测试类里,实现之前先跑一遍,确认它是红的。
  2. 再写最简实现让测试变绿。
  3. 然后看能不能在不改变行为的前提下让代码结构更清楚。

图片

这个循环会让你很放心地改代码。

图片

最后我想说一个可能引起争论的观点:TDD的目标不是“开发”,而是“设计”。它的价值其实在于给你一个极其快速的反馈闭环,让你不断审视自己抽象出来的接口到底别扭不别扭。如果某个功能你写测试的时候觉得取名太难、边界太乱,那就是设计在对你报警。可惜很多人把注意力放在覆盖率数字上,忽略了这个信号。

写了这么多,也不知道是不是跑题了。总结就是:我至今没有完全拥抱TDD,也彻底放弃了“TDD是银弹”的看法。它适合那些复杂度高、状态可枚举、并且你愿意先花时间去理解业务规则的代码;不适合需求模糊的时候盲目套流程。我的建议是,你要是还没试过,就找个身边最容易出bug的模块,开一个分支,用TDD写一个功能,跟传统写法对比一下。用数据说话,不要听谁的嗓门大。

🏷️ 标签: