# TDD中的测试

无论用什么样的测试框架,每个测试都会由四个依次执行的阶段构成:初始化(setup)、执行测试(exercise)、验证结果(verify)和复原(teardown)。这四个阶段主要的作用是:

  • 初始化:设置测试上下文,从而使待测系统处于可测试的状态。例如,对于需要操作数据库的后台系统,测试上下文包含了已经灌注测试数据的测试数据库,并将其与待测系统连接。
  • 执行测试:按照测试脚本的描述与待测系统互动。例如,按照功能描述,通过API对系统进行相应操作。
  • 验证结果:验证待测系统释放处于我们期待的状态中。例如,经过测试,数据库中的业务数据是否发生了期待中的改变。
  • 复原:将测试上下文、待测系统复原回测试之前的状态,或者消除测试对于待测系统的副作用。例如,删除测试数据中的脏数据,或者通过事务回滚。

如果测试时有进程的依赖,比如servlet容器、数据库、消息中间件、第三方服务等组件,测试上下文的设置,也就是初始化的过程将直接影响编写测试的难度、以及维护测试的成本。在测试的四个步骤中,验证结果是最核心的一步,也是最核心的技术。验证结果又两种方式,一种是状态验证,一种是行为验证

# 状态验证

状态验证是指在与待测试系统交互后,通过比对测试上下文与待测试系统的状态变化,判断待测系统是否满足需求的验证方式。状态验证是一种黑盒验证,他将测试上下文与待测系统当做一个整体,当待测系统不存在内部状态,而通过作用于依赖组件达成功能时,他们从依赖组件中获取状态,以验证待测系统。比如,我们在某个业务系统中保存或者注销了一个用户的信息,那么测试就可以通过验证数据库中这个用户某个表示注销成功或者保存生效的状态,即可完成验证。

状态验证需要大量的使用断言方法来判断状态,状态验证的难点是复原测试的上下文,消除因执行测试造成的状态累积,我们也可以把对固定值的验证,转化为对状态增量的验证,这样可以减少一些对本地依赖的影响。例如,验证用户注册是否成功,可以从原来验证注册用户的姓名、地址等相关状态信息,转化为验证数据库中用户表的增量,注册成功后,用户表自然会增加,这种增量验证方式可以让我们对状态的依赖,进而降低状态累积的影响。

# 行为验证

行为验证是指通过待测系统与依赖组件的交互,来判断待测系统是否满足需求的验证方式。行为验证背后的逻辑是,状态的改变是由交互引起的,如果所有的交互都正确,那么就可以推断最终的状态大概率也不会错。例如,如下代码:

interface Counter {
  void increase(); 
}

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

功能需求是SUTaction方法调用计数器Counter使其计数增加,按照状态验证,我们要从Counter中获取内部计数,然后再判断执行前后,计数是否增加。对于行为验证,因为计数增加与否在于是否调用了increase()方法,那么如果调用了increase()方法,我们就可以暂时推测计数器的计数必然增加。这种做法其实就是将对读数增加的严重,转化为对于increase()方法调用这个行为的验证。

对于类似数据库、第三方的鉴权或者短信验证这样的进程外组件,我们可以通过明确指明待测系统如何与进程外组件交互,并以此为基准,验证待测组件的行为是否满足需求。类似的场景还有第三方支付、消息队列或者其他微服务等等。除了进程外的组件,还有一种情况是进程内的组件的状态难以获得,例如图形界面等应用,我们需要测试viewmodel的状态是否一致,这种情况下,也可以使用行为验证来代替状态验证完成测试。

不过行为验证也带来一个问题,就是它的逻辑是通过测试功能是如何实现的,来推断结果是否正确,行为验证本身并不能验证功能是否正确,而只能验证功能是否按照某种方式实现,这与TDD的核心逻辑就有了一定的冲突,在TDD的红、绿、重构中,重构要求功能不变的前提下,改变实现方式,而对于行为验证而言,实现方式改变会影响测试结果,因而重构就无法进行,重构就需要重写测试,所以这种验证行为如果没有认识清楚,会阻碍TDD的进行。另外我们当前很多开发都依赖于注解(annotation)和一些元数据的配置,这种情况下仅仅通过交互行为,是无法验证元数据是否配置正确,类似的还有查询语句本身是否正确等行为。虽然行为验证的主要目的是降低测试成本,但如果丧失了测试的有效性,那么这种方式即便成本低也会毫无意义。

# 单元测试

在TDD的语境下,单元测试指的是能提供快速反馈的低成本研发测试。在不做任何强调的情况下,它会指一个针对不涉及进程外组件的单一软件测试。为了让测试能够聚焦到单一的单元,就需要拆分单元间的依赖,那么最终会得到一组彼此间没有耦合关系的小粒度对象。

有些实践为了让测试能够聚焦到单一的单元,会拆分单元间的依赖,最终得到一组彼此间没有直接耦合关系的小颗粒度对象。这种将所有直接偶尔都视为坏味道的设计取向,会将功能需求的上下文打散到一组稀碎的对象群落中。这么做只能增加代码本身的理解难度,最终走向过度设计的深渊。代码的坏味道通常源自过高的认知负载,一段代码如果会增加认知负载就不一定是特别好的设计。

很多人对TDD的批评主要是集中在:不一定能得到好的设计,可能编写无用的测试,以及阻碍重构的进行这几个点上,就像行为测试那样,如果一味的追求单元测试的结果,那无疑是一个不好的实践。在TDD中,其实从来没有强调必须要以什么样的形式来写单元测试,必须要进行怎样的集成测试,而是大多数情况下是针对不同单元粒度进行功能测试,并通过这一系列不同单元颗粒度的功能测试,来驱动软件开发。TDD的核心要义不是100%测试覆盖率这个结果,而是要通过测试来驱动开发这一行为

单元测试是一个具有误导性的提法,很多人对于单元测试的理解都不同,有些人提意将TDD中的测试叫做Xunit Testing,以区别行业中的叫法。过内也有人将其叫做“单元级别的功能性测试”,这种测试通常用如下几个特点:

  • 测试是由不同粒度的功能测试构成的。
  • 每一个测试都兼具功能性严重和错误定位功效。
  • 要从发现问题和定位问题的角度,去思考测试的效用和成本。
  • 单元粒度要以独立的功能上下文或变化点为粒度。