在敏捷方法中,人们提倡测试驱动开发(Test Driven Development,TDD)。在TDD中,先写测试的脚本或代码,然后再写实际的产品代码,让测试通过。所以,从可测试性角度看,TDD是最彻底的解决办法,确保了可测试性。先写测试代码,就是将测试问题考虑清楚再动手编程,也就是先确定测试标准和测试方法,然后正确地指导编程,确保代码符合标准。
代码的可测试性,具体表现在:代码块大小、耦合性、内聚性和复杂性等方面。我们曾经测试过一个产品的某个功能,做了几个版本,总是发现比较多的缺陷,而且发布出去以后,用户的抱怨也比较多。后来仔细查看代码,发现几个函数的代码块都比较大,其中一个函数居然有8000行代码。代码块大,必然带来代码的复杂性,而且耦合性也会增加,可测试性也会降低。不仅开发人员会写出问题比较多的代码,而且问题也不容易被发现,或者说,难以在一轮测试中把绝大部分的问题挖掘出来。
为了保证代码的可测试性,应对代码之间的依赖性有一个很好的管理,即要尽量减少模块或单元之间的依赖性。虽然单元之间或多或少会存在一定的关系,如果没有这些关系,这些单元也无法组成一个系统,但是单元之间过多的依赖关系,会严重影响测试。如果单元之间的依赖性很高,就可能存在一些难以隔离的单元。在这个时候,如果不了解所依赖的单元代码,就难以理解相应的单元代码。也就意味着,这些单元难以独立地被进行测试。要减少单元之间的依赖关系,可以借助配置文件、数据库来建立单元之间的关系,而不是让它们之间相关。通过配置文件、数据库来建立单元之间的关系,是在它们之间建立了接口,从而提高了系统的可观察性和可控制性,也就是增强了单元代码的可测试性。被测试的单元之间如果有三四个依赖关系,将带来很多测试的问题。所以根据单一责任原则,每个单元改变的原因只有一个,而没有多个。
保证代码的可测试性,就是避免代码的复杂性,保持代码的干净、简单。一般来说,有两种方法可以有效地降低代码的复杂性。
1) 通过单元测试来降低复杂性。 随着单元测试的不断深入,可以了解哪些代码不容易被测试。如果采用代码测试覆盖率工具,就更容易降低代码测试的复杂性。
2) 借助分离关注点(Separation of Concerns,SoC)原则也可以有效 降低代码的复杂性。 因为在某个时候,我们只关注某一个方面,不仅有足够的时间准备相关的技术,把事情做得更好,而且一个方面的复杂性是有限的,问题容易被简化。例如,MVC设计模式就很好地隔离了模型、视图和控制等不同层次,使不同的人关注不同的方面。关注业务模型的人,能够独立解决业务模型的构造,在这个过程中自然会减少来自界面的影响,从而使这些不同组件之间的关系处于松耦合之中。
更详细的内容,可以参考Martin Fowler 写的一篇论文——《分离用户界面代码》( Separating User Interface Code )。
代码可测试性常见的问题如下。
1) 构造函数干实际的工作。 构造函数本来只是在对象创建的时候做一些初始化工作,如果它去创建新的关键字或控制流、调用静态方法,将给测试带来困难。
2)
挖掘协作对象。
如果测试人员需要通过某个对象的关联图来寻找其所需对象,这样,所需对象和间接对象就有更多的关系,产生更深的对象链接,从而违反了迪米特法则
,降低了可测试性。实际编程过程中,应避免像holder、context、environment、principal、container或manager这样可疑的名字或对象。
3) 不友好的全局状态和单态模式(singleton)。 用静态方式访问全局状态,使得对应的构造函数和方法的依赖关系变得不清楚,全局状态还造成某些API调用的假象。要想搞清楚这些关系,我们必须读懂每一行代码。更糟糕的是,它会导致其他地方不可预料的结果,例如在一个测试中全局状态改变,会导致后续或者并行测试的意外失败。
4) 一个类做了太多的事。 这一点比较容易理解,因为类的某些属性只被个别方法使用,而且采用某些静态方法的话,还有参数识别的问题,所以这些都会给测试带来复杂性。
对于代码可测试性存在的问题,我们就要有相应的改善方法,例如:
1) 提供Get()方法,获得相关状态信息。
2) 提供访问、修改“状态”变量的接口。
3) 提供查询系统状态及资源使用情况的接口,包括CPU、内存使用、句柄数量、程序使用进程数等。
4) 对出错及异常处理,保存详细的记录或日志,并提供观察、恢复的外部方法。
5) 少用静态变量,从而保持类的独立性。
6) 依赖倒置,以降低耦合性。
7) 通过反射机制获得被测试类的消息(如下有Java反射机制的示例代码)。
8) 消息传递机制。
9) 使用模块化方法,编码低耦合、高内聚。
10)尽量做到每个操作对应一个函数,使函数小型化。
11)注释要详尽,特别是对于接口及其传递的参数。
12)设置Debug开关,当将Debug置于on时,提供更多的log信息。
13)为自动化测试预先保留相应的API或特定的hook。
确保代码可测试性的最有效办法是,通过相关工具对代码进行扫描,了解代码的复杂性,分析类的依赖关系,确定代码是否违背相关原则等。这类工具主要有以下6种。
1) Testability Explorer工具可从http://www.testabilityexplorer.org/report下载,其中有许多开源项目的代码可测试性示例。
2) Sonar是一个管理代码质量的开放平台,涵盖了代码质量的7个方面,即架构与设计、重复代码、单元测试、复杂性、潜在错误、编码规则和注释。
3) XRadar是一个开放的、为代码生成可扩展性报告的工具,可以估量代码的大小、复杂性以及代码的重复性、依赖性等。
4) Jdepend工具能够遍历Java class的文件目录,以Java包为单位,生成包的依赖性、稳定性等评价报告。该工具可从http://www.clarkware.com/software/JDepend.html下载。
5) Java源代码度量工具集,其中包括衡量代码复杂度的圈复杂度的度量,该工具集可以从JavaNCSS,http://javancss.codehaus.org/下载。
6) StructureMap工具可以使。Net框架的代码降低代码类之间的依赖性,改善类结构的可测试性,该工具可从http://structuremap.net/structuremap/index.html下载。
以上述列举中的Jdepend工具为例,其生成的包的依赖性、稳定性等评价结果示意图如图3-4所示,它可以度量下列项目。
图3-4 JDepend生成的包的依赖性、稳定性等评价结果示意图
① 类与抽象接口的数目(Number of Classes and Interfaces):稳定抽象原则(The Stable Abstractions Principle,SAP)指出包的稳定程度与其抽象程度(接口的数目)成正比。
② 包的抽象程度(Abstractness,A):一个包内包含的抽象类或接口占整个包中的类的比重。该比重处于0和1之间,若A=0,说明包内不包含任何抽象类或接口;若A=1,说明包内全部是抽象类或接口。按SAP原则,一个包内的接口所占的比重越大,这个包就越稳定。
③ 向心耦合(Afferent Couplings,Ca):依赖该包(包含的类)的外部包(类)的数目。该数目越大,说明该包担当的职责越大,也就越稳定。④ 离心耦合( Efferent Couplings,Ce):被该包依赖的外部包的数目,该数目越大,说明该包越不独立(因为依赖了别的包),也就越不稳定。不稳定性(Instability )可以用公式I=Ce/(Ce+Ca)来表示。
⑤ 包的循环依赖度(Package Dependency Cycles):无环依赖原则(The Acyclic Dependencies Principle,ADP)要求包之间不能有循环依赖关系。
而采用上述列举中介绍的Sonar平台进行检测的结果更为直观,可直接给出方法和类的复杂度值,如图3-5所示。
图3-5 用Sonar平台检测出的方法和类的复杂度值示意图