UML软件工程组织

JUnit的框架设计及其使用的设计模式
翻译:胡拥军 hu.yong.jun@ctgpc.com.cn 〔有所添加〕
原文:JUnit A Cook's Tour 见 www.junit.org

 1、介绍 
    在较早的文章(Test Infected: Programmers Love Writing Tests)中,我们描述了如何用一个简单
的框架编写可重复的测试;本文则说明这个框架是如何构造的。
    仔细地学习JUnit框架,从中可以看出我们是如何设计这个框架的。我们看到不同层次的JUnit教程,
但在本文中,我们希望更清楚地说明问题。弄清JUnit的设计思路是非常有价值的。
    我们先讨论一下Junit的目标,这些目标会在JUnit的每个细小之处得到体现。围绕着JUnit的目标,我
们给出Junit框架的设计和实现。我们会用模式和程序实现例子来描述这个设计。我们还会看到,在开发这
个框架时,当然还有其它的可选途径。
2、目标 
    什么是JUnit的目标?
    首先,我们回到开发的前提假设。我们假设如果一个程序不能自动测试,那么它就不会工作。但有更
多的假设认为,如果开发人员保证程序能工作,那么它就会永远正常工作,与与这个假设相比,我们的假
设实在是太保守了。
    从这个观点出发,开发人员编写了代码,并进行了调试,还不能说他的工作完成了,他必须编写测试
脚本,证明程序工作正常。然而,每个人都很忙,没有时间去进行测试工作。他们会说,我编写程序代码
的时间都很紧,那有时间去写测试代码呢?
    因此,首要的目标就是,构建一个测试框架,在这个框架里,开发人员能编写测试代码。框架要使用
熟悉的工具,无需花很多精力就可以掌握。它还要消除不必要的代码,除了必须的测试代码外,消除重复
劳动。
    如果仅仅这些是测试要作的,那么你在调试器中写一个表达式就可以实现。但是,测试不仅仅这些。
虽然你的程序工作很好,但这不够,因为你不能保证集成后的即使一分钟内你的程序是否还会正常,你更
不能保证5年内它还是否正常,那时你已经离开很久了。
    因此,测试的第二个目标就是创建测试,并能保留这些测试,将来它们也是有价值的,其它的人可以
执行这些测试,并验证测试结果。有可能的话,还要把不同人的测试收集在一起,一起执行,且不用担心
它们之间互相干扰。
    最后,还要能用已有的测试创建新的测试。每次创建新的测试设置或测试钳(test fixture)是很花
费代价的,框架能复用测试设置,执行不同的测试。
3、JUnit设计 
    最早,JUnit的设计思路源于"用模式生成架构(Patterns Generate Architectures)"一文。它的思
想就是,从0开始设计一个系统,一个一个地应用模式,直到最后构造出这个系统的架构,这样就完成一个
系统的设计。我们马上提出要解决的架构问题,用模式来解决这个问题,并说明如何在JUnit中应用这些模
式的。
3.1、从测试用例TestCase开始 
    首先我们创建一个对象来表示基础概念:测试用例(TestCase)。测试用例常常就存在于开发人员的
头脑中,他们用不同的方式实现测试用例:
·    打印语句
·    调试表达式
·    测试脚本
    如何我们想很容易地操纵测试,那么就必须把测试作为对象。开发人员脑海中的测试是模糊的,测试作
为对象,就使得测试更具体了,测试就可以长久保留以便将来有用,这是测试框架的目标之一。同时,对
象开发人员习惯于对象,因此把测试作为对象就能达到让编写测试代码更具吸引力的目的。
    在这里,命令模式(command)满足我们的需要。该模式把请求封装成对象,即为请求操作生成一个对
象,这个对象中有一个“执行(execute)”方法。命令模式中,请求者不是直接调用命令执行者,而是通
过一个命令对象去调用执行者,具体说,先为命令请求生成一个命令对象,然后动态地在这个命令对象中
设置命令执行者,最后用命令对象的execute方法调用命令执行者。这是TestCase类定义代码:〔此处译者
有添加〕
public abstract class TestCase implements Test { 
    ...  
}
    因为我们希望通过继承复用这个类,我门把它定义成“public abstract”。现在我们先不管它实现
Test接口,在此时的设计里,你只要把TestCase看成是一个单个的类就行了。
    每个TestCase有一个名字属性,当测试出现故障时,可以用它来识别是哪个测试用例。
public abstract class TestCase implements Test { 
    private final String fName; 
    public TestCase(String name) { 
        fName= name; 
    } 
    public abstract void run(); 
        … 
}
    为了说明JUnit的演化进程,我们用图来表示各个设计阶段的架构。我们用简单的符号,灰色路标符号
