返回博客

TDD 的真正价值不是覆盖率,是逼你把需求想清楚

「先写测试」这句话我听了很多年,但真正开始认真执行,是被一次线上事故打醒的。一个核心计算模块,逻辑写得很「直觉正确」,没有测试,上线后才发现边界条件处理有问题,回滚、修复、再部署,折腾了大半夜。

事后复盘,我意识到问题不是「没有测试」,而是我在写代码的时候根本没有认真定义「正确」是什么。先写测试这件事,本质是强迫你在写实现之前,先把「这段代码的合约」说清楚。

Red-Green-Refactor:一个节奏,不是仪式

TDD 的三步循环——先写失败的测试(Red)、写最小实现让它通过(Green)、在测试守护下重构(Refactor)——听起来像仪式,但它的价值在于节奏控制。

Green 阶段只做一件事:让测试绿。哪怕是最笨的实现,哪怕是硬编码返回值。很多人在这一步就开始「写优雅的代码」,结果越写越偏,测试也越难 pass。

Refactor 阶段只做一件事:整理结构,不扩展行为。如果这时想到新需求,记下来,下一个 Red 再开始。这个约束保证了「每次只改一件事」,出了问题知道去哪里找。

测试是需求的可执行表达

先写用户旅程(「作为用户,我希望……以便……」),再把每个旅程拆解成一组测试用例,包括正常路径、边界输入和错误场景。这个过程会暴露你对需求的模糊假设——比起 review 文档,代价更低,发现更早。

三层测试各司其职

单元、集成、E2E 三层不是「测三遍同一件事」,而是关注点完全不同。

单元测试关注纯函数和组件逻辑。它应该跑得极快(每个 < 50ms),不涉及网络和数据库。如果一个单元测试需要 mock 七八个依赖,这通常是架构信号——这个模块做了太多事。

集成测试关注边界契约。API 端点返回正确的 HTTP 状态码和结构、数据库操作符合预期、外部服务的 mock 覆盖了降级路径。这层测试往往比单元测试更能发现真实 bug,因为很多问题出在模块边界,而不是单个函数内部。

E2E 测试关注完整用户流程。它慢,所以只覆盖关键路径——登录、核心业务操作、支付或提交这类不可逆动作。用 Playwright 或 Cypress,测试语义要稳定:用 roledata-testid 而不是 CSS 类名,否则改个样式就能把测试打烂。

// 脆弱选择器:改样式就断
await page.click('.btn-primary-blue')
 
// 语义选择器:随结构变化保持稳定
await page.click('button[data-testid="submit-order"]')
await page.click('button:has-text("提交订单")')

测试隔离:每个用例自给自足

最常见的坏味道是测试之间有依赖关系——用例 A 创建了数据,用例 B 依赖这份数据。一旦调换顺序或并行执行,就会随机失败。

正确的做法是每个测试自己 setup、自己 teardown,数据工厂(factory 或 fixture)让这件事变成一行代码。外部依赖(数据库、缓存、第三方 API)在单元测试中全部 mock 掉,在集成测试中用测试专用的实例,不碰生产数据。

什么时候不写测试

TDD 不是每行代码都要测。以下场景我会主动降优先级:

  • 一次性迁移脚本:跑完就扔,验结果比测代码直接。
  • 纯配置文件:没有逻辑,测起来是在测框架本身。
  • 快速原型:验证方向的 spike 代码,先验证可行性,确认要做再补测试。

覆盖率目标是工具,不是目的。核心业务逻辑 90%+,工具函数 80% 左右,边缘模块按风险定。不要为了凑数字写没有断言价值的测试——那会让团队对覆盖率数字失去信任。

一句话心法

先写测试不是为了证明代码正确,而是为了在动手之前先把「正确」定义清楚。