一、设计策略
执行单元测试的代码可以多种多样,但编写单元测试代码必须有规律地遵循。
测试的对象一般情况下分为方法(包含构造函数)、属性,因此我们按照这两个方向来确定单元测试的标准。由于现在大多数开发人员还没有真正使用过单元测试工具,对于单元测试的了解程度也不高。因此我们这里也不便于制定非常多的标准。我们当前主要针对两大方面进行规范:
哪些代码需要添加单元测试?
单元测试代码的写法?
二,哪些代码需要添加单元测试
当前项目正处在一个最后冲刺阶段,主要的大部分编码工作已经基本完成。因此要全面的添加单元测试,其实是比较大的投入。所以单元测试不能一次性的全部加上,我们只能通过一步一步的来进行测试。
第一步,应该对所有程序集中的公开类以及公开类里面的公开方法添加单元测试。
第二步,对于构造函数和公共属性进行单元测试。
第三步,添加全面单元测试。
在产品全面提交之前可以先完成第一步的工作,二三步可以待其他所有功能完成之后再进行添加。由于第二三步的添加工作其实于第一步类似,只是在量上的累加,因此我们先着重讨论第一步的情况。
在作第一步单元测试添加的时候,也需要有选择性的进行,我们要抓住重点进行测试。首先应该针对属于框架技术中的代码添加单元测试。这里就包含操作数据库的组件、操作外部WebService的组件、邮件接收发送组件、后台服务与前提程序之间的消息传递的组件等等。通过为这些主要的可复用代码进行测试,可以大大加强底层操作的正确性和健壮性。
其次为业务逻辑层对界面公开的方法添加单元测试。这样可以让业务逻辑保持正确,并且能够将大部分的业务操作都归纳到单元测试中,保证以后产品发布之后,一旦出现问题可以直接通过业务逻辑的单元测试来找到BUG。
剩下的代码大部分属于代码生成器生成的,而且大多数的操作都是类似的,因此我们可以先针对某一个业务逻辑对象做详细的单元测试。通过这样的规定,单元测试添加的范围就减少了很多。
三,单元测试代码的写法
在编写单元测试代码的时候需要认真的考虑以下几个方面:
所测试的方法的代码覆盖率必须达到100%。
所测试的代码内部的状态,例如执行了某个方法之后,该方法所在的类中某个属性或者返回值是否与预期相同。
被测试代码所使用的外部设备的状态,如数据库是否可读、网络是否可用、打印机是否可用、WebService是否可用等等。
每一段单元测试代码,必须考虑到以上的三个问题,并且对于这些问题都要有相应的测试。
一般情况下,代码覆盖率低,说明测试代码中没有过多的考虑某些特殊情况。特殊情况包括:
边界条件数据,比如值类型数据的最大值、最小值、DBNull,或者是方法中所使用的条件边界,例如a>100那么100就变成了这个数据的边界。而且在测试的时候还必须把超出边界的数据作为测试条件进行测试。
空数据,一般空数据对应于引用类型的数据,也就是Null值。
格式不正确数据,对于引用类型的数据或者结构对象,类型虽然正确但是其内部的数据结构不正确的数据。例如一个数据库实体对象,数据库中要求其某个属性必须为非空,但是这时我们可以属于一个空。这样这个对象就属于一个不正确数据库。
这三种数据都是针对被测试方法中所使用的外部数据来说的。方法中使用的外部数据无非就是方法参数传入的数据和方法所在的对象的属性或者字段的数据。因此在编写测试代码的时候就必须将这些使用到的数据设置为上面这几种情况的数据来检测方法执行的情况。这才能保证方法编写是正确的。
在编写单元测试代码的时候先了解到被测试方法可能会使用的外部数据,然后将这些外部数据一次设置为上面规定的这几种情况,然后再执行方法。这样就基本可以达到外部数据所有情况都能够正确测试到了。
通过这种方法编写的单元测试代码覆盖率一般可以超过80%
在编写单元测试的时候,不能单纯的追求代码覆盖率。有时候代码覆盖率已经达到了100%,程序也能正常运行,但是可能会出现方法执行完毕之后某些数据并非预期的数值。这时就必须对执行的结果进行断言。在.NET提供的单元测试模块中,可以在单元测试中直接使用一个类的一些静态方法来判断某个值是否达到了预期的情况。这个类是Assert。在这个类中公开了很多判断等效性、判断开关性、判断非空性等一系列方法。这些方法可以让你提前做出预测,一旦程序执行之后,如果这些断言不能通过,就代表代码有错误。
在使用断言的时候,我们要求要达到平均5行测试代码就要有一个断言。
通过添加断言,我们就可以对程序执行过程中数据的正确性做一个检测,保证我们的程序不出现写错数据的情况或者出现错误状态的情况。
当代码覆盖率和预期值都达到了我们的要求之后,整个程序其实就基本达到了质量标准。但是这样还不全面,因为很多程序都要使用到外部的设备或者程序,例如数据库、打印机、网络、串行口、并行口等等。当这些设备发生改变或者不可用的时候,程序就可能出现一些不可预知的错误。因此一个健壮的程序也必须考虑到这些情况,这时通常都是通过将这些设备确定的设置为这些不正常状态来检测程序可能会出现的问题。然后再在测试程序中将这些条件加上。
四,单元测试解耦依赖
解耦,目的是为了方便单元测试。当然,另一个目的是为了保持程序的扩展性。
思想工具:为了同时达到单元测试与代码解耦(或者称为设计优良的OO代码),那么依赖注入的思想是必不可少的工具。
之所以说是思想,从设计的角度来说,这确实是需要思想上的超越;
之所以说是工具,是因为有许多工具可以实现这一思想,如Ninject,Unity。
简要如下图所示:
外部依赖:是指在系统中代码与其交互的对象个,而且无法对其进行人为控制。(最常见的例子是文件系统,线程,内存和时间等)
为什么要解除外部依赖,对于一个函数来说,只关注某一功能(即SRP,除非你想把所有的事情在一个方法内做完,但这不是OO,也没有讨论的价值)。
反测试设计的本质是代码依赖于外部资源,即使其逻辑非常正确,也可能导致测试失败。在系统代码的类或者方法可能依赖于多项不可控的外部资源。
对于解依赖,我们有很多方式,不过在我们常用的方式当中,大多使用桩(Stub)对象和模拟(Mock)对象。
桩(Stub)对象,是对系统中现有依赖项的一个替代品,可人为控制的。通过桩对象,无需涉及依赖项,即可直接对代码进行测试。
模拟(Mock)对象,是系统中的一个伪对象,用来决定一个单元测试是通过还是失败。它通过验证被测试对象和伪对象之间是否进行预期的交互来判断。通常每个测试只有一个伪对象。
交互测试,是用来测试一个对象如何向另外一个对象传递信息。或者如何从其他对象接收信息,即测试对象如何与其他对象交互。
桩(Stub)对象,是对系统中现有依赖项的一个替代品,可人为控制的。通过桩对象,无需涉及依赖项,即可直接对代码进行测试。
模拟(Mock)对象,是系统中的一个伪对象,用来决定一个单元测试是通过还是失败。它通过验证被测试对象和伪对象之间是否进行预期的交互来判断。通常每个测试只有一个伪对象。
上面代码中,如果我们写单元测试时会发现,WriteLog方法依赖于文件或数据库时,这时测试会比较困难一些。首先我们需要对解除依赖,再进行测试。
这时我们引入了ILog接口,在调用Valid方法前,需要对属于Log进行赋值,这里,我们便解除了对象之间的依赖。我们开始写单元测试,测试中,我们需要自己模拟一个桩对象。
桩对象是对系统中现有依赖项的一个替代品,可人为控制,通过使用桩对象,无需涉及依赖项,即可直接对代码进行测试。
这里我定义了一个类TestLog,这个类所生成的对象就是一个桩对象,桩对象的目的是为了替换测试中的依赖项,有些时候,依赖项可能需要文件或某些配置,导致很难测试,所以,为了方便测试,我们使用了桩对象。
上面的例子中,我们通过接口完成了对象间的依赖解除,再通过属性完成对接口的赋值,如果不使用属性,我们还可以用别的方法对接口进行赋值
开发的过程中,我们都会遇到对象间的依赖,比如依赖数据库或文件,这时,我们需要使用模拟对象,来进行测试,我们可以手写模拟对象,当然也可以使用模拟框架。
假如有这样的一个需求,当用户登陆时,我需要对用户名和密码进行验证,然后再将用户名写入日志中。
上面的代码在验证完登陆信息后,需要向日志中写入用户名,由于写入日志可能依赖于文件或数据库,我们可能很难进行测试,所以,这里使用模拟对象进行测试。手写模拟对象,代码如右:
这里我们定义了一个对象TestLog对象,该对象就是一个模拟对像,继承了ILog接口。该测试中,一共进行了两项测试。一项是:验证用户名和密码是否输入正确。另一项是:验证用户写入日志的信息是否正确(比如应该写入用户名,结果把密码写入了日志,测试会无法通过)。
这里我们区分一下模拟对象与桩对象。上一节中,我们讲过桩对象的定义,那么模拟对象与桩对象是什么关系呢?
模拟对象与桩对象在写法上区别很小,关键在于模拟对象需要进行断言,也就是说模拟对象可以导致测试失败。桩对象只是为了方便测试所定义的一个对象,不需要进行断言,所以,桩对象永远不会导致测试失败。
上面的测试中,如果我们去掉最后一行代码,即我们不进行写入日志的断言,则该对象就是一个桩对象。
本文经验来自好友肥某的分享。