在之前的文章《单元测试培训系列:(一)单元测试概念以及必要性》中最后一段有提到,单元测试其实是完全为了测试先行,测试驱动准备的,并简单阐述了一下实施的流程,很多朋友对此很感兴趣,希望能更深入了解具体是如何实施的。
隔离,是单元测试中最重要的概念。一个被单元测试的方法,需要与所有依赖项进行隔离。而依赖项包括了环境的依赖项(I/O,网络,数据库,系统时间等)以及外部类和方法的依赖。因此,隔离性保障了单元测试是最小粒度的测试。
但隔离也导致了单元测试的局限性,主要是以下两个方面:
1. 通过单元测试是不能检测到一个方法修改后对系统的影响范围的。
单元测试因为隔离了对其他方法的依赖,因此当一个方法因为重构或者修改BUG等原因进行了改变时,运行已有的单元测试只能检测到这个被修改的方法本身是否依然符合以前预期的目标;而对修改这个方法对整个系统有任何影响,是完全无法通过运行单元测试得知的!!
很多朋友一直错把集成测试和单元测试混为一谈,认为单元测试能够检测到一个方法改变后对整个系统哪些部分造成了影响,这种想法很显然是错误的:因为单元测试的每个测试方法都把被测试的方法和其他方法、外部环境隔离开来,每一个被测试的方法都不依赖其他方法的具体实现,因此,即便其他类或方法的实现发生了改变,只要接口依然保持原样,对当前的单元测试是都不会产生任何影响的!!
2. 单元测试对于需求变更基本没有太大作用。
需求发生变更,首先要改变的就是单元测试!因为单元测试的关注点是每个方法的进出项(输入值和输出值)是否满足期望值。当发生需求变更时,意味着相关方法的预期值发生改变,此前相关的单元测试不再具有价值,需要重新编写。
因为以上两个原因,对一个已有系统的代码追加单元测试的价值也变得非常鸡肋了。对于一个已有系统追加单元测试之后,单元测试唯一能在某个方法的内部实现进行重构的时候起到作用(例如修改BUG和算法优化,并且是在不修改当前调用关系以及相关接口的前提下)
基于测试先行来使用单元测试
说了这么多局限性,估计很打击大家积极性,难道单元测试就那么一无是处么?非也,而是单元测试的使用场景没对。
测试先行还是测试驱动也好,都是目标导向方法论的具体实践,其目的都是在编写代码之前先行确定好要编写代码的目标以及校验方式。而再来看单元测试,单元测试只关注一个单独方法本身的功能以及这个方法的进出项是否满足期望值。这根本上就是和测试先行的目标是完全吻合的。可以说单元测试是测试先行的具体实施办法。
有了这个基调,我们再来看如何把单元测试按照测试先行的指导来进行实施,因为该篇文章的关注点还是单元测试,就不过多探讨如何实施测试先行或者测试驱动。
我们直接从拿到一个具体的业务功能模块开始。
一、设计简要的类图以及类之间关系
我们首先简要的为该模块设计出基本的类图和类之间的关系(虽然有些敏捷方法对测试先行的要求是不做设计,只做测试用例,然后再编写测试代码和实现代码,但在这里个人还是按照先设计功能的类结构方式)
在这个阶段,基本只需要定义好几个类,而类里面的成员则没有必要在一开始就全部设计出来。(推荐使用Visual Studio里的项目选项View Class Diagram进行代码和类设计的同步进行)
来看下面这个例子:一个订单系统,订单明细里面的每条订单的产品分为服装和数码产品类,而服装的价格来源是Vancl,数码产品的价格来源是Newegg. 首先这个订单系统需要一个订单统计总价格的功能。
如上图所示,定义了主要的类以及彼此之间的关系, 除了一些数据属性外,还没有定义这几个类的方法。
ProductOrder类, 即定单类,需要实现方法Count,包含一个IList<BaseOrderDetail>类型的属性OrderDetails。
BaseOrderDetail类,抽象类,包含ProductID和Amount属性以及一个IPriceProvide类型的属性PriceProvider,以及一个Count方法,即每条明细合计自己的总价。
ClothingOrderDetail类,BaseOrderDetail的子类,即服装类的订单明细,该类的PriceProvider属性应该为VanclProvider类的实例。
DigialOrderDetail类,BaseOrderDetail的子类,即数码类的订单明细,该类的PriceProvider属性应该为NeweggProvider类的实例。
IPriceProvide接口,该接口定义提供了从第三方获取产品价格的查询方法QueryPriceByProductID。
VanclProvider类,实现了IPriceProvide接口,提供Vancl的产品价格查询。
NeweggProvider类,实现了IPriceProvide接口,提供Newegg的产品价格查询。
二、为某一个类上的某一个公开方法编写单元测试
当类以及类之间关系设计好之后,就开始根据业务功能的需要,逐步设计类的成员以及方法以实现这个类的功能。而在设计一个类的方法时,则是根据业务需求的要求来设计的,因此在编写一个类中的公开方法时,对这个类需要达成什么样的效果,应该是非常明确的。在明确了目标之后,其实编写单元测试就已经可以实现了,尽管现在实现代码还根本不存在。根据上面的例子,我们首先来编写ProductOrder类的Count方法的单元测试,此时,Count方法是没有真正实现的,甚至Count方法都还不存在(在单元测试代码中使用dynamitic关键字调用不存在的Count方法)。
ProductOrder类的Count方法,其实是统计所有单据中包含的所有货物的价格,因此可以分析得知Count方法依赖于BaseOrderDetail类的Count方法,并且属性OrderDetails会包含多个BaseOrderDetail类,这意味着在编写单元测试时,我们需要把这些BaseOrderDetail类都使用Mock对象代替。
在这个单元测试代码中,我们因为估计到实现Count方法需要依赖于OrderDetails属性,并合计里面所有BaseOrderDetail对象的价格,而BaseOrderDetail的价格是通过BaseOrderDetail.Count()方法获得的。我们只是测试ProductOrder的Count()方法,因此不需要再深入思考BaseOrderDetail.Count是如何实现的,只需要对BaseOrderDetail.Count()方法进行Mock即可。因此在这段代码中,添加了三个Mock的BaseOrderDetail对象,并分别设置他们的Count()方法返回值,最后把它们都加入到ProductOrder的OrdeDetails属性中。到此,这个单元测试就写完了,但其实此时,我们的Count方法还根本没实现。所以运行单元测试会抛出异常。
二、编写方法的真正实现逻辑
那么,接下来,我们来实现这个方法的实际逻辑。
Unit Test for Count
现在再运行之前的单元测试,会发现该单元测试已经顺利通过。大家是否看明白:单元测试只需要知道对被测试方法的期望值以及该方法可能依赖的项即可编写,无论这些方法是否已经真的实现。甚至可能的依赖项都是可以不用一开始编写的,而可以等到实现代码时发现需要调用其他方法时,再修改相应的单元测试代码进行隔离。
在这里再多写一个例子,方便大家加深认识。这次对BaseOrderDetail上的Count方法编写单元测试。
Unit Test for Count
这段代码是最简单的单元测试框架,如果执行肯定会因为Count方法还未实现而抛出异常。接下来我们编写Count方法的实现,
Unit Test for Count
可以看到BaseOrderDetail中的Count方法依赖于自身的属性PriceProvider,而该属性本应该是根据订单的类型指定对应的VanclProvder类的实例。如果此时,我们再运行单元测试,必然会引发空引用异常,原因是此时的PriceProvider属性并没有赋值。(在实际运行的代码中可以使用例如Autofac或者Unity之类的IOC容器实现初始化)
因此,我们需要在单元测试中添加Mock对象,来隔离对外部对象的依赖。修改后的单元测试代码如下:
Unit Test for Count
至此,两个单元测试都能顺利的通过验证。
三、当需求发生改变时,修改单元测试以及实现代码
我们假定我们的开发已经进入到一定阶段(已经有了两个类和方法的实现,并已经稳定),这时候需求发生了变更,我们来看下此时应该如何进行操作。
第一种情况,需求变更为,如果订单明细是ClothingOrderDetail类型时,Count方法需要在价格乘以数量之后增加额外的10元邮费。这种情况,其实只需要在ClothingOrderDetail类中重载BaseOrderDetail的Count方法即可,因此编写新的单元测试和方法实现即可。
从这里可以注意到一点单元测试的特性:虽然我们修改了BaseOrderDetail的需求和实现,但ProductOrder中Count方法的单元测试并无法检测到这种变化,甚至BaseOrderDetail的Count方法修改后会引发异常,也无法通过单元测试得知BaseOrderDetail中Count方法产生的异常会到只ProductOrder中的Count方法也会发生异常。这是单元测试的局限性。
第二种情况,需求变更为,如果订单的总额度超过5000,可以享受减免100的优惠。此时,可以先修改ProductOrder.Count方法的单元测试中的期望值为8648.55,再修改实际的代码实现如下:
Unit Test for Count
可以看到其实这是在实现代码中添加了一个条件判断分支,我们也许需要为条件分支再单独写一个单元测试(略)。
第三种情况,即需求发生重大变化,导致代码结构发生严重变化,那么基本之前的所有单元测试和实现代码都已经作废,甚至编译无法通过。
其实单元测试在开发完成后能起到的作用,仅限于这种场景,对一个方法的实现进行优化,例如VanclProvider类的QueryPriceByProductID方法由以前从数据库查询,改为先从本地缓存中读取,若缓存不存在才从数据库中读取,因为期望值并为发生改变,只是修改了这个方法的实现方式,因此在修改之后依然可以通过单元测试进行验收是否修改正确,即新的修改是否依然符合早期的需求(期望值没发生改变,则单元测试不发生改变)。
误区:
很多朋友一直把单元测试和集成测试搞混,因此一直有个误区:
认为只要对所有方法都编写了单元测试,在将来代码发生变化之后,可以通过运行单元测试快速的通过计算机校验得到所有受当前代码变化影响的用例以及影响范围。实际上,如果你仔细看到这里,你应该已经知道,这其实是不可能的。
单元测试的隔离性,导致了代码的影响不会传递:一个方法的实现导致了异常,并不会影响到其他调用过它的方法在原有的单元测试中顺利通过(Mock方法代替了实际方法的执行)。
总结:
通过以上这些例子,描述了如何结合测试先行的思想去做单元测试,核心的一点即是:目标先行,每实现一个方法之前先考虑这个方法要达到的目标是什么,再编写这个目标的检测手段(单元测试),最终去实现这个方法,并通过之前设想和编写的检测手段去验证这个实现是否已经达到预期的目标。
另一方面,这个过程也强烈的反应出了,单元测试作为测试的一种手段,其实在代码实现后,即维护阶段能起到的作用非常的小:
当需求发生变化时,需求的预期值必然发生变化,意味着单元测试需要被修改或重新编写。此时,原有的单元测试已经起不到事后检查的作用了。
只有在需求未发生改变时,对现有代码进行不涉及结构改变的优化时,用于修改后的代码是否依然满足之前的需求预期。但实际应用中,这种场景少之又少。