表明所使用的模式。当这个类在模式中的角色很明显时,就在路标中只指明模式名称;如果这个类在模式
中的角色不清晰,则在路标中还注明该类对应的参与模式。这个路标符号避免了混乱,见图1所示。
 
图1 TestCase类应用了命令模式
3.2、在run()方法中填写方法体 
    下面要解决的问题就是给出一个方便的地方,让开发人员放置测试用的设置代码和测试代码。
TestCase定义为抽象的,表示开发人员要继承TestCase来创建自己的测试用例。如果我们象刚才那样,只
在TestCase中放置一个变量,没有任何方法,那么第一个目标,即易于编写测试代这个目标就难以达到。
    对于所有的测试,有一个通用的结构,在这个结构中,可以设置测试钳夹(fixture),在测试钳夹下
运行一些代码,检查运行结果,然后清除测试钳夹。这表明,每个测试都运行在不同的钳夹下,一个测试
的结果不会影响其它的测试结果,这点符合测试框架的价值最大化的目标。
    模板方法(template method)模式很好地解决了上面提出的问题。模板方法模式的意图就是,在父类
中定义一个算法的操作的骨架,将具体的步骤推迟到子类中实现。模板方法在子类中重新定义一个算法的
特定步骤,不用改变这个算法的结构,这正好是我们的要求。我们只要求开发人员知道如何编写fixture
(即setup和teardown)代码,知道如何编写测试代码。fixtue代码和测试代码的执行顺序对所有的测试都
是一样的,不管fixture代码和测试代码是如何编写的。
    这就是我们需要的模板方法:
public void run() { 
    setUp(); 
    runTest(); 
    tearDown(); 
}
    这个模板方法的默认实现就是什么也不作。
protected void runTest() { 

protected void setUp() { 

protected void tearDown() { 
}
    既然setUp和tearDown方法要能被覆写,同时还要能被框架调用,因此定义成保护的。这个阶段的设计
如图2所示。
  
图2 TestCase.run()方法应用了模板方法模式
3.3、用TestResult对象报告结果 
    如果一个TestCase在原始森林中运行,大概没人关心它的测试结果。你运行测试是要得到一个测试记
录,说明测试作了什么,什么没有作。
    如果一个测试成功和失败的机会是相同的,或者我们只运行一个测试,那么我们只用在测试中设置一
个标志,当测试结束后检查这个标志即可。然而,测试成功和失败机会是不均衡的,测试通常是成功的,
因此我们只注重于测试故障的记录,对于成功的记录我们只做一个总概。
    在SmallTalk Best Practice Patterns中,有一个叫“收集参数(collecting parameter)”的模式,
当你需要在多个方法中收集结果时,你可以传给方法一个参数或对象,用这个对象收集这些方法的执行结
果。我们创建一个新对象,测试结果(TestResult),去收集测试的结果。
public class TestResult extends Object { 
    protected int fRunTests; 
    public TestResult() { 
       fRunTests= 0; 
    } 
}
    这里一个简单的TestResult版本,它只是计数测试运行的数量。为了使用TestResult,我们必须把它
作为参数传给TestCase.run()方法,并通知TestResult当前测试已经开始。
public void run(TestResult result) { 
    result.startTest(this); //通知TestResult测试开始
    setUp(); 
    runTest(); 
    tearDown(); 
}
    TestResult会跟踪计数运行了多少个测试:
public synchronized void startTest(Test test) { 
    fRunTests++; 
}
    我们把TestREsult中的startTest方法定义成同步的,即线程安全的,那么一个TestREsult对象就可以
收集不同线程中的测试的结果。我们想让TestCase的接口保持简单,因此我们创建了一个无参数版本的
run()方法,它创建自己的TestResult对象。
    
public TestResult run() { 
    TestResult result= createResult(); 
    run(result); 
    return result; 

protected TestResult createResult() { 
    return new TestResult(); 
}
    这里用到的设计如图3所示。
  
图3:TestResult应用了收集参数模式
    如果测试一直都是运行正确的,那么我们就不用写测试了。我们对测试的故障感兴趣,特别是那些我
们未预料到的故障。当然,我们可以期望故障以我们所希望的方式出现,例如计算得出一个不正确的结
果,或者一个更奇特的故障方式,例如编写一个数组越界错误。不管测试如何出现故障,我们还要能继续
进行其后的测试。
    JUnit在故障(failure)和错误(error)之间作了区分。故障是可预期的,用断言来检测,错误是
不可预期的,如数组越界例外(ArrayIndexOutOfBoundsException)。故障标识为AssertionFailedError
错误。为了从故障中区分不可预料的错误,故障用第一个catch语句捕获,故障之外的错误用第二个catch
语句捕获,这样就保证了本测试之后的其它测试得以运行。
public void run(TestResult result) { 
    result.startTest(this); 
    setUp(); 
    try { 
        runTest(); 
    } catch (AssertionFailedError e) { //1 
        result.addFailure(this, e); 
    } catch (Throwable e) { // 2 
        result.addError(this, e); 
    } finally { 
        tearDown(); 
    } 
}
    AssertionFailedError故障是由TestCase提供的assert方法触发的。JUnit为不同的用途提供了许多
assert方法,这里有一个简单的例子:
protected void assert(boolean condition) { 
    if (!condition) 
        throw new AssertionFailedError(); 
}
    AssertionFailedError故障不是由测试客户(测试的请求者,即TestCase中的测试方法)捕获的,而
是在模板方法TestCase.run()内捕获的。AssertionFailedError继承自Error。
public class AssertionFailedError extends Error { 
    public AssertionFailedError () {} 
}
    在TestResult中收集错误的方法如下:
public synchronized void addError(Test test, Throwable t) { 
    fErrors.addElement(new TestFailure(test, t)); 

public synchronized void addFailure(Test test, Throwable t) { 
    fFailures.addElement(new TestFailure(test, t)); 
}
    在框架中,TestFailure是一个内部帮助类,它将不成功的测试以及其运行中发生的例外对应起来,以
备将来报告。
public class TestFailure extends Object { 
    protected Test fFailedTest; 
    protected Throwable fThrownException; 
}
    收集参数要求把它传递给每一个方法。如果我们这样作,每个测试方法需要有一个TestResult作为参
数,这会导致测试方法的签名型构受到破坏;利用例外,我们可以避免签名型构受到破坏,这也是对例外
的副作用的一个利用吧。测试用例方法,或者测试用例调用的帮助方法抛出例外来,它不用知道
TestResult的信息。MoneyTestSuite中的测试方法就可以作为例子,它表明测试方法不用知道TestResult
的任何信息。
public void testMoneyEquals() { 
    assert(!f12CHF.equals(null)); 
    assertEquals(f12CHF, f12CHF); 
    assertEquals(f12CHF, new Money(12, "CHF")); 
    assert(!f12CHF.equals(f14CHF)); 
}
    JUnit中有很多不同用途的TestResult实现,默认的实现很简单,它计数发生故障和错误的数量,并收
集结果。TextTestResult用文本的表现方式表示收集到的结果,而JUnit测试运行器利用UITestResult,用
图形界面的方式表示收集的结果。
    TestResult是JUnit框架的扩展点。客户可以定义它们自己的TestResult类,比如,定义一个
HTMLTestResult类,用HTML文档的形式报告测试结果。
3.4、No stupid subclasses - TestCase again 
    我们应用命令模式来表示一个测试。命令执行依赖一个这样的方法:execute(),在TestCase称为
run(),通过它使命令得到调用,这使得我们能用这个相同的接口实现不同的命令。
    我们需要一个普遍的接口来运行我们的测试。然而所有的测试用例可能是在一个类中用不同的方法实
现的,这样可以避免为每一种测试方法创建一个类,从而导致类的数量急剧增长。某个复杂测试用例类也
许实现许多不同的测试方法,每个测试方法定义了一个简单测试用例。每个简单测试用例方法有象这样的
名字:testMoneyequals或testMoneyAdd,测试用例并不需要遵守那个简单的命令模式接口,同一个
Command类的不同实例可以调用不同的测试方法。因此,下一个问题就是,在测试客户(测试的调用者)的
眼里,要让所有的测试用例看起来是一样的。
    回顾一下,这个问题被设计模式解决了,我们想到了Adapter模式。Adapter模式的意图就是,将一个
已经存在的接口转变为客户所需要的接口。这符合我们的需要,Adapter有几种不同的方式做到这一点。一
个方式就是类适配(class adapter),就是用子类来适配接口,具体说就是,用一个子类来继承已有的
类,用已有类中的方法来构造客户所需要的新的方法。例如,要将testMoneyequals适配为runTest,我们
继承MoneyTest类,覆写runTest方法,这个方法调用testMoneyEquals方法。
public class TestMoneyEquals extends MoneyTest { 
    public TestMoneyEquals() { super("testMoneyEquals"); } 
    protected void runTest () { testMoneyEquals(); } 
}
    使用子类适配的方式要求为每个测试用例实现一个子类,这增加了测试者的负担。JUnit框架的一个目
标就是,在增加一个用例时尽量保持简单。另外,为每个测试方法创建一个子类也会导致类膨胀,如果有
许多类,这些类中就那么一个方法,这是不值得的,为它们取有意义的名字都很困难。
    Java提供了匿名内隐类机制,解决了命名问题。我们用匿名内隐类来达到Adapter目的,且不用命名:
TestCase test= new MoneyTest("testMoneyEquals ") { 
    protected void runTest() { testMoneyEquals(); } 
};
    这比通常的子类继承方便多了,它仍然在编译时进行类型检查,代价是增加了开发人员的负担。
Smalltalk Best Practice Patterns描述了这个问题的另外一个解决方案,不同的实例在相同的
pluggable behavior下行为表现不同。其思想就是,使用一个类,这个类可以参数化,即根据不同的参数
值执行不同的逻辑,因此避免了子类继承。
    最简单的可插入行为(pluggable behavior)形式是可插入选择子(Pluggable Selector)。在
SmallTalk中,Pluggable Selector是一个变量,它指向一个方法,是一个方法指针。这个思想不局限于 
SmallTalk,也适用于Java。在Java中没有方法选择子的概念,然而,Java的反射(reflection)API能根
据方法名这个字符串来调用方法,我们能利用Java的反射特性实现Pluggable Selector。通常我们很少使
用Java反射,在这里,我们要涉及一个底层结构框架,它实现了反射。
    JUnit提供给测试客户两种选择:或者使用Pluggable Selector,或者使用匿名内隐类。默认地,我们
使用Pluggable Selector方式,即runTest方法。在这种方式中,测试用例的名字必须与测试方法的名字一
致。如下所示,我们用反射特性调用方法。首先,我们查看方法对象,一旦有了这个方法对象,我们就可
以传给它参数,并调用它。由于我们的测试方法不带参数,因此,我们传进一个空的参数数组:
protected void runTest() throws Throwable { 
    Method runMethod= null; 
    try { 
        runMethod= getClass().getMethod(fName, new Class[0]); 
    } catch (NoSuchMethodException e) { 
        assert("Method \""+fName+"\" not found", false); 
    }    try { 
        runMethod.invoke(this, new Class[0]); 
    } 
    // catch InvocationTargetException and IllegalAccessException 
}
    JDK1.1反射API只让我们查找public方法,因此你必须把测试方法定义为public,否则你会得到
NoSuchMethodException例外。
    这是该阶段的设计,Adapter模式和Pluggable Selector模式。
  
图4:TestCase应用了Adapter模式(匿名内隐类)和Pluggable Selector模式
〔begin 译者添加〕
由于TestCase中只有一个runTest方法,那么是不是说一个TestCase中只能放一个测试方法呢?为此引入
Pluggable Selector模式。在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功能交付给一个特定的基类。所有的测试必须遵从这个
接口,因此测试客户所看到的就是这个接口:
public interface Test { 
    public abstract void run(TestResult result); 
}
    Leaf所代表的简单TestCase实现了这个接口,我们前面已经讨论过了。
    下面,我们讨论Composite,即组合测试用例,称为测试套件(TestSuite)。TestSuite用Vector来存
放他的孩子(child test):
public class TestSuite implements Test { 
    private Vector fTests= new Vector(); 
}
    测试套件的run()方法委托给它的孩子,即依次调用它的孩子的run()方法:
public void run(TestResult result) { 
    for (Enumeration e= fTests.elements(); e.hasMoreElements(); ) { 
        Test test= (Test)e.nextElement(); 
        test.run(result); 
    } 
}
 
图5:测试套件应用了composite模式
    测试客户要向测试套件中添加测试,调用addTest方法:
public void addTest(Test test) { 
    fTests.addElement(test); 
}
    注意,上面的代码是如何依赖于Test接口的。既然TestCase和TestSuite都遵从同一个Test接口,因此
测试套件可以递归的包含测试用例和测试套件。开发人员可以创建自己的TestSuite,并用这个套件运行其
中所有的测试。
    这是一个创建TestSuite的例子:
public static Test suite() { 
    TestSuite suite= new TestSuite(); 
    suite.addTest(new MoneyTest("testMoneyEquals")); 
    suite.addTest(new MoneyTest("testSimpleAdd")); 
}
〔begin    为有助于理解,此处为译者添加〕
    以上代码中,suite.addTest(new MoneyTest("testMoneyEquals"))表示向测试套件suite中添加一个测
试,指定测试类为MoneyTest,测试方法为testMoneyEquals(由selector选定该方法,与模板方法
runTest对接)。
    在MoneyTest类中没有声明MoneyTest(String)的构造器,那么MoneyTest(“testMoneyequals”)执行时调
用super(String)构造器,它定义于MoneyTest的父类TestCase中。
    TestCase(此处也即MoneyTest)把“testMoneyEquals”字符串存放在私有变量中,这个变量是一个
方法指针,使用的是Pluggable Selector模式,表明它所指定的方法testMoneyEquals要与模板方法runTest
对接。表明该测试用例实例中起作用的是testMoneyEquals(),利
用Java的反射特性实现对该方法的调用。
    因此以上代码向suite中添加了2个测试实例,类型均为MoneyTest,但测试方法不同。
〔end    为有助于理解,此处为译者添加〕
    这个例子工作很好,但要我们手工添加所有的测试,这是很笨的办法,当你编写一个测试用例时,你
要记得把它们添加到一个静态方法suite()中,否则它就不会运行。为此,我们为TestSuite增加了一个构
造器,它用测试用例的类作为其参数,它的作用就是提取这个类中的所有测试方法,并创建一个测试套
件,把这些提取出来的测试方法放进所创建的测试套件中。但这些测试方法要遵守一个简单的协定,即方
法命名以“test”作为前缀,且不带参数。这个构造器利用这个协定,使用Java的反射特性找出测试方
法,并构建测试对象。如果使用这个构造器,上面的代码就很简单:
public static Test suite() { 
    return new TestSuite(MoneyTest.class); 
}
     即为MoneyTest类中中的每一个testXxx方法都创建一个测试实例。〔此处为译者添加〕
    但前一种方式仍然有用,比如你只想运行测试用例的一个子集。
3.6、概要 
JUnit的设计到此告一段落。下图显示了JUnit设计中使用的模式。
  
图6:JUnit中的模式
    注意TestCase(JUnit框架中的核心功能)参与了4个模式。这说明在这个框架中,TestCase类是“模
式密集(pattern density)”的,它是框架的中心,与其它支持角色有很强的关联。
    下面是查看JUnit模式的另外一个视角。在这个情节图中,你依次看到每个模式所带来的效果。
Command模式创建了TestCase类,Template Method模式创建了run方法,等等。这里所用的符号都来自
图6,只是去掉了文字。
  
图7:JUnit中的模式情节板
    要注意一点,当我们应用Composite模式时,复杂性突然增加了。Composite模式功能很强大,使用当
心。
4、结论 
    为了得出结论,我们作一些一般的观察:
·    模式
以前,当我们开发框架和试图向其它人解释框架时,我们发现用模式来讨论设计是无用的。现在,你处于
一个极好的处境来判断用模式来描述框架是否有效,如果你喜欢上述讨论,那么也用这样的方式来表示你
的系统。
·    模式密集度
围绕着TestCase有很高的模式密集度,TestCase是JUnit设计中的关键抽象,它易于使用,但难以改变。我
们发现围绕关键抽象有很高的模式密集度,是成熟框架的普遍现象。对于不成熟的框架,情形相反,它们
模式密集度不高。一旦你发现你要解决的是什么问题,你就开始“浓缩”你的解决方案,达到高的模式密集度。
·    Eat your own dog food 
As soon as we had the base unit testing functionality implemented, we applied it ourselves. 
A TestTest verifies that the framework reports the correct results for errors, successes, 
and failures. We found this invaluable as we continued to evolve the design of the 
framework. We found that the most challenging application of JUnit was testing its own 
behavior. 
·    交集,而非合并
在框架开发中,总想包含进每一个特性,想让框架尽可能有价值,但有另一个因素作用相反:你希望开发
人员使用你的框架。框架的特性越少,学习就越容易,开发人员就越可能使用它。JUnit的设计就是这样
的思路,它实现那些对于运行测试而言是必不可少的特性,如运行测试套件、将不同的测试互相隔离、自
动运行测试等等。当然我们还会添加新的特性,但我们会仔细地加以选择,并把它们放进JUnit扩展包中。
在扩展包中,一个值得注意的成员就是TestDecorator类,它使用了Decorator模式,可以在测试代码运行
之前或运行之后执行其它的代码。〔此处译者有添加〕
·    框架作者要花很多时间阅读框架代码
我们阅读框架代码的时间要比编写代码的时间多得多;我们为框架增加功能,但我们花同样多的时间为删
除框架中的重复功能。我们用各种途径为框架设计、增加类、移动类职责,只要我们能考虑到的各种途
径。在JUnit、测试、对象设计、框架开发和写文章的工作中,我们不断地提高洞察力,并受益无穷。

 

版权所有:UML软件工程组织