前两天,一个微信好友,也是我《Rust 第一课》的读者,就我课程中这段话跟我讨论了几句 TDD(Test Driven Development):
在撰写实现之前撰写单元测试,这是标准的 TDD 的方式。我个人不是 TDD 的狂热粉丝,但我会在构建完 trait 后,就为这个 trait 撰写测试代码。因为在写测试代码的时候,就是一个很好地验证接口是否好用的时间点。我不希望实现完 trait 之后,才发现 trait 的定义有瑕疵,需要修改,这个时候改动的代价就比较大了。所以,当 trait 推敲完毕,我会开始写使用 trait 的测试代码,感受 trait 在使用过程中的体验。此刻,如果写测试用例时用得不舒服,或者为了使用它需要做很多繁琐的操作,那么我会重新审视 trait 的设计。你如果仔细看单元测试的代码,就会发现,我秉持测试 trait 接口的思想。尽管在测试中我需要一个实际的数据结构进行 trait 方法的测试,但核心的测试代码,我都是使用泛型函数,让这些代码只跟 trait 相关。这样一来可以避免某个具体的 trait 实现的干扰,二来可以让我未来加入更多 trait 实现时,可以共享测试代码。比如未来我支持 DiskTable,那么只消加几个测试例,调用已有的泛型函数即可。
他觉得这似乎和 TDD 的思想并不一致,并问我我对于网上 TDD 已死的言论是怎么看待的。其实 TDD 已死这样的言论大概 14 年前后就出来了,几年前我为了反驳还把自己对 TDD 的思考总结为一篇文章:如何用正确的姿势打开 TDD?这篇文章里的基本观点都是成立的。我再把最近的一些思考放出来跟大家分享一下。
究竟怎样做才算 TDD?
首先,TDD 并不存在一个特别「官方」的解释。Wikipedia 的定义我觉得算是比较符合我的认知:
Test-driven development (TDD) is a software development process that relies on the repetition of a very short development cycle: requirements are turned into very specific test cases, then the software is improved to pass the new tests, only. This is opposed to software development that allows software to be added that is not proven to meet requirements.
在我看来,一切机械地把 TDD 理解为一系列不得不进行的流程,都犯了教条主义的错误,过于刻板。软件工程与其说是一门科学,不如说是在长时间试错过程中,总结出来的方法论。既然是方法论,那么就有使用它的上下文,或者说场景。所以,掌握 TDD 的精髓,根据自己的实际情况采纳合理的流程,去芜存菁,而不是机械地采纳 TDD 的流程或者教条,才是正途。
那么根据 Wikipedia,TDD 的核心是什么呢?是使用测试去描述需求,然后实现需求,并用测试来验证需求得到了实现。
不幸的是,需求作为一种文字性的表达,很难直观用测试代码去表述。所以,我们需要对需求进行功能上的设计(包括架构),用描述行为的代码来描述实现需求所需要的功能,这样才能用测试代码去调用这些描述行为的代码来表达需求。这听上去很拗口,那么,什么样的代码可以用来描述行为呢?
答案是接口。接口是个宽泛的定义,它可以是 trait (rust) / typeclass (haskell) / interface (java) / behavior (elixir) 等编程语言本身就提供的语法定义出来的接口,也可以是任何的函数或者方法的函数签名。一旦我们把接口和需求对应起来,我们就可以使用相关的接口撰写测试来描述需求。通过这个过程,我们还可以验证我们的接口设计是否合理。
所以在我看来,TDD 是一种验证接口设计的,代价最小的手段。这可能是 TDD 最最重要的功效。借助 TDD,我可以在没有撰写任何实际功能之前,通过测试来感受接口使用的过程是否流畅。如果在撰写测试时发现接口不合理,不好用,不漂亮,表达能力不强,过早决策(比如可以用泛型的地方使用了具体的类型),那么,我们可以花费很小量的代码不断迭代设计接口,直至接口达到了实用与美感的平衡。
如果在使用 TDD 的过程中,达到了这样的效果,那么我就认为这是成功的,高质量的 TDD。至于走什么具体的流程,以什么顺序写测试代码和功能代码,那都是细枝末节,团队可以自己掌控。
TDD 要做到什么程度?
产品要为需求服务,无论是开发代码,还是测试代码,也都要为这一目标而服务。TDD 做到什么程度要看你在哪个层次考虑问题。如果在产品层面,那么它处理的是产品和外界交互的接口,包括产品和用户间的接口,产品和服务器间的接口,以及产品和第三方集成所需的接口;如果在某个模块的层次考虑 TDD,那么它处理的是这个模块和其它模块交互的公开接口(模块和模块间的需求);如果在类或者结构这个层次考虑 TDD,那么它处理的是这个类或者结构和其它类或结构交互的公开方法和属性(类或结构之间的需求)。
你看,我在这几个层次考虑的都只是接口,并且是外部(公开)接口。无论我们采用什么样的架构,引入什么设计思路,使用什么样的手段编码,测试关心的都只该是外部接口。对调用者来说,外部接口是一份严格的契约 —— 用户如何使用产品的契约,客户端如何与服务器通讯的契约,模块如何被其它模块调用的契约,类如何跟其它类发生作用的契约等等。
从这个角度来看,TDD 应该仅限于外部接口,不应该使用在私有接口上。事实上,任何时候,如果你花费时间和精力为私有接口进行测试(不管什么级别的测试),都是有害无益的。私有接口不是契约,在它所处的层级来看,这些接口可以是不稳定的,随需而变的。所以,如果花费心力去测试私有接口,如果这个接口发生剧烈变化,那么,一切投入都会打水漂。
有同学可能会说:外部接口也可能发生剧烈变化啊!
没错。外部接口发生剧烈变化有几种可能:1) 原有的接口设计考虑不周,甚至有问题,无法很好地应对现有的需求,不得不修改。2) 当需求变更时,原有接口不适应新的需求,不得不拓展以应对新的需求。3) 需求发生剧烈变化,导致接口设计需要跟着变化。
对于 1),TDD 可以很好地避免这个问题;对于 2) 在使用 TDD 对接口迭代的过程中,我们可以通过更深入地挖掘潜在的需求,和进行延迟决策,来减少其带来的影响。
我常常说,产品代码和测试代码,你起码要保持其中之一的稳定性,否则你怎么确保正确性?如果你经常发现在修改产品代码时,需要同时修改测试代码,那么要么你的测试引入了太多对私有接口的不必要的测试,要么你的接口定义不好。这样的代码的质量是堪忧的,不稳定的。
TDD 和单元测试是什么关系?
最后,我们来谈谈 TDD 和单元测试的关系。很多人把 TDD 等同于一种在撰写代码前先撰写单元测试的行为,通过上面的分析,现在你应该会觉得这种认识是不妥当的。TDD 是一种思想,这里的 T 可以是任何种类的测试。至于是什么种类,就像上文分析的那样,取决于你在哪个层次考虑问题。下面是应用 TDD 思想在不同层级可以使用的测试方法: