TDD 到底有没有用?我实践 3 年后发现:先写测试不是重点,控制反馈速度才是

🔑 关键词:测试驱动开发,TDD,单元测试,红绿重构,测试先行

📖 摘要:一篇不吹不黑的 TDD 实践复盘:从 14 分钟构建到 6 分 40 秒,从 1800 个单测砍到 1200 个,哪些场景该用 TDD,哪些用了反而拖慢。含具体步骤、工具版本、踩坑和判断标准。

先说结论:TDD 不是测试方法,是设计压力测试

图片

2021 年我带过一个 6 人小组做订单系统,技术栈是 Spring Boot 2.5、JUnit 5、Mockito 3.12、Testcontainers 1.16、Gradle 7.2。当时老板看了本书,要求 PR 必须“先有失败测试”。我们照做了两周,单测从 400 个涨到 1800 个,构建时间从 4 分钟干到 14 分钟。最骚的是,周五下午没人敢合并,因为全量 CI 经常卡在 12 分钟然后挂在一个随机失败的测试上。后来我把测试砍到 1200 个,构建回到 6 分 40 秒,线上 P0 故障反而从每季度 2.3 次降到 0.7 次。所以我的观点可能不太合群:TDD 的价值不在“先写测试”这个动作,而在于它逼你把模糊需求变成可执行的断言。

TDD、测试后补、只写集成测试,到底差在哪

图片

我拿我们那个订单系统做过对比。纯 TDD 的那部分:计价、优惠券叠加、支付回调签名,单测 420 个,平均每个 38ms,总耗时 16 秒,重构时敢改,因为一跑就知道有没有踩到边界。测试后补的那部分:Controller、DTO、Mapper,单测 600 个,很多是 getter/setter 和参数校验,跑起来 2 分 10 秒,但线上出问题时它们一个都拦不住。只写集成测试的那部分:报表导出、对账文件,8 个 Testcontainers 测试,跑一次 3 分 20 秒,慢,但真能发现 SQL 和事务问题。我的结论很直接:TDD 适合逻辑密度高、边界多、回归成本高的代码;CRUD、胶水代码、一次性脚本硬上 TDD,就是给自己找加班。

维度 严格 TDD 测试后补 只写集成测试
反馈速度 单测 100ms 内,很快 写完再补,反馈滞后 一次 20 秒到 3 分钟
设计压力 强,逼出接口和边界 弱,容易先堆实现 中,偏重链路
重构安全 高,但测试要稳 看补的测试质量 高,但定位慢
维护成本 容易 Mock 过多 容易覆盖垃圾代码 环境依赖重
适合场景 计价、权限、状态机、签名 变化慢的 CRUD 跨服务、SQL、消息

图片

我现在用的 TDD 循环,和书里不太一样

书里说红-绿-重构,但我实际操作会拆成 5 步。第 0 步:先写 1 条接口测试,用真实 HTTP + Testcontainers,跑一次 20 到 40 秒,确认它失败,失败信息要能看出是业务断言挂了,不是环境没起来。第 1 步:把核心逻辑拆成纯函数或领域对象,写 5 到 8 个单测,每个目标小于 100ms,整个单测套件小于 2 秒。第 2 步:只写让测试通过的最少代码,允许重复,允许丑陋,禁止提前抽象。第 3 步:重构,删重复,改名字,跑测试。第 4 步:提交前跑 ./gradlew test --tests '*UnitTest',CI 再跑全量。如果单测套件超过 2 秒,我会先优化测试,不是继续加测试。

举个小例子。支付回调签名校验,我先写 4 个失败 case:签名正确、时间戳超过 5 分钟、nonce 重复、金额不一致。写的时候发现签名算法依赖配置中心,那就不该塞在 Controller 里,于是逼出一个 SignatureVerifier 接口。实现只写了 37 行,但测试有 9 个。后来渠道方把签名算法从 HMAC-SHA256 换成 SM3,我只改了 SignatureVerifier 的一个实现类,Controller 和订单服务基本没动。这个收益不是覆盖率给的,是接口边界给的。

图片

砍掉 600 个测试后,数据反而好了

我们当时 1800 个单测里,有 600 个是三类垃圾:第一类,getter/setter 和 Lombok 生成代码;第二类,Controller 参数校验,其实 Spring 自己会测;第三类,Mock 过度,连 List.add 都要 verify。砍完之后覆盖率从 78% 掉到 63%,看着很吓人。但变更失败率从 24% 降到 11%,回滚次数从每月 3.1 次降到 1.2 次,P0 从每季度 2.3 次降到 0.7 次。为什么?因为剩下的测试大多在断言业务规则,而不是在证明代码被调用过。覆盖率是体温计,不是目标。你发烧了,体温计会显示 39 度,但你不会对着体温计吃药。

图片

踩过的 4 个坑,和我的土办法

坑 1:Mock 太多,测试全绿,线上挂。我们曾经 mock 掉库存服务,结果库存扣减顺序变了,测试没发现。解决:自己控制的接口用真实实现,数据库用 Testcontainers,外部 HTTP 用 WireMock,只 mock 自己控制不了的东西。坑 2:测试名字叫 test1test2。解决:改成 should_reject_when_timestamp_expired_over_5_minutes 这种,失败时不用看代码。坑 3:测试互相依赖,单独跑绿,一起跑红。解决:每个测试独立事务,不共享静态变量,随机端口,少用 @DirtiesContext。坑 4:为了覆盖率写断言。解决:删掉,别心疼。每周花 30 分钟清理最慢的 10% 测试和 flaky 测试,比多写 100 个测试有用。

图片

我的判断标准:这段代码错了,会不会半夜叫醒我

TDD 不是道德,也不是宗教。我现在判断要不要 TDD,就问一句:这段代码如果错了,会不会半夜叫醒我?会,就 TDD,比如计价、权限、支付、状态机、费率计算。不会,就先写实现再补少量测试,比如后台报表、运营配置、一次性迁移脚本。团队 3 人以下、需求每天变、代码活不过 6 个月,硬上 TDD 可能真的不如先上线再补。但如果核心链路一年要改 20 次,TDD 省下来的回归时间,比写测试的时间多得多。这个标准有点糙,但比无脑喊“测试先行”有用。

🏷️ 标签: