测试驱动开发值不值得用?我按语言类型算了笔账,结论跟主流说法有点反

🔑 关键词:测试驱动开发,TDD,变异测试,单元测试,Stryker

📖 摘要:写了七年 TDD 之后,我越来越觉得它不是一个「好习惯」问题,而是一笔成本核算:TDD 的边际收益跟语言的类型系统强度成反比。这篇文章用同一段订单金额逻辑在 Python、TypeScript、Rust 三种语言里的实测数据来说明,顺带聊了「假绿」测试、变异测试工具链、AI 补全如何偷走了 red 阶段,以及哪些场景我劝你别上 TDD。

2017 年我在杭州一家做跨境电商的小公司,接手了一个 800 多行的 OrderService.java。整个下单流程没有一行测试,改一条运费规则,我得在测试环境手动下 11 单——改收货地址、换支付方式、把优惠券叠加到刚好卡在门槛上——才能确认没搞坏别的。当时觉得这太蠢了,买了本《测试驱动开发》,两个周末啃完,周一跟 leader 说:下个迭代我们上 TDD。

图片

第一个迭代我就崩了。花了一整天写测试,一个都没跑起来。OrderService 的构造函数里塞了三个静态调用——一个读配置中心,一个连 Redis,一个 new 了个 HttpClient。想给它写测试,得先 mock 掉整个宇宙。那天走得很晚,路上想:这玩意儿是不是只适合书上那种干干净净的小项目?

后来才想明白,我搞反了因果。TDD 不是「给已经写好的代码补测试」,而是「用测试逼你把代码写成能测的样子」。我拿一个已经烂掉的类去套 TDD,当然套不进去——那不是 TDD 的问题,是那个类的问题,只是被 TDD 提前捅出来了而已。

我的核心观点:TDD 的性价比,跟类型系统强度成反比

见过太多「TDD 到底值不值」的争论,吵的都是团队文化和工程师自律,很少有人认真算这笔账。我的看法是:TDD 的边际收益,跟你的语言类型系统强度成反比。这个结论我自己也觉得有点刺耳,但数据摆在那儿。

同一个业务逻辑我做了一组实验——订单金额计算,包含阶梯折扣、税率、运费门槛、四舍五入规则,三种写法:

图片

Python 3.11 + pytest 7.4,TDD 写完 46 个用例。里面一多半在测类型相关的东西:传进来 None 会怎样、amount 是字符串会不会炸、Decimal 和 float 混用会不会丢精度。这些事在动态语言里,你不写测试就真的没人管,线上炸的是钱不是 CI 红灯。

TypeScript 5.4 开 strict 加 noUncheckedIndexedAccess,配 Vitest 1.2,同样逻辑 19 个用例就够了。discount: number | null 这种问题 tsc 直接标红,你不用写测试去证明它;arr[0] 可能是 undefined 也是编译器管的事。我一共删掉了 27 个「本来准备写」的用例,删的时候还有点慌。

Rust 那版我一行测试没写,不是懒,是穷举 match 加 newtype 之后,编译器已经把分支全逼出来了。再写就是在重复编译器的活,除了让覆盖率报告好看,没有任何实际意义。

所以结论:TDD 在 Python、Ruby、原生 JS 上收益最高,因为它替你补上了缺失的类型检查;Java、C# 次之,编译器拿走了一部分;到 Rust 或者严格模式的 TS,边际收益明显掉下来,你的精力应该放在真正的业务语义上,而不是「传 null 会怎样」。

这话跟主流说法不太一样。主流说法是「TDD 在任何语言里都是好习惯」,我觉得这话跟「每天喝八杯水」一个性质——听着没毛病,但不看人、不看场景、不看肾功能。

比不写测试更可怕的,是「假绿」

图片

JUnit 5 的 assertEquals 参数顺序是 (expected, actual)。写反了不会失败,但报错信息会把你带偏,你以为是自己逻辑错了,其实只是参数位置写反了。Python 的 unittest 的 assertEqual(first, second) 同理。这种错我犯过至少五次。

更阴的是 Mockito。when(payClient.query(anyString())).thenReturn(SUCCESS) —— anyString() 这个匹配器宽松到什么程度?如果被测方法压根没调用 payClient,你拿到的是 mock 的默认返回值(对象是 null,boolean 是 false)。有时候逻辑恰好走通了,测试绿了,你以为是验证过的,其实什么都没验证。

还有一种我踩过最狠的:测试里 catch 了异常然后 assertTrue(true)。当时是为了「让测试别挂」,结果那个 catch 把真正的 NPE 吞了一年多,直到线上有笔订单金额算成了 0 才被发现。

