一个让我羞愧的TDD失败案例
十年前,我在一家外包公司第一次接触TDD。当时项目组推行测试先行,要求每个方法都必须有测试。我写了三个月,最大的收获不是信心,而是学会了如何为测试而设计——所有方法都返回interface,所有对象都通过构造函数注入。代码越来越“干净”,但整个系统像一碟散沙。直到一次需求变更,为了修改一个字段,我改了23个测试。那一刻我意识到,我写的测试不是在保护功能,而是在保护我自己最初的错误假设。后来我读了Kent Beck的书,才明白自己错在哪儿——不是我不用心,而是我把测试当成了目的而不是手段。
被当作信仰的TDD,其实是个半真半假的预言
TDD的教义说,先写测试能让你聚焦需求和设计。他们说得对,但只说对了一半。聚焦需求的前提是你对需求有足够清晰的理解。现实中的需求往往是混沌的,甚至互相矛盾。当你强行把模糊需求转成测试用例时,你会陷入一个陷阱:把不确定当确定。测试是固化行为的工具,如果你过早固化,反而失去了探索空间。所以我现在认为,TDD的黄金时刻不是编码前,而是当你的代码第一次坏掉的时候——那时你才真正知道需要什么样的测试来拉住那匹脱缰的马。很多团队统计里,出问题最多的不是没测试,而是坏掉之后没人敢改那些装了满身测试的代码。
隐形成本:为了可测试而做出的结构妥协
TDD常被包装成一种“免费”的质量保险,其实它有巨大的隐形成本。首先是设计成本:为了可测性,我们引入大量接口、抽象和依赖注入容器。这些设计一般被称为“良好设计”,但很多都是为了适应测试框架才存在的,而非业务本身需要。换句话说,我们为了测试而把代码拆得面目全非。其次是维护成本:测试本身也是代码,有着同样的维护负担。当业务快速迭代时,测试套件往往会拖慢脚步。我见过一个团队,为了保持100%覆盖率,把两个布尔值的组合测试全部显式写出,一共17个用例,可实际业务逻辑只有一个if。这不是安全感,这是自欺欺人。
我的TDD:把Test-Driven Development改成Test-Driven Design
那么TDD还值得用吗?我的答案是:要,但请把“TDD”重新诠释为“Test-Driven Design(测试驱动的设计)”,而不是“Test-Driven Development(测试驱动开发)”。区别在哪?Develop是写代码,Design是塑形。你写测试的目的不是验证代码是否正确,而是逼迫自己去回答一个问题:这个模块暴露的契约是什么?一旦你能写下测试,你其实是在描述行为边界;而实现可以迟到。所以我不再要求先写测试,而是要求先写可测试的接口。等接口稳定后,再补上那些真正有价值的测试——能反映业务关键路径和异常分支的测试。至于那些琐碎的setter、getter,别浪费人生在它们上面。
一个务实的折中策略
如果你打算采用TDD,我推荐一个折中策略:只对核心领域逻辑使用严格TDD;对基础设施和CRUD代码,可以事后补冒烟测试。别把覆盖率当KPI,它只能说明你执行了很多代码,不能说明你保护了重要的东西。每次写完代码,问自己:如果这段逻辑明年变了,哪个测试会站在我的对立面而不是站在我这边?学会让测试当你的同事,而不是你的上司。你可能觉得我在反对TDD,其实我反对的是盲从——以及那些把测试数量当政绩的口号。工具是为人服务的,别反过来当工具的奴隶。