单元测试是整个测试流程中最基础的部分,它们要求程序员尽可能早地发现问题,并给予控制,这是其一。另外,如果集成测试出现问题,它们可以帮助诊断。这样就为在软件开发流程中建立高效的事件反应机制打下了坚实基础。
JUnit就是为Java程序开发者实现单元测试提供一种框架,使得Java单元测试更规范有效,并且更有利于测试的集成。
JUnit的内部结构
JUnit的软件结构
JUnit 共有七个包,核心的包就是junit.framework 和junit.runner。Framework包负责整个测试对象的构架,Runner负责测试驱动。
JUnit的类结构
JUnit有四个重要的类:TestSuite、TestCase、TestResult、TestRunner。前三个类属于Framework包,后一个类在不同的环境下是不同的。这里使用的是文本测试环境,所以用的是 junit.textui.TestRunner。各个类的职责如下:
1.TestResult,负责收集TestCase所执行的结果,它将结果分为两类,客户可预测的Failure和没有预测的Error。同时负责将测试结果转发到TestListener(该接口由TestRunner继承)处理;
2.TestRunner,客户对象调用的起点,负责对整个测试流程的跟踪。能够显示返回的测试结果,并且报告测试的进度。
3.TestSuite, 负责包装和运行所有的TestCase。
4.TestCase, 客户测试类所要继承的类,负责测试时对客户类进行初始化,以及测试方法调用。
另外还有两个重要的接口:Test和TestListener。
1.Test, 包含两个方法:run() 和countTestCases(),它是对测试动作特征的提取。
2.TestListener, 包含四个方法:addError()、addFailure()、startTest()和endTest(),它是对测试结果的处理以及测试驱动过程的动作特征的提取。
下面给出的两个类图(篇幅有限,只显示主要部分)很好地阐明了类之间的关系,以及junit的设计目标(如图1)。测试案例的类采用Composite模式。这样,客户的测试对象就转变成一个“部分—整体”的层次结构。客户代码仅需要继承类TestCase,就可以轻松的与已有的其他对象组合使用,从而使得单元测试的集成更加方便。
图1 测试结构图
图2是测试跟踪类图。图2左边TestSuite包含了测试对象集合,右边包含了测试结果集。具体如何处理结果,以及包含哪些测试对象,并没有立即得出结论,而是尽量地延迟到具体实现的时候。例如,实现接口TestListener的JUnit中就含有:junit.awtui.TestRunner、junit.swingui. TestRunner、junit.ui.TestRunner等,甚至客户用自己的类实现TestListener,从而达到多样化的目的。
图2 测试跟踪图
从以上两个类图,可以了解JUnit对单元测试的基本思路,这个框架的核心就是结果集和案例集。
JUnit的实现流程
典型的使用JUnit的方法就是继承TestCase类,然后重载它的一些重要方法:setUp()、teardown()、runTest()(这些都是可选的),最后将这些客户对象组装到一个TestSuite对象中,交由 junit.textui.TestRunner.run (案例集) 驱动。下面分析案例集是如何运转的。
图3基本上阐述JUnit的测试流程架构。我们将从不同的角度来详细分析这个图。
图3 测试序列图
首先,从对象的创建上来分析。客户类负责创建Suite和aTestRunner。注意,类TestRunner含有一个静态函数Run(Test),它自创建本身,然后调用doRun()。客户类调用的一般是该函数,其代码如下:
static public void run(Test suite)
{
TestRunner aTestRunner= new TestRunner();//新建测试驱动
aTestRunner.doRun(suite, false);//用测试驱动运行测试集
}
Suite对象负责创建众多的测试案例,并将它们包容到本身。客户测试案例继承TestCase类,它将类,而不是对象传给Suite对象。Suite对象负责解析这些类、提取构造函数和待测试方法。以待测试方法为单位构造测试案例,测试案例的fName就是待测试方法名。测试结果集由aTestRunner创建。这似乎同先前阐述的类图有些矛盾,那里阐述了一个测试集可以包含很多个不同的测试驱动,似乎先创建结果集比较理想。显然,这里对测试结果的处理只采用了一种方式,所以这样做同样可行。
其次,从测试动作的执行上来分析,测试真正是从suite.run(result) 开始的。其代码如下:
public void run(TestResult result)
{
//从案例集中获得所有测试案例,分别执行
for (Enumeration e= tests(); e.hasMoreElements(); )
{
if (result.shouldStop() )
break;
Test test= (Test)e.nextElement();
runTest(test, result);
} }
一旦测试案例开始执行,首先使用一个回调策略将自身交由Result。这样做的每一步测试,测试驱动aTest Runner都可以跟踪处理。这无形中建立了一个庞大的监视系统,随时都可以对所发生的事件给予不同等级的关注。
我们分析一下涉及到的动作行为的设计模式:
1. Template Method (模板方法)类行为模式,它的实质就是首先建立方法的骨架,而尽可能地将方法的具体实现向后推移。TestCase.runBare()就采用了这种模式,客户类均可以重载它的三个方法,这样使得测试的可伸缩性得到提高。
public void runBare() throws Throwable
{
setUp();
try {runTest();}
finally {tearDown();}
}
2. Command (命令)对象行为模式,其实质就是将动作封装为一个对象,而不关心动作的接收者。这样动作的接收者可以一直到动作具体执行时才需确定。接口Test就是一个Command集,使得不同类的不同测试方法可以通过同一种接口Test构造其框架结构。这样对测试的集成带来了很多方便。
JUnit的Exception的抛出机制
JUnit的异常层次分为三层:1.Failure,客户预知的测试失败,可以被Assert方法检测到;2. Error,客户测试的意外造成的;3.Systemerror, JUnit的线程死亡级异常,这种情况一般很少发生。JUnit的这三种异常在TestResult类的RunProtected()方法得到很好体现。这里用Protectable接口封装了Test的执行方法,其实p.protect执行的就是test.runBare()。
public void runProtected(final Test test, Protectable p)
{
try {p.protect();}
catch (AssertionFailedError e)
{addFailure(test, e);}
catch (ThreadDeath e)
{rethrow e;}
catch (Throwable e)
{addError(test, e);}
}
代码首先检查是否是Assertion FailedError,然后判断是否是严重的ThreadDeath。这种异常必须Rethrow,才能保证线程真正的死亡,如果不是,说明它是一种意外。
前两种异常均保存在测试结果集中,等到整个测试完成,依次打印出来供客户参考。
实施JUnit的几点建议
从以上的分析中,可以了解JUnit的结构和流程,但是在实际应用JUnit时,有几点建议还需要说明,如下:
1. 客户类可以重载runTest(),它的缺省实现是调用方法名为fName的测试方法。如果客户不是使用TestSuite加载TestCase,就尤其需要对其重载,当然这种方式并不赞成使用,不利于集成。另外,setUp()和tearDown()的功能似乎与构造函数雷同,但如果测试案例之间具有类继承关系,采用构造函数初始化一些参数就会造成数据的混乱,不利于判定测试结果的有效性。
2. 待测试函数的调用顺序是不确定的,采用的数据结构是Vector()。如果需要有顺序关系,可以将它们组合到一起,然后用同一个测试方法。
3. 为了使测试结果清晰明了,程序中最好不要有打印输出,要么程序的打印输出与JUnit测试的打印输出不要用同一个数据源System.out。其实这是两种测试习惯,直接打印输出是较传统的,从测试动机上考虑它也是较随意的,并且结果需要人工观察。如果直接打印输出较多的话,观察者可能无法获得满意的结果。
此外,如何扩展这个测试框架呢? junit.extensions包给出了几点提示。我们可以使用junit.extensions. ActiveTest在不同的线程中运行一个测试实例。 对于要对测试案例添加新的功能可以采用Decorator模式,可以参考junit.extensions.TestDecorator以及它的子类junit.extensions.TestSetup、junit.extensions.RepeatedTest。这些仅仅提供了一些拓宽的思路,涉及到具体测试目标,还需进一步地挖掘。
自动生成测试代码
在存储过程测试用例的开发过程中我们发现,开发这些测试代码需要的代中近 90% 的测试代码都是重复或者基本类似的。测试用例中变化最多的是存储过程的名称和所需的参数值,其他代码都是为调用存储过程服务的。因此,自动生成测试代码,然后再由用户做小小的改动是最理想的模式。那么如何自动生成测试代码呢?在实现存储过程测试代码的自动生成过程中,我们遇到并解决了以下问题:
如何获得存储过程的名字?
在存储过程测试代码生成过程中,第一个问题就是要针对哪些存储过程生成测试代码。有两种方式可以获取这个信息,其一是由用户手动指定,其二是由系统自动从文件中分析出存储过程名称。为了最大限度的减少工作量且最大限度的利用现有资源,我们设计了允许用户通过指定包含存储过程名称的固定格式的文件,来让系统从中解析出存储过程的名字,并据此来生成测试代码。这样的文件可能是一个定义了 Java 常量的 .java 文件,也可能是一个 .properties 文件。只要其中包含"=",系统将自动把"="右边的部分识别为存储过程的名称。
怎样取得存储过程的参数信息?
用 JDBC 调用存储过程时,必须为存储过程指定参数的值。对应的方法有setString(), setInteger(), setFloat, setDate(), setTime 等。因此必须在代码生成之时知道每一个参数的类型,而这些信息如果让程序员去找的话也是非常繁琐的。既然作为数据库的对象,存储过程的名称、参数等信息也都有相应的数据字典表存放。因此,在代码生成过程中会根据存储过程的名称,需要查询数据库的系统表来获取参数信息,例如 DB2 的 SYSCAT.ROUTINEPARMS 表,Oracle 的USER_ARGUMENTS 表或者 MS SQLServer 的 syscolumns 表等。从这些表中,可获取如下信息:
如何设置存储过程调用参数的默认值?
代码生成时会为各种类型的参数初始化一个对应的默认值,这样可以保证生成的测试代码是可以立即运行的,默认值列表如下:
在代码生成之后,程序员需要把这些默认的值修改为一些具体的测试用例的值。
如何调用执行存储过程?
在已经生成的测试代码中,如何运行存储过程也是一个问题。将大批的数据库操作写在测试代码中是不合适的,真正对测试有用的代码可能会被淹没在这些大量的辅助代码之中,而造成代码的混乱和维护困难。因此我们封装了一个类,用它专门来运行存储过程,这个类叫做 SPProcessSample,它提供了以下主要方法有:
其中的 StoredProcedureInfo 是记录存储过程信息的类,包括存储过程名、存储过程参数列表等。因此,只需要首先创建存储过程信息,然后调用 runSP 方法即可运行存储过程。而这部分代码也是由系统自动生成的,程序员真正需要做的就是修改调用参数的值。
如何定制测试用例?
可以假设有这样一种场景,即程序员需要在所有生成的代码中增加一个新的逻辑,或者增加一条公共的 Assert 语句,这时可能需要修改所有已经生成的代码,无异于一场噩梦。为了使程序员能够统一控制生成的代码,必须让他能够干预代码生成的逻辑,这样就可以保证代码生成功能的灵活性和扩展性。
我们采取的办法是提供一个用户可以修改的代码模板,这个模板是一个 .txt的文本文件。系统生成代码时,会根据这个模板文件来生成新的代码。除了其中的保留字之外,用户可以更改代码的任何部分。并且用户可以根据此模板制作若干针对不同测试目的的模板,以便生成各自的测试代码。例如对于一个只需要进行存储过程性能的测试代码而言,只要能统计出存储过程的运行时间即可,但是对只需要验证存储过程正确性的测试代码而言,就可以把这些时间统计功能的代码去掉。方法就是针对这两种应用,制作两个模板文件,分别使用这两个文件生成代码即可。
下面就是代码模板中用于生成其他测试用例的示例代码,这段代码起到示范作用,所有测试例将按照此代码样式生成。用户只需要修改这段代码,就可以统一定制代码的格式和内容:
final public void do*ROUTINESCHEMA*_*ROUTINENAME*() {
SPTestResult result = new SPTestResult();
long currentTestStartTime = System.currentTimeMillis();
result.setNames(
"*CLASS_NAME*",
"do*ROUTINESCHEMA*_*ROUTINENAME*",
"*ROUTINESCHEMA*.*ROUTINENAME*");
int rows = 0;
StoredProcedureInfo spInfo = new StoredProcedureInfo("*ROUTINETYPE*");
spInfo.setRoutineSchema("*ROUTINESCHEMA*");
spInfo.setRoutineName("*ROUTINENAME*");
spInfo.setRowsReturnedParmId(11);
//TODO If this procedure has a parameter returning the rows of the result set, please
//change the parm_id to the real id, int value is needed.
//spInfo.setRowsReturnedParmId(parm_id);
ArrayList parms=new ArrayList();
//TODO Please modify the last parameter of the 'new ParmInfo()'
// function to set the proper parameters.
*PARAMETERS_LIST*
spInfo.setParmList(parms);
try {
rows = spProcess.runSP(spInfo);
result.setDurationTime(spProcess.getDurationTime());
result.setMemo(spInfo.getParmString());
result.setRowsReturned(rows);
testResultList.add(result);
} catch (Exception e) {
System.err.println(e.getMessage());
result.setMemo(e.getMessage());
}
}