跳转至

06 TDD中的测试(2):行为验证为什么应该尽量避免使用?

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

上节课我们介绍了测试的基础结构——四阶段测试,也就是将每个测试都看作四个依次执行的阶段:初始化、执行测试、验证结果和复原。并且,我们还着重介绍了该如何使用状态验证来验证测试的结果。

今天这节课,我们将介绍另一种验证方式——行为验证,以及为什么你应该尽量避免使用它。

验证结果——行为验证

行为验证是指通过待测系统与依赖组件(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)或者其他微服务等等。

除了进程外组件,还有一种情况是进程内组件的状态难以获得。最典型的例子就是具有图形界面(Graphics User Interface,GUI)的应用,比如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日零点。所以非常希望你能跟上我们的更新进度,多动手实操,并记录学习体会。

最后,希望我们都能好好学习,更上层楼!

精选留言(15)
  • 邓志国 👍(23) 💬(2)

    对一些第三方系统的使用,比如发送短信。我可能只需要关心短信发送这个动作是否被调用了,那么这个时候Mock发短信的动作我觉得是可以的。

    2022-03-22

  • leesper 👍(5) 💬(1)

    老师,看你讲到领域模型,那您一定也是DDD高手嘛?能开个课讲讲DDD吗,感觉落地好难

    2022-08-18

  • byemoto 👍(1) 💬(1)

    《Google软件工程》书中第13章提到,在有些情况下,交互测试是必要的: 1. 不能使用实际实现或伪实现。 2. 调用函数的次数或顺序的差异会导致非预期的行为。 这里的交互测试即行为验证。

    2022-04-28

  • davix 👍(1) 💬(1)

    請教函數式編程中的測試一般是哪種類型?

    2022-04-09

  • Adoy 👍(1) 💬(2)

    对于想OptionParser这样设计成Inverse of Control风格的API,mock一个parser来做行为验证比较合理。类似的前端onClick,onChange这样的API,行为验证的测试实现起来更简单直观,也不影响组建内部的重构。

    2022-03-22

  • lvxus 👍(0) 💬(1)

    参照测试金字塔,层级最高的那层使用行为测试,应该符合减少成本而又不太影响重构?

    2022-04-23

  • Sudouble 👍(0) 💬(1)

    功能、结构本身改动不大之处,适用于行为验证。基于行为的验证不合适的主要原因在于实现方式变化,以及无法感知到实现变化后,实际输入输出变化。相应的,主要思考在于哪个位置变化小?可以推广至再上层的集成测试、功能测试级别,或者说API接口。

    2022-04-10

  • 阿崔cxr 👍(0) 💬(1)

    找到程序的边界,对于那些非自己程序的逻辑,比如第三方库,就可以 mock 掉。也就是使用行为测试

    2022-03-23

  • 邓志国 👍(0) 💬(1)

    如果我的业务逻辑依赖于一个Repository接口,我用内存实现了一个这样的接口。比如测试一个新增后,我会去这个Repository内存实现中查找到新增的对象。这样的测试替身算是行为验证还是状态验证?

    2022-03-22

  • 程序袁帅 👍(31) 💬(0)

    我之前把《Mock 不是 Stub》这篇文章翻译成了中文,分享给大家: https://www.yuque.com/yuanshenjian/agile/mocks-are-not-stubs 这篇文章有点长,但仔细读两遍,会有很多收获,可以作为CTO专栏的很好补充

    2022-04-05

  • 努力努力再努力 👍(2) 💬(0)

    问题:行为验证不适合作为 TDD 的默认验证方式,那么在哪些特定情况下,行为验证既不会影响重构又能降低测试成本呢? 1. 应该将行为验证放在接口而非实现上,例如老师例子中的 EntityManager.createQuery 方法就是一种具体的实现,如果创建一个接口 Db.query() ,行为验证只验证这个接口是否被调用即可,对于接口内如何被重构,也不会影响行为验证的结果。 总结:我认为就是,行为验证适合不变的接口,不适合具体的实现

    2022-09-14

  • zenk 👍(1) 💬(0)

    验证是否正确调用了依赖的第三方组件提供的接口,用行为来验证感觉比较适合

    2022-05-06

  • davix 👍(1) 💬(0)

    如果測試協議,是不是應該用行為測試?假如我要自己實現TCP,要保證它開始三步握手的包內容及次序的正確,slow start、close等包次序準確等場景

    2022-04-09

  • 士心23 👍(0) 💬(0)

    repo中的SQL语句如何验证是否存在语法错误呢,有没有办法测试

    2024-04-28

  • 常文龙 👍(0) 💬(0)

    课后题:行为验证不适合作为 TDD 的默认验证方式,那么在哪些特定情况下,行为验证既不会影响重构又能降低测试成本呢? 答:属于SUT的契约部分,就可以用。也就是,SUT声明了调用A接口,会达到什么效果,这个“效果”就是可以行为验证的。举个例子,调用了订单支付接口,就一定会创建一个支付单,因此对于pay接口的行为验证,就是createPayOrder被调用了。

    2022-09-23