单元测试是整个测试流程中最基础的部分,它们要求程序员尽可能早地发现问题,并给予控制,这是其一。另外,如果集成测试出现问题,它们可以帮助诊断。这样就为在软件开发流程中建立高效的事件反应机制打下了坚实基础。
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 测试跟踪图
典型的使用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.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); } }
我们分析一下涉及到的动作行为的设计模式:
1. Template Method (模板方法)类行为模式,它的实质就是首先建立方法的骨架,而尽可能地将方法的具体实现向后推移。TestCase.runBare()就采用了这种模式,客户类均可以重载它的三个方法,这样使得测试的可伸缩性得到提高。
2. Command (命令)对象行为模式,其实质就是将动作封装为一个对象,而不关心动作的接收者。这样动作的接收者可以一直到动作具体执行时才需确定。接口Test就是一个Command集,使得不同类的不同测试方法可以通过同一种接口Test构造其框架结构。这样对测试的集成带来了很多方便。public void runBare() throws Throwable{ setUp(); try {runTest();} finally {tearDown();} }
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。这些仅仅提供了一些拓宽的思路,涉及到具体测试目标,还需进一步地挖掘。