你好,我是徐昊。从今天开始,我们来讨论一下测试驱动开发中的驱动是什么意思。

在上节课,我们讨论了测试驱动中测试的性质。它们不是“单元测试”,而是不同粒度的功能测试。或者如我建议的那样,你可以叫它们“单元级别功能测试”。那么这些单元级别功能测试将会如何驱动我们的开发呢?今天我们就来讨论一下这个问题。

如果当初我们那么做了

上节课我们讲到,TDD中测试针对的粒度是独立的功能上下文或变化点。测试验证功能上下文或变化点符合功能需求。

对于相同的功能,如果我们划分的功能上下文不同,会有什么结果呢?回看之前的TDD演示,在第一讲中,我们讨论了三种实现方式:

  1. 从给定的参数列表中寻找对应选项,并根据选项类型读取参数;
  2. 将参数列表按照选项,分割成由选项名称和参数组成的数组;
  3. 将参数列表按照选项,分解成由选项名称和参数组成的映射。

这三种选择实际上就划分了不同的功能上下文。当时出于实现方式复杂度的考虑,我选择了第一种实现方式。即,从功能上下文的角度考虑,也就是从API视角出发,将全部功能当作一个上下文。那么如果我们当时选择了其他的实现方式,会如何呢?请看接下来的视频演示。

好,到此为止,通过红/绿循环,我们实现了参数分割的功能。剩下的部分,其实与我们之前展示的相差不多。我稍微演示一两个测试,其他的你可以自行操作。

可以发现,对比第一次我们做的实现,不同的实现策略,隐含着不同的功能上下文划分。针对不同的功能上下文,我们编写对应的单元级别功能测试来验证其功能。在这些单元级别功能测试的指引下,我们逐步完成了软件的功能。也就是说,功能上下文的划分,指引我们编写测试;在测试的驱动下,我们逐步完成功能上下文的实现。

从这里,我们似乎可以窥探到测试驱动开发的核心要点:单元级别功能测试能够驱动其对应单元(功能上下文或变化点)的外在功能需求。而对于对应单元之内功能的实现,测试就没有办法了。

以上面演示的视频为例,我们需要将参数列表进行分解。但参数列表的分解是Args.parse内部实现的方式。当我们从功能测试的角度测试Args.parse时,是无法得知Args.parse是如何处理参数列表的。所以,无论是这节课的实现方式,还是第一讲里的实现方式。从Args.parse这个功能上下文来看,测试是极端类似的。

如果我们要驱动单元内的功能实现,该怎么办呢?那么就需要将这个单元对应的功能上下文,分解为更小的上下文,并将功能需求在这个上下文中加以分解。如下图所示:

比如上面演示的视频中,Args.parse是一个大的功能上下文。按照我们的实现思路,我们将它分解成了一个小的功能上下文:将参数列表分解为映射。那么我们可以将参数列表分解放入另外一个单元(Args.toMap),对它进行测试,从而驱动它的实现。

也就是说,单元级别功能测试无法驱动小于其测试单元的功能需求,也无法驱动单元内的实现方式,需要进一步拆分功能上下文才可以。而指引功能上下文拆分的方式有很多,比如有不同的实现思路、架构等。

TDD的极限

曾经有些人希望通过构造难以用测试驱动出实现的需求,来证明TDD不是一种有效的开发方法。对于这些人,我都懒得回应。

第一,是因为这样的需求一点儿都不难构造;第二,TDD并没有宣称它是所有开发问题的答案,所以找到一个反例又能说明什么呢?第三,TDD的效用在于将研发过程工程化。那么无法通过TDD有效实现需求,也只意味着这类问题不能有效工程化而已。排除一个错误答案,并不代表能找到正确的答案。

根据我们上一节的讨论,让TDD丧失驱动力最简单的办法,就是指明某个单元内的实现细节。比如,使用冒泡法对数组进行排序。因为从功能角度来说,冒泡法还是快排序,是没有差别的:

@Test
public void should_sort_by_bubble_sort() {
  assertArrayEquals(new int[] {1, 2, 3}, bubbleSort(3, 1, 2));
}

@Test
public void should_sort_by_bubble_sort() {
  assertArrayEquals(new int[] {1, 2, 3}, quickSort(3, 1, 2));
}

如果我们需要在测试中体现不同排序算法的差异,以驱动不同的实现,那么就需要改用行为验证的方。请观看接下来的视频演示:

至此,我们可以明白,测试驱动开发的主要关注点在于功能在单元(模块)间的分配,而对于模块内怎么实现,需要你有自己的想法。当然Kent Beck说得更直接:TDD可以提高效率,但不能避免愚蠢。

如果真的不知道怎么实现

如果真的不知道该怎么实现,要怎么办呢?那么TDD仍然可以帮你提高效率。就是这么神奇!

还是以ArgsTest为例,如果我就是不知道参数分解这一步到底要怎么实现,那么我能怎么办呢?

正如视频中演示的,就算你真的不知道某个关键的功能要如何实现,只要你能列出对于这个单元的期待,那么你仍然可以完成其余功能,然后再找人帮忙!

只不过此时,你就算找人帮忙,对于这个模块的输入输出也已经了然于胸。当然,你还可以写一组测试,验证别人帮你写的代码是否真的可以完成你想要的功能。甚至你还可以带着测试代码到Stack Overflow上去求助,肯定会有意想不到的收获。

小结

这节课我们讨论了测试驱动开发到底驱动了什么:功能在单元(模块)间的分配。我们也讲了,测试驱动开发在什么地方会失去驱动力:单元(模块)内的实现方式。

那么很有意思的事就来了,从“驱动”的角度来说,TDD实际上并不是一种编码技术(Coding Technique),它无法帮助实现你不会写的代码,你必须要知道如何实现这些功能;但是一旦你明确了要实现的功能,并且知道要怎么实现,TDD可以帮助你更好地将功能放置到不同的单元。也就是说,TDD“驱动”的是架构,因而实际是一种架构技术

是的,这正是我们讲的编码架构师(Coding Architect),也是真正的实干型而非PPT型架构师(当然你可以省略地讲,这是真正的架构师)。这也是为什么TDD也被看作Test Driven Design。当然,我觉得Test Driven Development其实描述得更全面。

思考题

请从架构的角度出发,思考红/绿/重构循环,分别发挥了什么作用?

编辑来信

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