治这个毛病得靠变异测试(mutation testing)。它不看你跑了多少行代码,它在源码里造小改动——把 < 改成 <=,把 + 改成 -,删掉一个 if 分支——然后重跑你的测试。测试还是绿的,说明这个变异「存活」了,也就是你根本没测到这个点。

2023 年我在一个 Node 项目上跑 Stryker(当时 7.x,现在 8.x),覆盖率报告写着 85%,看起来挺体面。mutation score 出来 41%。印象最深的是一条:把 if (amount > 0) 改成 if (amount >= 0),全套测试绿灯,一个都没挂。这意味着 0 元订单这个分支,我写了三个测试,没有一个是真在断言的。

图片

工具链上:JS/TS 用 Stryker(npm i -D @stryker-mutator/core,配置里 mutate 指向 src/**/*.ts,排除测试文件);Java 用 PIT(pitest-maven 1.15.x,mutationThreshold 设 60);Python 有 mutmut 和 cosmic-ray。我现在的门槛是 mutation score 低于 60,就别在周报里写覆盖率了。

AI 补全把 red 阶段偷走了

2023 年之后我用 Cursor 和 Copilot 写代码,明显感觉 red-green-refactor 的节奏被打乱。以前你写完 assert calc(100, 0.1) == 90,IDE 不会告诉你答案,你必须自己想清楚 calc 应该长什么样、边界在哪。现在你敲完断言,AI 已经猜出你要写的实现,Tab 一下就有了。

结果是 green 来得太快,快到 red 那一步被跳过。而 red 那一步的价值根本不是「确认测试能失败」,是强迫你在写实现之前把接口的形状定下来。你手一抖跳过了,接口就是 AI 猜的那个样子,不一定是你想要的样子,甚至不一定是合理的。

我现在逼自己做一个动作:写完测试先跑一遍,红了,看清楚红的理由是什么。如果红是因为方法不存在,OK;如果红是因为断言失败,更好;如果红是因为别的原因——比如 import 错了、mock 没配对、测试类路径写错了——那说明这个测试本身还没准备好。理由不对的 red,后面会给你一个理由不对的 green。

图片

有些地方我劝你别上 TDD

探索期别用。你还不知道要做什么的时候,接口一周变三次,写测试就是在给自己挖坑,每次改接口都要重写一遍断言。这种阶段我一般先写个能跑的脚本,跑通了、需求稳定了,再回过头补测试。

一次性数据迁移脚本别用。跑完就删的东西,测试写得再漂亮也是浪费。当然,如果这个脚本要跑第二次,那它就不是一次性的了,另说。

UI 布局和动画别用。像素级的视觉回归,截图对比或者 Playwright 的视觉断言比单元测试靠谱得多,而且维护成本低一个量级。

集成第三方 API 别用单元测试硬扛。对方接口一改你的 mock 全套失效,而且 mock 通过不代表线上能通。这种场景用契约测试(Pact 那套)或者对着沙箱环境跑集成测试更实在。

性能优化阶段也别用单测。你需要的是 benchmark 和火焰图,不是断言。用单测去驱动性能,方向就是反的。

图片

我现在的操作清单

  1. 写测试前先想清楚一个「因为」。一个测试只应该因为一个原因失败,两个原因混在一起,失败时你分不清是哪个。
  2. 断言断到具体的值。assertThat(order.total()).isEqualByComparingTo("89.90")assertNotNull(order.total()) 有用一百倍。
  3. 每个新测试写完先跑一遍看它红。红错了说明测试写错了,别急着往下走。
  4. 别用 any() 这种宽松匹配。Mockito 的 any() 会匹配 null,你以为在做强校验,其实是在放行。
  5. 每季度跑一次变异测试。Stryker、PIT、mutmut 都行,mutation score 低于 60 就说明你的测试在划水。
  6. 类型系统能挡的,别再用测试挡一遍。省下来的时间花在业务边界上,那才是测试真正值钱的地方。

我现在不觉得 TDD 是信仰了。它更像一笔贷款:你在写实现之前先付一笔「把接口想清楚」的利息,换后面重构的时候不用提心吊胆。利率高低取决于你的语言——动态语言利率高,因为你从编译器那儿借不到钱;静态强类型利率低,因为你账上已经有一笔存款了。

要不要贷,看你这个项目准备活多久。

上周我还在一个 Go 项目里跟同事吵,他说 error 返回值让 TDD 特别有必要,我说 Go 的显式 error 已经强迫你处理分支了,测试重点应该放在并发和超时上。吵到一半去吃饭了,没吵完,下次再说。

🏷️ 标签: