JUnit的框架设计及其使用的设计模式

原文:JUnitACook'sTour见www.junit.org

1、介绍

2、目标

3、JUnit设计

3.1、从测试用例TestCase开始

3.2、在run()方法中填写方法体


3.3、用TestResult对象报告结果

3.4、Nostupidsubclasses-TestCaseagain

3.5、不用担心是一个测试用例还是许多测试用例-TestSuite

3.6、概要

4、结论


1、介绍
在较早的文章(TestInfected:ProgrammersLoveWritingTests)中,我们描述了如何用一个简单
的框架编写可重复的测试;本文则说明这个框架是如何构造的。
仔细地学习JUnit框架,从中可以看出我们是如何设计这个框架的。我们看到不同层次的JUnit教程,
但在本文中,我们希望更清楚地说明问题。弄清JUnit的设计思路是非常有价值的。
我们先讨论一下Junit的目标,这些目标会在JUnit的每个细小之处得到体现。围绕着JUnit的目标,我
们给出Junit框架的设计和实现。我们会用模式和程序实现例子来描述这个设计。我们还会看到,在开发这
个框架时,当然还有其它的可选途径。
2、目标
什么是JUnit的目标?
首先,我们回到开发的前提假设。我们假设如果一个程序不能自动测试,那么它就不会工作。但有更
多的假设认为,如果开发人员保证程序能工作,那么它就会永远正常工作,与与这个假设相比,我们的假
设实在是太保守了。
从这个观点出发,开发人员编写了代码,并进行了调试,还不能说他的工作完成了,他必须编写测试
脚本,证明程序工作正常。然而,每个人都很忙,没有时间去进行测试工作。他们会说,我编写程序代码
的时间都很紧,那有时间去写测试代码呢?
因此,首要的目标就是,构建一个测试框架,在这个框架里,开发人员能编写测试代码。框架要使用
熟悉的工具,无需花很多精力就可以掌握。它还要消除不必要的代码,除了必须的测试代码外,消除重复
劳动。
如果仅仅这些是测试要作的,那么你在调试器中写一个表达式就可以实现。但是,测试不仅仅这些。
虽然你的程序工作很好,但这不够,因为你不能保证集成后的即使一分钟内你的程序是否还会正常,你更
不能保证5年内它还是否正常,那时你已经离开很久了。
因此,测试的第二个目标就是创建测试,并能保留这些测试,将来它们也是有价值的,其它的人可以
执行这些测试,并验证测试结果。有可能的话,还要把不同人的测试收集在一起,一起执行,且不用担心
它们之间互相干扰。
最后,还要能用已有的测试创建新的测试。每次创建新的测试设置或测试钳(testfixture)是很花
费代价的,框架能复用测试设置,执行不同的测试。
3、JUnit设计
最早,JUnit的设计思路源于"用模式生成架构(PatternsGenerateArchitectures)"一文。它的思
想就是,从0开始设计一个系统,一个一个地应用模式,直到最后构造出这个系统的架构,这样就完成一个
系统的设计。我们马上提出要解决的架构问题,用模式来解决这个问题,并说明如何在JUnit中应用这些模
式的。
3.1、从测试用例TestCase开始
首先我们创建一个对象来表示基础概念:测试用例(TestCase)。测试用例常常就存在于开发人员的
头脑中,他们用不同的方式实现测试用例:
·打印语句
·调试表达式
·测试脚本
如何我们想很容易地操纵测试,那么就必须把测试作为对象。开发人员脑海中的测试是模糊的,测试作
为对象,就使得测试更具体了,测试就可以长久保留以便将来有用,这是测试框架的目标之一。同时,对
象开发人员习惯于对象,因此把测试作为对象就能达到让编写测试代码更具吸引力的目的。
在这里,命令模式(command)满足我们的需要。该模式把请求封装成对象,即为请求操作生成一个对
象,这个对象中有一个“执行(execute)”方法。命令模式中,请求者不是直接调用命令执行者,而是通
过一个命令对象去调用执行者,具体说,先为命令请求生成一个命令对象,然后动态地在这个命令对象中
设置命令执行者,最后用命令对象的execute方法调用命令执行者。这是TestCase类定义代码:〔此处译者
有添加〕
publicabstractclassTestCaseimplementsTest{
...
}
因为我们希望通过继承复用这个类,我门把它定义成“publicabstract”。现在我们先不管它实现
Test接口,在此时的设计里,你只要把TestCase看成是一个单个的类就行了。
每个TestCase有一个名字属性,当测试出现故障时,可以用它来识别是哪个测试用例。
publicabstractclassTestCaseimplementsTest{
privatefinalStringfName;
publicTestCase(Stringname){
fName=name;
}
publicabstractvoidrun();

}
为了说明JUnit的演化进程,我们用图来表示各个设计阶段的架构。我们用简单的符号,灰色路标符号
表明所使用的模式。当这个类在模式中的角色很明显时,就在路标中只指明模式名称;如果这个类在模式
中的角色不清晰,则在路标中还注明该类对应的参与模式。这个路标符号避免了混乱,见图1所示。

图1TestCase类应用了命令模式
3.2、在run()方法中填写方法体
下面要解决的问题就是给出一个方便的地方,让开发人员放置测试用的设置代码和测试代码。
TestCase定义为抽象的,表示开发人员要继承TestCase来创建自己的测试用例。如果我们象刚才那样,只
在TestCase中放置一个变量,没有任何方法,那么第一个目标,即易于编写测试代这个目标就难以达到。
对于所有的测试,有一个通用的结构,在这个结构中,可以设置测试钳夹(fixture),在测试钳夹下
运行一些代码,检查运行结果,然后清除测试钳夹。这表明,每个测试都运行在不同的钳夹下,一个测试
的结果不会影响其它的测试结果,这点符合测试框架的价值最大化的目标。
模板方法(templatemethod)模式很好地解决了上面提出的问题。模板方法模式的意图就是,在父类
中定义一个算法的操作的骨架,将具体的步骤推迟到子类中实现。模板方法在子类中重新定义一个算法的
特定步骤,不用改变这个算法的结构,这正好是我们的要求。我们只要求开发人员知道如何编写fixture
(即setup和teardown)代码,知道如何编写测试代码。fixtue代码和测试代码的执行顺序对所有的测试都
是一样的,不管fixture代码和测试代码是如何编写的。
这就是我们需要的模板方法:
publicvoidrun(){
setUp();
runTest();
tearDown();
}
这个模板方法的默认实现就是什么也不作。
protectedvoidrunTest(){
}
protectedvoidsetUp(){
}
protectedvoidtearDown(){
}
既然setUp和tearDown方法要能被覆写,同时还要能被框架调用,因此定义成保护的。这个阶段的设计
如图2所示。
JUnit的框架设计及其使用的设计模式_第1张图片
图2TestCase.run()方法应用了模板方法模式
3.3、用TestResult对象报告结果
如果一个TestCase在原始森林中运行,大概没人关心它的测试结果。你运行测试是要得到一个测试记
录,说明测试作了什么,什么没有作。
如果一个测试成功和失败的机会是相同的,或者我们只运行一个测试,那么我们只用在测试中设置一
个标志,当测试结束后检查这个标志即可。然而,测试成功和失败机会是不均衡的,测试通常是成功的,
因此我们只注重于测试故障的记录,对于成功的记录我们只做一个总概。
在SmallTalkBestPracticePatterns中,有一个叫“收集参数(collectingparameter)”的模式,
当你需要在多个方法中收集结果时,你可以传给方法一个参数或对象,用这个对象收集这些方法的执行结
果。我们创建一个新对象,测试结果(TestResult),去收集测试的结果。
publicclassTestResultextendsObject{
protectedintfRunTests;
publicTestResult(){
fRunTests=0;
}
}
这里一个简单的TestResult版本,它只是计数测试运行的数量。为了使用TestResult,我们必须把它
作为参数传给TestCase.run()方法,并通知TestResult当前测试已经开始。
publicvoidrun(TestResultresult){
result.startTest(this);//通知TestResult测试开始
setUp();
runTest();
tearDown();
}
TestResult会跟踪计数运行了多少个测试:
publicsynchronizedvoidstartTest(Testtest){
fRunTests++;
}
我们把TestREsult中的startTest方法定义成同步的,即线程安全的,那么一个TestREsult对象就可以
收集不同线程中的测试的结果。我们想让TestCase的接口保持简单,因此我们创建了一个无参数版本的
run()方法,它创建自己的TestResult对象。

publicTestResultrun(){
TestResultresult=createResult();
run(result);
returnresult;
}
protectedTestResultcreateResult(){
returnnewTestResult();
}
这里用到的设计如图3所示。
JUnit的框架设计及其使用的设计模式_第2张图片
图3:TestResult应用了收集参数模式
如果测试一直都是运行正确的,那么我们就不用写测试了。我们对测试的故障感兴趣,特别是那些我
们未预料到的故障。当然,我们可以期望故障以我们所希望的方式出现,例如计算得出一个不正确的结
果,或者一个更奇特的故障方式,例如编写一个数组越界错误。不管测试如何出现故障,我们还要能继续
进行其后的测试。
JUnit在故障(failure)和错误(error)之间作了区分。故障是可预期的,用断言来检测,错误是
不可预期的,如数组越界例外(ArrayIndexOutOfBoundsException)。故障标识为AssertionFailedError
错误。为了从故障中区分不可预料的错误,故障用第一个catch语句捕获,故障之外的错误用第二个catch
语句捕获,这样就保证了本测试之后的其它测试得以运行。
publicvoidrun(TestResultresult){
result.startTest(this);
setUp();
try{
runTest();
}catch(AssertionFailedErrore){//1
result.addFailure(this,e);
}catch(Throwablee){//2
result.addError(this,e);
}finally{
tearDown();
}
}
AssertionFailedError故障是由TestCase提供的assert方法触发的。JUnit为不同的用途提供了许多
assert方法,这里有一个简单的例子:
protectedvoidassert(booleancondition){
if(!condition)
thrownewAssertionFailedError();
}
AssertionFailedError故障不是由测试客户(测试的请求者,即TestCase中的测试方法)捕获的,而
是在模板方法TestCase.run()内捕获的。AssertionFailedError继承自Error。
publicclassAssertionFailedErrorextendsError{
publicAssertionFailedError(){}
}
在TestResult中收集错误的方法如下:
publicsynchronizedvoidaddError(Testtest,Throwablet){
fErrors.addElement(newTestFailure(test,t));
}
publicsynchronizedvoidaddFailure(Testtest,Throwablet){
fFailures.addElement(newTestFailure(test,t));
}
在框架中,TestFailure是一个内部帮助类,它将不成功的测试以及其运行中发生的例外对应起来,以
备将来报告。
publicclassTestFailureextendsObject{
protectedTestfFailedTest;
protectedThrowablefThrownException;
}
收集参数要求把它传递给每一个方法。如果我们这样作,每个测试方法需要有一个TestResult作为参
数,这会导致测试方法的签名型构受到破坏;利用例外,我们可以避免签名型构受到破坏,这也是对例外
的副作用的一个利用吧。测试用例方法,或者测试用例调用的帮助方法抛出例外来,它不用知道
TestResult的信息。MoneyTestSuite中的测试方法就可以作为例子,它表明测试方法不用知道TestResult
的任何信息。
publicvoidtestMoneyEquals(){
assert(!f12CHF.equals(null));
assertEquals(f12CHF,f12CHF);
assertEquals(f12CHF,newMoney(12,"CHF"));
assert(!f12CHF.equals(f14CHF));
}
JUnit中有很多不同用途的TestResult实现,默认的实现很简单,它计数发生故障和错误的数量,并收
集结果。TextTestResult用文本的表现方式表示收集到的结果,而JUnit测试运行器利用UITestResult,用
图形界面的方式表示收集的结果。
TestResult是JUnit框架的扩展点。客户可以定义它们自己的TestResult类,比如,定义一个
HTMLTestResult类,用HTML文档的形式报告测试结果。
3.4、Nostupidsubclasses-TestCaseagain
我们应用命令模式来表示一个测试。命令执行依赖一个这样的方法:execute(),在TestCase称为
run(),通过它使命令得到调用,这使得我们能用这个相同的接口实现不同的命令。
我们需要一个普遍的接口来运行我们的测试。然而所有的测试用例可能是在一个类中用不同的方法实
现的,这样可以避免为每一种测试方法创建一个类,从而导致类的数量急剧增长。某个复杂测试用例类也
许实现许多不同的测试方法,每个测试方法定义了一个简单测试用例。每个简单测试用例方法有象这样的
名字:testMoneyequals或testMoneyAdd,测试用例并不需要遵守那个简单的命令模式接口,同一个
Command类的不同实例可以调用不同的测试方法。因此,下一个问题就是,在测试客户(测试的调用者)的
眼里,要让所有的测试用例看起来是一样的。
回顾一下,这个问题被设计模式解决了,我们想到了Adapter模式。Adapter模式的意图就是,将一个
已经存在的接口转变为客户所需要的接口。这符合我们的需要,Adapter有几种不同的方式做到这一点。一
个方式就是类适配(classadapter),就是用子类来适配接口,具体说就是,用一个子类来继承已有的
类,用已有类中的方法来构造客户所需要的新的方法。例如,要将testMoneyequals适配为runTest,我们
继承MoneyTest类,覆写runTest方法,这个方法调用testMoneyEquals方法。
publicclassTestMoneyEqualsextendsMoneyTest{
publicTestMoneyEquals(){super("testMoneyEquals");}
protectedvoidrunTest(){testMoneyEquals();}
}
使用子类适配的方式要求为每个测试用例实现一个子类,这增加了测试者的负担。JUnit框架的一个目
标就是,在增加一个用例时尽量保持简单。另外,为每个测试方法创建一个子类也会导致类膨胀,如果有
许多类,这些类中就那么一个方法,这是不值得的,为它们取有意义的名字都很困难。
Java提供了匿名内隐类机制,解决了命名问题。我们用匿名内隐类来达到Adapter目的,且不用命名:
TestCasetest=newMoneyTest("testMoneyEquals"){
protectedvoidrunTest(){testMoneyEquals();}
};
这比通常的子类继承方便多了,它仍然在编译时进行类型检查,代价是增加了开发人员的负担。
SmalltalkBestPracticePatterns描述了这个问题的另外一个解决方案,不同的实例在相同的
pluggablebehavior下行为表现不同。其思想就是,使用一个类,这个类可以参数化,即根据不同的参数
值执行不同的逻辑,因此避免了子类继承。
最简单的可插入行为(pluggablebehavior)形式是可插入选择子(PluggableSelector)。在
SmallTalk中,PluggableSelector是一个变量,它指向一个方法,是一个方法指针。这个思想不局限于
SmallTalk,也适用于Java。在Java中没有方法选择子的概念,然而,Java的反射(reflection)API能根
据方法名这个字符串来调用方法,我们能利用Java的反射特性实现PluggableSelector。通常我们很少使
用Java反射,在这里,我们要涉及一个底层结构框架,它实现了反射。
JUnit提供给测试客户两种选择:或者使用PluggableSelector,或者使用匿名内隐类。默认地,我们
使用PluggableSelector方式,即runTest方法。在这种方式中,测试用例的名字必须与测试方法的名字一
致。如下所示,我们用反射特性调用方法。首先,我们查看方法对象,一旦有了这个方法对象,我们就可
以传给它参数,并调用它。由于我们的测试方法不带参数,因此,我们传进一个空的参数数组:
protectedvoidrunTest()throwsThrowable{
MethodrunMethod=null;
try{
runMethod=getClass().getMethod(fName,newClass[0]);
}catch(NoSuchMethodExceptione){
assert("Method\""+fName+"\"notfound",false);
}try{
runMethod.invoke(this,newClass[0]);
}
//catchInvocationTargetExceptionandIllegalAccessException
}
JDK1.1反射API只让我们查找public方法,因此你必须把测试方法定义为public,否则你会得到
NoSuchMethodException例外。
这是该阶段的设计,Adapter模式和PluggableSelector模式。
JUnit的框架设计及其使用的设计模式_第3张图片
图4:TestCase应用了Adapter模式(匿名内隐类)和PluggableSelector模式
〔begin译者添加〕
由于TestCase中只有一个runTest方法,那么是不是说一个TestCase中只能放一个测试方法呢?为此引入
PluggableSelector模式。在TestCase中放置多个名为testXxx()的方法,在new一个TestCase时,用selector指
定哪个testXxx方法与模板方法runTest对接。
〔end译者添加〕
3.5、不用担心是一个测试用例还是许多测试用例-TestSuite
一个系统通常要运行许多测试。现在,JUnit能运行一个测试,并用TestResult报告结果,下一步就是
扩展JUnit,让它能运行许多不同的测试。如果测试的调用者并不在意它是运行一个测试还是许多测试,即
它用同样的方式运行一个测试和运行许多测试,那么这个问题就解决了。Composite模式可以解决这个问
题,它的意图就是,将许多对象组成树状的具有部分/整体层次的结构,Composite让客户用同样的接口处
理单个的对象和整体组合对象。部分/整体的层次结构在此很有意义,一个组合测试可能是有许多小的组合
测试构成的,小的组合测试可能是有单个的简单测试构成的。
Composite模式有以下参与者:
·Component:是一个公共的统一的接口,用于与测试交互,无论这个测试是简单测试还是组合测试。
·Composite:用于维护测试集合的接口,这个测试集合就是组合测试。
·Leaf:表示简单测试用例,遵从Component接口。
这个模式要求我们引入一个抽象类,该类为简单对象和组合对象定义了统一的接口,它的主要作用是
定义这个接口,在Java里,我们直接使用接口,没有必要用抽象类来定义接口,因为Java有接口的概念,
而象C++没有接口的概念,使用接口避免了将JUnit功能交付给一个特定的基类。所有的测试必须遵从这个
接口,因此测试客户所看到的就是这个接口:
publicinterfaceTest{
publicabstractvoidrun(TestResultresult);
}
Leaf所代表的简单TestCase实现了这个接口,我们前面已经讨论过了。
下面,我们讨论Composite,即组合测试用例,称为测试套件(TestSuite)。TestSuite用Vector来存
放他的孩子(childtest):
publicclassTestSuiteimplementsTest{
privateVectorfTests=newVector();
}
测试套件的run()方法委托给它的孩子,即依次调用它的孩子的run()方法:
publicvoidrun(TestResultresult){
for(Enumeratione=fTests.elements();e.hasMoreElements();){
Testtest=(Test)e.nextElement();
test.run(result);
}
}
JUnit的框架设计及其使用的设计模式_第4张图片
图5:测试套件应用了composite模式
测试客户要向测试套件中添加测试,调用addTest方法:
publicvoidaddTest(Testtest){
fTests.addElement(test);
}
注意,上面的代码是如何依赖于Test接口的。既然TestCase和TestSuite都遵从同一个Test接口,因此
测试套件可以递归的包含测试用例和测试套件。开发人员可以创建自己的TestSuite,并用这个套件运行其
中所有的测试。
这是一个创建TestSuite的例子:
publicstaticTestsuite(){
TestSuitesuite=newTestSuite();
suite.addTest(newMoneyTest("testMoneyEquals"));
suite.addTest(newMoneyTest("testSimpleAdd"));
}
〔begin为有助于理解,此处为译者添加〕
以上代码中,suite.addTest(newMoneyTest("testMoneyEquals"))表示向测试套件suite中添加一个测
试,指定测试类为MoneyTest,测试方法为testMoneyEquals(由selector选定该方法,与模板方法
runTest对接)。
在MoneyTest类中没有声明MoneyTest(String)的构造器,那么MoneyTest(“testMoneyequals”)执行时调
用super(String)构造器,它定义于MoneyTest的父类TestCase中。
TestCase(此处也即MoneyTest)把“testMoneyEquals”字符串存放在私有变量中,这个变量是一个
方法指针,使用的是PluggableSelector模式,表明它所指定的方法testMoneyEquals要与模板方法runTest
对接。表明该测试用例实例中起作用的是testMoneyEquals(),利
用Java的反射特性实现对该方法的调用。
因此以上代码向suite中添加了2个测试实例,类型均为MoneyTest,但测试方法不同。
〔end为有助于理解,此处为译者添加〕
这个例子工作很好,但要我们手工添加所有的测试,这是很笨的办法,当你编写一个测试用例时,你
要记得把它们添加到一个静态方法suite()中,否则它就不会运行。为此,我们为TestSuite增加了一个构
造器,它用测试用例的类作为其参数,它的作用就是提取这个类中的所有测试方法,并创建一个测试套
件,把这些提取出来的测试方法放进所创建的测试套件中。但这些测试方法要遵守一个简单的协定,即方
法命名以“test”作为前缀,且不带参数。这个构造器利用这个协定,使用Java的反射特性找出测试方
法,并构建测试对象。如果使用这个构造器,上面的代码就很简单:
publicstaticTestsuite(){
returnnewTestSuite(MoneyTest.class);
}
即为MoneyTest类中中的每一个testXxx方法都创建一个测试实例。〔此处为译者添加〕
但前一种方式仍然有用,比如你只想运行测试用例的一个子集。
3.6、概要
JUnit的设计到此告一段落。下图显示了JUnit设计中使用的模式。
JUnit的框架设计及其使用的设计模式_第5张图片
图6:JUnit中的模式
注意TestCase(JUnit框架中的核心功能)参与了4个模式。这说明在这个框架中,TestCase类是“模
式密集(patterndensity)”的,它是框架的中心,与其它支持角色有很强的关联。
下面是查看JUnit模式的另外一个视角。在这个情节图中,你依次看到每个模式所带来的效果。
Command模式创建了TestCase类,TemplateMethod模式创建了run方法,等等。这里所用的符号都来自
图6,只是去掉了文字。
JUnit的框架设计及其使用的设计模式_第6张图片
图7:JUnit中的模式情节板
要注意一点,当我们应用Composite模式时,复杂性突然增加了。Composite模式功能很强大,使用当
心。
4、结论
为了得出结论,我们作一些一般的观察:
·模式
以前,当我们开发框架和试图向其它人解释框架时,我们发现用模式来讨论设计是无用的。现在,你处于
一个极好的处境来判断用模式来描述框架是否有效,如果你喜欢上述讨论,那么也用这样的方式来表示你
的系统。
·模式密集度
围绕着TestCase有很高的模式密集度,TestCase是JUnit设计中的关键抽象,它易于使用,但难以改变。我
们发现围绕关键抽象有很高的模式密集度,是成熟框架的普遍现象。对于不成熟的框架,情形相反,它们
模式密集度不高。一旦你发现你要解决的是什么问题,你就开始“浓缩”你的解决方案,达到高的模式密集度。
·Eatyourowndogfood
Assoonaswehadthebaseunittestingfunctionalityimplemented,weapplieditourselves.
ATestTestverifiesthattheframeworkreportsthecorrectresultsforerrors,successes,
andfailures.Wefoundthisinvaluableaswecontinuedtoevolvethedesignofthe
framework.WefoundthatthemostchallengingapplicationofJUnitwastestingitsown
behavior.
·交集,而非合并
在框架开发中,总想包含进每一个特性,想让框架尽可能有价值,但有另一个因素作用相反:你希望开发
人员使用你的框架。框架的特性越少,学习就越容易,开发人员就越可能使用它。JUnit的设计就是这样
的思路,它实现那些对于运行测试而言是必不可少的特性,如运行测试套件、将不同的测试互相隔离、自
动运行测试等等。当然我们还会添加新的特性,但我们会仔细地加以选择,并把它们放进JUnit扩展包中。
在扩展包中,一个值得注意的成员就是TestDecorator类,它使用了Decorator模式,可以在测试代码运行
之前或运行之后执行其它的代码。〔此处译者有添加〕
·框架作者要花很多时间阅读框架代码
我们阅读框架代码的时间要比编写代码的时间多得多;我们为框架增加功能,但我们花同样多的时间为删
除框架中的重复功能。我们用各种途径为框架设计、增加类、移动类职责,只要我们能考虑到的各种途
径。在JUnit、测试、对象设计、框架开发和写文章的工作中,我们不断地提高洞察力,并受益无穷。

你可能感兴趣的:(设计模式,框架,算法,工作,JUnit)