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

上节课我们介绍了测试的基础结构——四阶段测试。也就是将每个测试都看作四个依次执行的阶段:初始化、执行测试、验证结果和复原。并且,我们着重介绍了该如何使用状态验证来验证测试的结果。那么这节课,我们将介绍另一种验证方式——行为验证。以及为什么你应该尽量避免使用它。

验证结果——行为验证

行为验证是指通过待测系统与依赖组件(Depended On Component)的交互,来判断待测系统是否满足需求的验证方式。其验证方式如下图所示:

行为验证背后的逻辑是,状态的改变是由交互引起的。如果所有的交互都正确,那么就可以推断最终的状态也不会错。例如,对于如下的代码:

interface Counter {
  void increase(); 
}

class SUT {
  public void action(Counter counter) {
    counter.increase();
  }
}

功能需求是SUT的action方法调用计数器Counter使其计数增加。按照状态验证,我们需要从Counter中获取内部计数,然后判断在执行测试前后,计数是否增加。

而对于行为验证,因为计数增加与否只在于是否调用了increase方法。那么如果SUT调用了increase方法,我们就可以推测Counter的计数也必然增加了。于是,我们可以将对于读数增加的验证,转化为对于increase方法调用的验证:如果increase方法被调用了,那么我们可以推测SUT是满足功能需求的。状态验,行为推

那么要怎么使用行为验证呢?让我们将命令行参数解析中的测试,改写成行为验证:

对比状态验证和行为验证,在测试上下文中,我们引入了测试替身(Test Double)来作为测试上下文中的依赖组件,然后使用测试替身的验证方法代替了测试框架提供的断言方法。具体到这个例子中,我们使用Mockito API中的verify替换了JUnit API中的assert方法。

在参数解析的例子中,行为验证和状态验证几乎没什么差别。如果硬要说的话,状态验证稍稍容易懂一些。那么我们为什么要使用行为验证呢?

旨在降低测试成本

在TDD社区中,行为验证主要是为了降低测试成本。再让我们看一下开篇词中“测试应用”的例子,如果换做行为测试的话,我们就不需要使用实际的数据库了:

对于类似于数据库这样的进程外组件,我们都可以通过类似的手段加以处理。也就是说,明确指明待测系统如何与进程外组件交互,并以此为基准,验证待测组件的行为是否满足需求。类似的场景还有很多,比如三方支付服务、消息队列(Message Queue)或者其他微服务等等。

除了进程外组件,还有一种情况是进程内组件的状态难以获得。最典型的例子就是具有图形界面(GUI,Graphics User Interface)的应用,比如Android App、Eclipse RCP等等。在有GUI的情况下,我们需要测试视图(View)与模型(Model)的状态一致。通常视图中的状态难以获取,或者获取成本极高(比如我就有过很多次需要从头写GUI TestDriver的情况)。在这种情况下,也可以使用行为验证来代替状态验证,完成测试。

对TDD用处不大

无可否认,行为验证是一种有用的技巧,但是对TDD用处不大。在开篇词“测试应用”的例子中,我们是改写的已有测试。而如果重头来一遍的话,你就会发现问题所在了:

是不是感觉把实现代码先在测试中写了一遍,然后又搬回到生产代码中去了?除了多写了一遍实现代码,到底有什么好处?怎么有一种脱了裤子放屁的感觉?

除了直观的体验很差之外,行为验证的逻辑也与TDD的核心逻辑冲突。

上节课我们讲过,状态验证是将测试上下文与待测系统当作一个整体的黑盒验证,而行为验证就是将它们看作分离组件的白盒验证。

它的逻辑是通过测试功能是如何实现的,来推断结果是否正确。换句话说,行为验证本身并不能验证功能是否正确,而只能验证功能是否按照某种方式实现。如果按照某种方式实现,那么就可以推测出功能是正确的。

这与TDD的核心逻辑就冲突了。在TDD的红/绿/重构中,重构要求在功能不变的前提下,改变实现方式。而对于行为验证而言,实现方式改变就是功能改变。因而重构就无法进行!需要重写!也就是说,行为验证会阻碍TDD的进行

仍然以“测试应用”为例,如果我们将JPQL的实现方式改为Criteria API,那么行为验证就会失败,而状态验证则不会:

丧失测试的有效性

除去与TDD的逻辑冲突之外,行为验证还可能会丧失测试的有效性。特别是在依赖复杂的框架或是进程外组件的时候。

还是“测试应用”的例子。在这个例子中,领域模型存取的成功并不仅仅依赖于代码,还依赖于JPA的元数据与配置。那么,仅仅通过与EntityManager的交互,并无法验证元数据的配置是否正确,类似的还有查询语句本身的正确性等等。

虽然行为验证的主要目的是降低测试成本,但如果丧失了测试的有效性,那么成本再低也是无意义的。

小结

今天我们介绍了行为验证,以及如何通过行为验证推测待测系统是否满足功能需求。虽然在特定场景下,行为验证是非常有用的测试技术,但它并不合适作为TDD的默认验证方式。为了保证TDD中的红/绿/重构循环,我们应该尽量使用状态验证。

至于降低测试成本,在云时代的今天我们有了其他的选择(比如Test Container、Mountebanke等进程外测试替身),并不一定非要使用行为验证。

需要提醒的是,虽然行为验证会大量使用测试替身技术,但并不是所有的测试替身都是行为验证。Martin Folwer在他的名篇《Mock不是Stub》中,对于不同的测试替身给予了充分的说明。

思考题

虽然我们反复提醒,行为验证不适合作为TDD的默认验证方式,那么在哪些特定情况下,行为验证即不会影响重构又能降低测试成本呢?

编辑来信

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