功能测试或集成测试是关系到整体系统功能的测试,而不只是牵涉到小段代码(单元)。这需要将已经单独测试好的模块组装起来,以保证其连接时也能像预期一样正常工作。JUnit是进行Java程序测试最常用的测试框架。
大多数Java开发人员都善于解决逻辑结构测试问题,比如如何建立测试预设环境、利用断言?添加测试方法、用setup方法进行初始化等。然而,如果Java开发人员能更深入地了解如何设计功能测试集来有效地检验代码是否正常运行,他们将获得更多的益处。
这篇文章介绍了可以建立有效 JUnit功能测试集的策略。包括:
确定测试用例覆盖所有程序行为。
确定代码入口点:测试程序整体功能的主要代码段。
匹配入口点与相应的测试用例。
根据初始化 /运行/检查流程创建测试用例。
设计并利用运行时事件表进行测试。
我将结合Saxon(一个可以处理XPath、XQuery和XSLT 的XML工具)的源代码来具体阐述这些策略。Saxon由约50000行Java代码组成,它是开源的,代码风格优良,注释文档详尽。
确定用例
功能测试有两个相辅的目标:覆盖率与粒度。为确保完整性,功能测试必须覆盖程序提供的所有功能,且必须在各组件水平上分别进行测试。一个测试可以建立在另一个测试的基础上,但任何测试都不能用来验证两项功能。
建立一个全面的功能测试集,第一步是列出程序可以实现的所有行为。这可以通过使用特定的用例模拟外部因素(程序使用者或其它软组件)执行系统内部的功能来实现。
一个典型的企业Java程序应该包含各种用户所需的详细文档,包括用例说明、非功能性要求、测试用例说明、用户界面设计文档、模型、用户个人信息以及其它各种人工生成的信息。一般来说简单的应用程序只有一个简单的说明文档。
借助这些文档,你可以快速确定需要测试的用例。每个测试用例都描述了应用程序可以执行的一项功能。用规模相近的测试方案确定唯一的功能是一个好习惯,而较大的方案可以根据其检验的功能拆分为较小的方案。
有许多种建立用例模型的方法,其中最简单的便是输入/输出匹配法。在Saxon的query类中,最简单的用例是传送一个查询文件、一个查询请求和一个输出文件路径。输出文件若不存在,将根据要求创建,并在文件中显示查询结果。
更复杂的用例可能需要输入更多的信息或输出更多的结果。然而,用例并不关心功能是如何在内部实现的。对它们来说,软件就像是一个
“黑盒子”,只要运行正常,即使真正实现软件功能的是盒子里的侏儒也无所谓。这是很重要的一点,因为输入/输出匹配用例很容易直接转换为测试用例,使得复杂的说明与简单的测试吻合,确定该运行的功能正常运行,而不该运行的功能如预期一样失效。
如果类相对比较简单,或者已有列举类所有功能的说明文档,为指定入口点描述用例将很容易。如果不是这样,或许就需要研究类可能有的所有行为(确定类的目的与用法)。如果你想知道所有调用代码的地方,也可以从代码中提取用例。
最可能的情况是,根据开发人员提供的类的一些基本说明文档,可以完全确定这些类应有和不应有的行为。基于此,设计一套准确的用例集。
转换测试用例
每个测试用例都由两部分组成:输入和预期输出。输入部分包括所有创建变量或为变量赋值的测试用例语句。预期输出部分则表明应该得到的输出结果,它应该显示断言成立或“没有异常”(不存在断言语句时)这样的信息。
基本的输入/输出模式是理解测试用例模型最简单易用的办法。它采用一般函数(传递参数,获取返回值)和大多数用户行为(按某个键实现某项功能)惯用的模式。然后,可以用该模式进行:
初始化:建立测试预设环境。代码初始化可以在测试开始时进行或通过调用setUp()方法实现。
运行:调用被测试的代码,记录所有值得注意的输出和数据。
检查:使用断言语句确保代码正常运行。
举例来说,假设要测试Saxon库的转换类入口点。其中一个用例是将XML文件转换为HTML文件,当然前提是已有描述这个转换的XSL文件。输入这三个文件的路径,就应该输出HTML文件的内容。这可以直接转为下面的测试:
<p>public void testXSLTransformation() {<br>
/* initialize the variables<br>
(or do this in setUp if used in many tests) */<br>
String processMePath = "/path/to/file.xml";<br>
String stylesheetPath = "/path/to/stylesheet.xsl";<br>
String outputFilePath = "/path/to/output.xml";<br>
//do the work<br>
Transform.main(new String[] {<br>
processMePath,<br>
stylesheetPath,<br>
"-o", outputFilePath } );<br>
//check the work<br>
assertTrue(checkOutputFile(outputFilePath));<br>
}</p>
每一步都可以根据需要进行增减。这里声明的变量也可以简单地通过调用方法来赋值。预期输出的实现是由几个步骤组成。如果成功得到预期输出,有时可以省略检查步骤。
虽然这个模式简单且灵活可变,但是第二步必不可少。这个模板没有告诉我们寻找要测试代码的方法,也不能保证代码以方便测试的方式运行。这是个需要认真考虑的问题。
功能测试
通过确定执行程序功能的主要代码段,可以将测试建立在一个更有效的环境下。由于这些类提供了从系统外部进行测试的途径,所以也是代码的入口点。
因此,功能测试的整体目标就是确定一组可以访问系统功能的高层接口类。这些类的独立性越高越好。毕竟,如果能将类从环境中分离出来,测试起来会更加容易。
确定作为入口点的代码是一个简单的过程。在代码库中,通常有几个控制该库所有功能的入口点。这些外部类作为客户端代码,与库的中介对象将开发人员从复杂的代码分析中解脱出来。这些便是应当首先对其方法进行测试的类。
比如,Saxon有一小组类作为逻辑入口点提供对库的访问。通过对外部类进行编码操作,比如转换、设置和查询,客户端代码可以访问库的许多功能类,而无需考虑类的接口问题,甚至无需担心这些类是否存在。这些外部类用高层易用的接口提供一个简单的方式对系统功能进行测试,这正是一个优良的库的特征。
程序代码中的各个功能模块通常是各自独立的。在某些代码中,甚至可以认为这些模块各自对应不同的、可通过大量外部类访问的库。这些类查找高层接口的逻辑位置。插件结构通常都采用这种设计模式:每个插件程序都有一个可以有效执行内部代码全部功能的简单接口。
在一些非严格描述的系统中,通常有一个所有程序行为的中介点。在MVC架构中,这个中介类一般作为“控制器”,负责配置系统各部分的请求路由。整体系统的功能主要由这个控制器连接的类实现,因此,这些类是测试的主要对象。
比如在 Applet程序设计中,java.applet.Applet的派生类就是所有代码的中心处理单元。根据代码的分解程度,测试焦点可以放在Applet
子类或与其连接的类上。
连接各个模块的代码也是测试的主要对象。将应用程序请求转换为数据库查询的类,以及有相似功能的适配类是其次应该考虑的测试对象。
各种基于MVC(模式-视图-控制器)架构的组件可以用其它的测试框架(比如Junit的扩展)进行测试。例如,Struts的
action类就最好使用JUnit的扩展StrutsTestCase进行测试;服务器端的组件(如Servlets、JSP和EJB)最好用
Catus进行测试;而HttpUnit则是对Web应用程序进行黑盒测试的最好框架。本文讨论的所有技术都可应用于这些框架环境下的测试。
从用例到测试用例
每个入口点都必须与相应的用例匹配。某些情况下可以忽视这一步,因为类名的自记录可以实现自动匹配,比如
Saxon中的转换类可以实现XSL转换,查询类可以进行XQuery转换。
其它情况则要复杂得多。通常用例描述的功能只能以横切关注点的方式存在,不能用任何单独的类进行例证。只有几组类交互时或满足一定条件时,才能观察到功能行为。这种情况下,测试的初始化程序会比较长,或者可以用
setUp()方法提供需要的测试环境。
而调用代码的运行程序应该尽可能地设计成一行,以减少与被测试代码的关联,这可以有效避免对边缘效应与不稳定实现细节的依赖。测试的检查阶段是最复杂的,因为这个阶段经常需要添写非测试用代码。测试时可能需要对结果进行严格的分析以确保其符合要求。有时甚至需要将这个过程分为几步来完成,以取得测试可以识别的结果。在XSL转换中,这两种情况都是可能的,结果储存在文件中,然后以XML格式读入内存并进行准确性分析。
Saxon中有个相对简单的例子。已有XML文件和XPath表达式的情况下,Saxon可以执行表达式并返回匹配列表。Saxon中的XpathExample样本类就是用来执行这种任务的。基于以上分析,可以设计如下的测试流程:
public void testXPathEvaluation() {
//initialize
XPathEvaluator xpe = new XPathEvaluator(
new SAXSource(new InputSource("/path/to/file.xml")));
XPathExpression findLine =
xpe.createExpression("/some/xpath[expression]");
//work
List matches = findLine.evaluate();
//check
assertTrue(matches.count() > 0);
}
两次输入的都是字符串常量,输出的则是所匹配的列表,可以用来验证匹配结果的正确性。这些工作都由一行代码完成,这行代码只是简单地调用了被测试的方法。
另一种可能的情况是XPathEvaluator没有调用createExpression()方法。因为表达式不存在,这时可能会显示错误信息。
将输入的源文件名和表达式保留在测试用例中不是个好习惯。某些项目(服务器名、用户名和密码等)不应该出现在测试文件中,它们应该可以根据情况自由设置。并且,测试用例的设计应该方便测试驱动和测试数据的分离、测试驱动对大范围数据的可重用性和测试数据对测试驱动的可重用性。另一方面,不要将一个简单的测试用例实现设计地过于复杂。一般来说,测试用例已经说明了系统的大部分状态,并可对其进行参数描述,所以无需在测试中进行过于详细的参数描述。
许多代码段可能出现在不止一个测试用例中。有经验的面向对象开发人员会尝试对其进行重构并创建通用类和有效方法。有时候这样做非常有用,比如登录过程应该设计成所有测试用例可用的方法。
但是,不要过度设计测试,这些Java类仅仅是用来验证应用程序的功能行为而已。
测试用例是脆弱的。比如,如果开发人员更改了testXPathEvaluation测试中输入文件的位置,或者creatExpression方法签名有所变动,测试脚本就会失效。
对于应用程序的测试用例实现来说,大量的重复性工作与改动是不可避免的。因此,可跟踪性对于所有的测试用例都是至关紧要的。出现问题的时候,如果能为开发人员指出相应的测试用例说明和用例说明将有利于提高修正bug的速度。
因此,测试用例注释中应标明原始说明文档的引用位置。这可以是一个简单的代码注释,也可以对每条测试都注释相关用例和所测功能,这样当测试出现问题时开发人员就会收到一条相关信息。因此,在代码中加入参考并维护可追踪性是很重要的。
设计运行时事件表
要了解测试覆盖的范围,必须先了解所测试代码如何运行,以及各种静态类如何形成描述程序状态的动态对象图表。
有许多模拟这种行为的方法,包括Granovetter图和物件互动图。其基本思想是用图形化的方式研究代码以了解测试中涉及到的运行时部分。这些技术都可用运行时事件表(Runtime
Event Diagrams)来描述,因为这些图表显示了程序运行时发生的事件,而非理论上类可以控制的事件。这些图表非常重要的原因包括:
首先,这些图表便于从高层上理解代码,并提供有用的说明文档。这个文档与代码的内联文档不同。这些图表显示代码的运行时表现,是产生代码功能的地方,也易于对系统的了解;大多数设计模式和架构在用对象和参考表示时要比用类和域表示容易得多。
另外,这些图表将测试执行的代码分类列表,并确定测试是否会受到将来对任意代码改动的影响。如果开发人员确定测试A是建立在B、C和D的基础上,她就可以确定如果对B、C或D做出改动就需要对A进行重新测试(确保向后兼容)。
以尽可能少的步骤模拟系统是个好方法。总的来说,实际调用与此无关,重要的是系统如何作为整体运作以获得预期目标。可以用简化的模拟系统实现这个目的,该系统只关心对象间的基本交互,并用自然语言描述交互中发生的事件。
做出运行时事件表后,就可以将其整合到类文档中。需要注意的是,为表添加一些限制可使其对类的修改更有弹性。首先,一般不能使用方法名,因为它们会随时间发生变化。取而代之的是更易理解的自然语言描述。其次,这些图表主要是关于系统中各部分的交互。这是高层架构上的设计方案,一般不会再做改动。最后,图表是建立在类型而非特定类的基础上。只要基本类型不变,为维持交互协议的正常运行,这些图表就不需要更新。
一旦图表创建成功,可以在许多方面获得应用。比如,一个图表可以用来获取系统如何运作,以及如何运用其交互部件实现功能的概览。在某种程度上这是一种简化了的UML语言,它只描述关系到整体功能的系统部件:实例及其类型、其它引用的实例,以及组件可以实现的功能。
这些图表也可以用来分析系统的复杂性以及如何进行简化。要确定简化系统的方法,可以查找系统中使用过一到两次的对象,并为其寻找其它可能更合适的位置。也可以查找重复的任务,将其封装到方法或类中。
然而,最重要的是图表在测试中的应用。通过对系统状态的总结,图表可以帮助解决系统中出现的问题。出现问题时,图表中的信息便可用作参考。因为只需要将系统目前状态与预期状态作比较即可,这样确定问题产生的原因也就变得比较简单了。对小组件的改动不应该影响整体架构,因此可以通过对照运行时事件表以保证系统仍然正常运行。并且,当有重要组件发生变动时,可以用运行时事件表对照系统当前状态以获取系统修正方案。由于将系统作为整体和对预期功能的描述,运行时事件表也可以看作是一种结构化的单元测试。如果系统有变动,可以更容易地做出修正以维持系统的正常功能。
如果经常因细节问题影响对全局的把握,就应该使用图表。其高层本质可以用来分析软件的设计模式,就像反模式一样。还有许多其它用途,并且当运行时事件表、测试用例说明和用例说明没有描述所需的细节时,它还提供了直接进行代码分析的路线图。
利用功能测试进行回归测试
最后,为回报你在功能测试上做出的努力,配置一个与自动生成的程序相应的自动化测试程序。这个程序不只从功能上测试代码,还可以同时进行常规的回归测试。现在大多开发项目都建立在庞大的代码库基础上,如果不能对代码库进行充分测试,开发团队将无从决定对程序的修正是否会破坏现有的功能,结果就是很难对这种代码进行扩展或优化。与此相反,如果开发人员可以在全面的功能测试基础上进行回归测试,优化或扩展代码时就不必担心可能会引发不可预料的问题。毕竟,没有比做完回归测试后发现一切正常更令人心情愉快的事了。
|