你好,我是徐昊。今天我们来继续学习测试驱动开发中的测试。

上节课我们介绍了行为验证,以及为什么它不应该是TDD的主要验证方式,而应该尽可能地采用状态验证。至此,我们介绍完了测试驱动开发中测试的基本结构,及其主要的验证方法。

有了这些做基础,我们再来讨论一下测试驱动中测试的性质,以及为什么称呼它为“单元测试”是一种误解。

集成测试还是单元测试?

首先请回忆第一讲中的视频演示,我们从功能出发,分解出来的第一个驱动开发的测试,是针对布尔选项的测试:

//ArgsTest

@Test
public void should_set_boolean_option_to_true_if_flag_present() {
  BoolOption options = Args.parse(BooleanOption.class, "-l");
  assertTrue(options.logging());
}

static record BooleanOption(@Option("l") boolean logging);

而后来在重构中(参看第三讲),我们抽取了OptionParser接口,并将上面的测试改写成了一个范围更小的测试:

//OptionParsersTest.BooleanOptionParser

@Test
public void should_set_value_to_true_if_option_present() {
  assertTrue(OptionParsers.bool().parse(asList("-l"), option("l")));
}

这两个测试看起来没有什么不同,测试的功能也是一致的功能。但如果严格区分的话,第一个测试是所谓的集成测试(Integration Test)或功能测试(Functional Test),而第二个测试,则是我们常规意义上的单元测试(Unit Test),也就是对于单一单元的测试。

如上图所示,第一个测试的测试范围包含了Args和OptionParser,可以看作是“集成测试”或“功能测试”。第二个测试的测试范围仅仅覆盖OptionParser,才会被看作是“单元测试”。

正如我们前面提到的,这个OptionParser接口并不是一开始就存在,而是通过重构获得的。在重构的过程中,我们从Args中分离了部分代码,从而使得ArgsTest的测试范围不再聚焦于某一个单元,而是覆盖了更多的单元。

于是ArgsTest就成了“集成测试”。更有意思的是,在重构的过程中,虽然这个测试没有任何变动,但是到重构结束的时候,它就从“单元测试”变成了“集成测试”。

TDD中的单元测试

既然我们没有改变测试代码,而仅仅通过重构就改变了测试的种类,那么我们区分不同种类的测试还有什么意义呢?为什么TDD社区又特别爱强调要编写“单元测试”呢?

在TDD的语境下,“单元测试”指的是能提供快速反馈的低成本的研发测试(Developer Test)。TDD社区希望通过强调“单元测试”,来强调这些特点。但是“单元测试”这个词,并不是由TDD社区发明的,而是软件行业中由来已久的一个词汇。在不做任何强调的情况下,它会指针对不涉及进程外组件的单一软件单元的测试。

为了让测试能够聚焦到单一的单元,就需要拆分单元间的依赖,那么最终会得到一组彼此间没有直接耦合关系的小粒度对象。而如果强制ArgsTest也遵守“单元测试”风格,那我们大概会得到这样的结果:

或许有人会说,这不正是好的面向对象设计吗?这不正是彻底的解耦吗?这种将所有直接耦合都视为坏味道的设计取向,会将功能需求的上下文打散到一组细碎的对象群落中,增加理解的难度。最终滑向过度设计(Over Design)的深渊。

坏味道通常源自过高认知负载(Cognitive Load)或不易修改,俗称“看不懂改不动”。比如,坏味道过长的方法(Long Method)不是以绝对代码长度来衡量的,而是以“是否超出认知负载或难以改变其中的行为”来衡量的。而将功能上下文切得过于细碎,也会增加认知负载。因而不能单纯地认为,这种风格就一定是好的设计

而对于进程外组件,正如我们在上节课讲到的,需要将连接真实系统的状态验证转变为行为验证,才能消除对于进程外组件的依赖。由此带来的问题我们已经详细讲解过了:可能会丧失测试的有效性,并阻碍重构

不一定能得到好的设计,可能编写无用的测试,以及可能会阻碍重构而不是像宣称地那样辅助重构,这三点正是很多人对TDD的批评(或者说是疑惑)。比如 Ruby on Rails 的作者David Heinemeier Hansson(DHH)就曾极力抨击TDD,并写下长文 TDD is dead, long live testing (2014年),痛斥单元测试无益于软件开发。

然而事实真相是,在测试驱动开发中,从来没有强调必须是“单元测试”。反而在大多数情况下,都是针对不同单元粒度的功能测试。并通过这一系列不同单元粒度的功能测试,驱动软件的开发(正如你在第1-4讲中看到的那样)。

在DHH与Martin Fowler、Kent Beck关于TDD的圆桌会议中,Kent Beck也反复强调,他几乎从不写“单元测试”,而主要是通过构造恰当粒度的黑盒功能测试驱动开发。之后Martin Folwer也再次写文章谈论单元测试,并在其中阐述了TDD社区所谓的单元测试到底是什么。也就是我们前面说的,能提供快速反馈的低成本的研发测试。Martin Fowler还建议将TDD中的测试叫作极限单元测试(Xunit Testing),以区别于行业中的叫法。

显然,“单元测试”是一个极具误导性的提法,甚至是TDD不能得到广泛传播的罪魁之一(也是几次TDD大论战的焦点)。这也是为什么从2009年起,我就不再使用“单元测试”来指代TDD中的测试了。转而使用“单元级别功能测试(Unit Level Functional Test)”来指代TDD中需要的那种测试

“单元级别功能测试”这个名字同时具有“单元”和“功能”。通过这个名字,我想要强调这样几点:

无论是“极限单元测试”还是“单元级别功能测试”,从今天起,请停止使用“单元测试这样源自测试领域的名字。毕竟TDD中的测试,并不是一种关于测试的技术,而是通过分解功能以驱动软件开发的技术。

小结

今天我们讨论了TDD中的测试到底是什么。它不是行业中所谓的“单元测试”,而是指能提供快速反馈的低成本的研发测试,也是针对不同粒度单元的功能测试。我们要从发现问题和定位问题的角度出发,去理解和思考每一个测试的功效。

在理解了TDD中测试的性质之后,我们就可以开始讲解这样的测试到底驱动了软件哪些部分的开发,以及它到底是怎么驱动的。

思考题

请结合课程内容,反驳David Heinemeier Hansson(DHH)的“TDD已死”。

编辑来信

TDD是一项技能,唯有动手实操、反复练习,才能有所小成。为了帮助你更快地进步,徐昊老师特发起了“TDD专栏首发·代码评点”活动。
 
在第一个实战项目结束后,我们会根据你提交的学习反馈,手动选出其中几位进行代码评点与解疑答惑。而评点的详细内容我们也将制成加餐,展示在专栏里,供其他同学学习与参考。
 
划重点!如果学完第1-10讲再写反馈,将会大大提高你入选的机会!另,此次收集时间截至4月3日零点。所以非常希望你能跟上我们的更新进度,多动手实操,并记录学习体会。
 
最后,希望我们都能好好学习,更上层楼!