一、单测中的问题
从代码来看,大家测试的意识比较强,现有代码已有部分单元测试和系统测试case;从case的质量来看,基本符合标准的组织和书写形式,集成测试大家都会注意测试数据的准备和清理。但也存在一些问题,主要表现为:
1、测试代码的风格不一致,维护成本会比较高。有些用JdbcTemplate直接写Sql插入准备数据,有些用Dbunit来导入数据;有些在service采用集成测试,有些采用单元测试;
2、生产代码的可测性不强,导致测试用例很难写全面。现在在service层大多数接口方法依赖于多个private方法,如何某个private方法中有重要逻辑,不能直接进入逻辑中测试;
3、测试方法不得当,导致书写用例的成本很高。现在的用例普遍采用service层集成测试的方式,由于业务层逻辑关系比较复杂,大多数情况需要构造多张数据库表的数据才能测试,构造数据过程耗时耗力。同时由于集成测试都在spring的容器中,每个case初始化时间都比较长,导致调试和运行都不方便。
二、单测整体解决方案
为了提高单测的质量和效率,需要从生产代码和测试代码两方面入手。一方面代码的可测性高低与系统设计的优劣是一对孪生兄弟,如果单元测试易于开展则系统一定符合高内聚、低耦合的特性。另一方面测试代码需要符合一定的规范和实施原则,以保证用例能够低成本开发和调试。
为解决以上问题,首先需要对已有不利于测试的代码进行重构以保证单测顺利开展,这里主要在遵循面向对象设计原则的前提下,通过分析业务逻辑关系,采用适宜的设计模式或者调整代码来解决。
其次需要统一测试的方法:service层进行单元测试,dao层进行集成测试。原因如下,service层逻辑一般涉及较多的业务关系,需要细粒度的测试,如果与dao混在一起测试,构造数据不方便,容易导致测试不充分;dao层现在采用ibatis框架,有较多原生sql语句,为验证sql的正确性必须采用集成测试,同时对单一dao进行测试时一般只关联其对应的bo,测试成本也不高。
三、重构与单测实例
以下主要是这次重构改进可测性的一些典型实例,后续代码书写和单测参考可作为参考,为了屏蔽复杂业务逻辑带来的干扰,这里主要展示伪代码:
3.1 门面模式的运用(Facade)
先看一下原来的类:
我们可以看到这个类有一个handle方法,主要分为两步完成它的功能,第一步从自己的dao中查出一个map然后根据业务逻辑进行加工,第二步将map中的整型值通过调用barService替换成字符串。实际代码中当然没有这么简单,比较常见的形式的FooServiceImpl中用到了2-3Dao和2-3个Service才能完成handle()应该实现的功能。下面我们来看如何对handle()方法进行单元测试:
可以看到为了测试handle()方法,我们必须mock2个对象(fooDao和barService),对于实际的代码,我们极有可能需要mock4-6个对象,这样单测一个方法的成本太高了。
站在职责单一原则的角度思考,对于FooServiceImpl中的handle()中的两步完全可以分开,因为调用barService明显超出了它的职责,service调用service时我们需要思考下是否可以放在fa?ade里面,这样层次明晰后代码职责更清晰了,我们重构成两个类:FooServiceImpl和FooFacadeImpl:
可以看到FooFacadeImpl中handle()实现了替换功能,而FooServiceImpl中只用到了FooDao一个依赖对象了,那么在测试preHandle只用构造一个mock对象了。
这时候有人发问:这时测试FooFacadeImpl中的handle()需要构造两个测试对象了???测试还是没有变简单,这样只是把mock对象的时间延后了。其实handle()中的替换操作应该封装在BarService中,FooFacadeImpl中只是调用这个两个service:
这样预处理操作封装在FooService中,替换操作封装在BarService中,分别对这两个类中的方法进行测试分别只用mock一个对象,一方面增加了测试的信心(书写成本低、迅速调通可运行的测试case),另一方面代码也变得更有层次感和清晰。回到问题上来,怎么测试FooFacadeImpl,是否还需mock两个对象并准备数据?其实当我们充分测试preHandle()和postHandle()方法后,FooFacadeImpl只用测试使用各调用了preHandle()和postHandle()一次:
从这个例子可以看出,对service进行单测成本高的时候极有可能是代码构成不合理,对于一个service中有多个service和dao的情况,可以建立业务逻辑的层次调用,避免出现大类的情况,这样对于降低单测成本、做到低耦合是大有帮助的。
3.2 策略模式的运用(Strategy)
先看一下原来的类:
这种类型的代码在系统中较多地出现,有时候是以switch—case的形式,但更多的时候是根据条件的不一样选择不同的处理逻辑。当然现在的这个例子是简化后的,实际上handle中的选择分支更多,而private中的方法也不一定只有15行。对于这样的类和方法我们测试势必很难,因为重要的逻辑几乎在handleAtrade(),handleBtrade(),handleCtrade()里面,而这些方法在接口类MultiBranchService中是没有的。
从代码本身来讲,这样写代码并不符合开闭原则(对扩展开放,对修改关闭),因为如果将来多了一种处理逻辑,需要新增一个private方法,然后在handle加个else if分支。对于这种情况可以采用策略模式解决,先定义一个策略的上下文,它只负责调用各种处理:
可以看到本质上StrategyContext和MultiBranchServiceImpl没有本质的区别,只是将处理的具体逻辑注入进来了。现在要测试handle方法可以参考3.1中后测试fa?ade中的handle方法,只需测试type取不同值时响应的strategyHandle是否被调用。现在来看一下TradeStrategy,它只是一个接口:
对于A/B/C行业的处理逻辑是TradeStrategy的实现:
代码重构成这样基本可以很好的测试,因为各种处理逻辑暴露出来的,同时代码也具有更好的扩展性了。
3.3 模板模式的运用(Template)
先看一下原来的类,这里看一下真实的代码:
划红线的方法是这个类中的private方法,可以看到如果要对这个类的work()方法进行测试是多么困难,work()如果加上这些private方法大概有150多行,而且work()前面有一部分代码是去下载文件,单测初始化工作也不好做,因为后续的操作多次要处理这个文件,要覆盖各种情况需要准备不同情况的文件。
重构这个大类的基本思路还是来源于业务逻辑的划分,可以看到work中主要完成3件事:下载文件、合并文件和处理单词,这里可以采用模板模式:
具体的实现在HandleTempleImpl里面:
这样对于真正的处理能够比较充分地测试,比如mergeFile()现在完全不依赖与downLoadFile()的调用而可以直接测试了。
四、原则与总结
以上实例只是一些普遍性较强的例子,实际上细节点非常多,从上也可明显看出如果代码结构不合理可测性确实是非常低的。
这里有一些代码书写或者重构的原则,对于提高可测性有较大的帮助,较全的重构方法可以参考《重构》这本经典书籍:
1)形成模板方法(如3.3模板模式的应用)
2)引入断言(比如每个方法中判断传入参数的合法性,这样在测试时可以减少异常参数测试的工作量)
3)用多态替代条件语句(如3.2策略模式的应用)
4)用异常代替错误码(测试时只需要调用后判断预期异常)
5)将查询方法与修改方法分离(测试时由于查询和修改职责单一后会降低构造数据的成本)
6)以明确函数取代参数(测试时由于函数明确、职责单一也会降低构造数据成本)
另外,在测试时也有一些tips:
1)用TestNG或者Junit的参数化来减少代码量,并尽量将测试数据单独一个类出来;
2)用ObjectFactory的方式来获取默认数据,这样对于构造拥有多个属性的类的实例时将大大减少工作量,因为只需要在默认对象的基础上做些修改;
3)使用Mokito的注解方式来mock对象。