JUnit 之走马观花篇

类别:软件工程 点击:0 评论:0 推荐:
JUnit 之走马观花篇 作者:JWT 李巍(outmyth)  【译者注】本文译自JUnit3.8.1发布版本中JUnit A Cook's Tour一文。1.简介      在一篇早些的文章(请参见Test Infected: Programmers Love Writing Tests, Java Report, July 1998, Volume 3, Number 7)中,我们描述了如何使用一个简单的框架来编写可重复的测试。在本文中我们将匆匆一瞥其内中细节,并向你展示该框架本身是如何被构造的。    我们细致地研究JUint框架并思索如何来构造它。我们发现了许多不同层次上的教训。在本文中,我们将尝试着立刻与它们进行沟通,这是一个令人绝望的任务,但至少它是在我们向你展示设计和构造一件价值被证实的软件的上下文中来进行的。    我们引发了一个关于框架目标的讨论。在对框架本身的表达期间,目标将重复出现许多小的细节中。此后,我们提出框架的设计和实现。设计将从模式(惊奇,惊奇)的角度进行描述,并作为优美的程序来予以实现。我们总结了一些优秀的关于框架开发的想法。2.什么是JUnit的目标呢?    首先,我们不得不回到开发的假定上去。如果缺少一个程序特性的自动测试(automated test),我们便假定其无法工作。这看起来要比主流的假定更加安全,主流的假定认为如果开发者向我们保证一个程序特性能够工作,那么现在和将来其都会永远工作。    从这个观点来看,当开发者编写和调试代码时,它们的工作并没有完成,它们还要必须编写测试来演示程序能够工作。然而,每个人都太忙,他们要做的事情太多,他们没有充足的时间用于测试。我已经有太多的代码需要编写,要我如何再来编写测试代码?回答我,强硬的项目经理先生。因此,首要目标就是编写一个框架,在这个框架中开发者能够看到实际来编写测试的希望之光。该框架必须要使用常见的工具,从而学习起来不会有太多的新东西。其不能比完全编写一个新测试所必须的工作更多。必须排除重复性的工作。    如果所有测试都这样去做的话,你将可以仅在一个调试器中编写表达式来完成。然而,这对于测试而言尚不充分。告诉我你的程序现在能够工作,对我而言并没有什么帮助,因为它并没有向我保证你的程序从我现在集成之后的每一分钟都将会工作,以及它并没有向我保证你的程序将依然能够工作五年,那时你已经离开了很长的时间。    于是,测试的第二个目标就是生成可持续保持其价值的测试。除原作者以外的其他人必须能够执行测试并解释其结果。应该能够将不同作者的测试结合起来并在一起运行,而不必担心相互冲突。    最后,必须能够以现有的测试作为支点来生成新的测试。生成一个装置(setup)或夹具(fixture)是昂贵的,并且一个框架必须能够对夹具进行重用,以运行不同的测试。哦,还有别的吗?3.JUnit的设计    JUnit的设计将以一种首次在Patterns Generate Architectures(请参见"Patterns Generate Architectures", Kent Beck and Ralph Johnson, ECOOP 94)中使用的风格来呈现。其思想是通过从零开始来应用模式,然后一个接一个,直至你获得系统架构的方式来讲解一个系统的设计。我们将提出需要解决的架构问题,总结用来解决问题的模式,然后展示如何将模式应用于JUnit。3.1 由此开始-TestCase    首先我们必须构建一个对象来表达我们的基本概念,TestCase(测试案例)。开发者经常在头脑中存在着测试案例,但在实现它们的时候却采用了许多不同的方式    打印语句    调试器表达式    测试脚本    如果我们想要轻松地操纵测试,就必须将它们构建成对象。这将会获取到一个仅仅是隐藏在开发者头脑中的测试,并使之具体化,其支持我们创建测试的目标,即能够持续地保持它们的价值。同时,对象的开发者比较习惯于使用对象来进行开发,因此将测试构建成对象的决定支持我们的目标-使测试的编写更加吸引人(或至少是不太华丽)。    Command(命令)模式(请参见Gamma, E., et al. Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley, Reading, MA, 1995)则能够比较好地满足我们的需求。摘引其意图(intent),“将一个请求封装成一个对象,从而使你可用不同的请求对客户进行参数化;对请求进行排队或记录请求日志...”Command告诉我们可以为一个操作生成一个对象并给出它的一个“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的演变过程,我们将使用图(diagram)来展示构架的快照(snapshot)。我们使用的标记很简单。其使用包含相关模式的尖方框来标注类。当类在模式中的角色(role)显而易见时,则仅显示模式的名称。如果角色并不清晰,则在尖方框中增加与该类相关的参与者的名称。该标记可使图的混乱度降到最小限度,并首次见诸于Applying Design Patterns in Java(请参见Gamma, E., Applying Design Patterns in Java, in Java Gems, SIGS Reference Library, 1997)。图1展示了这种应用于TestCase中的标记。由于我们是在处理一个单独的类并且没有不明确的地方,因此仅显示模式的名称。

 1 TestCase应用Command3.2 空白填充-run()    接下来要解决的问题是给开发者一个便捷的“地方”,用于放置他们的夹具代码和测试代码。将TestCase声明为abstract是指开发者希望通过子类化(subclassing)来对TestCase进行重用。然而,如果我们所有能作的就是提供一个只有一个变量且没有行为的超类,那么将无法做太多的工作来满足我们的首个目标-使测试更易于编写。    幸运的是,所有测试都具有一个共同的结构-建立一个测试夹具,在夹具上运行一些代码,检查结果,然后清理夹具。这意味着每一个测试将与一个新的夹具一起运行,并且一个测试的结果不会影响到其它测试的结果。这支持测试价值最大化的目标。    Template Method(模板方法)比较好地涉及到我们的问题。摘引其意图,“定义一个操作中算法的骨架,并将一些步骤延迟到子类中。Template Method使得子类能够不改变一个算法的结构便可重新定义该算法的某些特定步骤。”这完全恰当。我们就是想让开发者能够分别来考虑如何编写夹具(建立和拆卸)代码,以及如何编写测试代码。不管怎样,这种执行的次序对于所有测试都将保持相同,而不管夹具代码如何编写,或测试代码如何编写。Template Method如下:

public void run() {setUp();runTest();tearDown();}  这些方法被缺省实现为“什么都不做”:

protected void runTest() {}protected void setUp() {}protected void tearDown() {}  由于setUp和tearDown会被用来重写(override),而且其将由框架来进行调用,因此我们将其声明为protected。我们的第二个快照如图2所示。

 图2 TestCase.run()应用Template Method3.3 结果报告-TestResult    如果一个TestCase在森林中运行,是否有人关心其结果呢?当然-你之所以运行测试就是为了要证实它们能够运行。测试运行完后,你想要一个记录,一个什么能够工作和什么未能工作的总结。如果测试具有相等的成功或失败的机会,或者如果我们刚刚运行一个测试,我们可能只是在TestCase对象中设定一个标志,并且当测试完毕时去看这个标志。然而,测试(往往)是非常不均匀的-他们通常都会工作。因此我们只是想要记录失败,以及对成功的一个高度浓缩的总结。    The Smalltalk Best Practice Patterns(请参见 Beck, K. Smalltalk Best Practice Patterns, Prentice Hall, 1996)有一个可以适用的模式,称为Collecting Parameter(收集参数)。其建议当你需要在多个方法间进行结果收集时,应该在方法中增加一个参数,并传递一个对象来为你收集结果。我们创建一个新的对象,TestResult(测试结果),来收集运行的测试的结果。 

public class TestResult extends Object {protected int fRunTests; public TestResult() {fRunTests= 0;}} 

    这个简单版本的TestResult仅仅能够计算所运行测试的数目。为了使用它,我们不得不在TestCase.run()方法中添加一个参数,并通知TestResult该测试正在运行:

public void run(TestResult result) {result.startTest(this);setUp();runTest();tearDown();}     并且TestResult必须要记住所运行测试的数目:

public synchronized void startTest(Test test) {fRunTests++;}我们将TestResult的stratTest方法声明为synchronized,从而当测试运行在不同的线程中时,一个单独的TestResult能够安全地对结果进行收集。最后,我们想要保持TestCase简单的外部接口,因此创建一个无参的run()版本,其负责创建自己的TestResult。

public TestResult run() {TestResult  result= createResult();run(result);return result;}protected TestResult createResult() {return new TestResult();}  

    我们下面的设计快照可如图3所示。 图3 TestResult应用Collecting Parameter如果测试总是能够正确运行,那么我们将没有必要编写它们。只有当测试失败时测试才是让人感兴趣的,尤其是当我们没有预期到它们会失败的时候。更有甚者,测试能够以我们所预期的方式失败,例如通过计算一个不正确的结果;或者它们能够以更加吸引人的方式失败,例如通过编写一个数组越界。无论测试怎样失败,我们都想执行后面的测试。JUnit区分了失败(failures)和错误(errors)。失败的可能性是可预期的,并且以使用断言(assertion)来进行检查。而错误则是不可预期的问题,如ArrayIndexOutOfBoundsException。失败可通过一个AssertionFailedError来发送。为了能够识别出一个不可预期的错误和一个失败,将在catch子句(1)中对失败进行捕获。子句(2)则捕获所有其它的异常,并确保我们的测试能够继续运行... 

public void run(TestResult result) {result.startTest(this);setUp();try {runTest();}catch (AssertionFailedError e) { //1result.addFailure(this, e);}catch (Throwable e) { // 2result.addError(this, e);}finally {tearDown();}}     TestCase提供的assert方法会触发一个AssertionFailedError。JUnit针对不同的目的提供一组assert方法。下面只是最简单的一个:

protected void assert(boolean condition) {if (!condition)throw new AssertionFailedError();}(【译者注】由于与JDK中的关键字assert冲突,在最新的JUnit发布版本中此处的assert已经改为assertTrue。)AssertionFailedError不应该由客户(TestCase中的一个测试方法)来负责捕获,而应该由Template Method内部的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是一个小的框架内部帮助类(helper class),其将失败的测试和为后续报告发送信号的异常绑定在一起。

public class TestFailure extends Object {protected Test fFailedTest;protected Throwable fThrownException;}    规范形式的Collecting parameter模式要求我们将Collecting parameter传递给每一个方法。如果我们遵循该建议,每一个测试方法都将需要TestResult的参数。其将会造成这些方法签名(signature)的“污染”。使用异常来发送失败可以作为一个友善的副作用,使我们能够避免这种签名的污染。一个测试案例方法,或一个其所调用的帮助方法(helper method),可在不必知道TestResult的情况下抛出一个异常。作为一个进修材料,这里给出一个简单的测试方法,其来自于我们MoneyTest套件(【译者注】请参见JUnit发布版本中附带的另外一篇文章JUnit Test Infected: Programmers Love Writing Tests)。其演示了一个测试方法是如何不必知道任何关于TestResult的信息的。public void testMoneyEquals() {assert(!f12CHF.equals(null));assertEquals(f12CHF, f12CHF);assertEquals(f12CHF, new Money(12, "CHF"));assert(!f12CHF.equals(f14CHF));}(【译者注】由于与JDK中的关键字assert冲突,在最新的JUnit发布版本中此处的assert已经改为assertTrue。)    JUnit提出了关于TestResult的不同实现。其缺省实现是对失败和错误的数目进行计数并收集结果。TextTestResult收集结果并以一种文本的形式来表达它们。最后,JUnit Test Runner的图形版本则使用UITestResult来更新图形化的测试状态。    TestResult是框架的一个扩展点(extension point)。客户能够自定义它们的TestResult类,例如HTMLTestResult可将结果上报为一个HTML文档。3.4 不愚蠢的子类-再论TestCase    我们已经应用Command来表现一个测试。Command依赖于一个单独的像execute()这样的方法(在TestCase中称为run())来对其进行调用。这个简单接口允许我们能够通过相同的接口来调用一个command的不同实现。    我们需要一个接口对我们的测试进行一般性地运行。然而,所有的测试案例都被实现为相同类的不同方法。这避免了不必要的类扩散(proliferation of classes)。一个给定的测试案例类(test case class)可以实现许多不同的方法,每一个方法定义了一个单独的测试案例(test case)。每一个测试案例都有一个描述性的名称,如testMoneyEquals或testMoneyAdd。测试案例并不符合简单的command接口。相同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的目标,即框架应该尽可能地使测试案例的增加变得简单。此外,为每一个测试方法创建一个子类会造成类膨胀(class bloat)。许多类将仅具有一个单独的方法,这种开销不值得,而且很难会提出有意义的名称。Java提供了匿名内部类(anonymous inner class),其提供了一个让人感兴趣的Java所专门的方案来解决类的命名问题。通过匿名内部类我们能够创建一个Adapter而不必创造一个类的名称:TestCase test= new MoneyTest("testMoneyEquals ") {protected void runTest() { testMoneyEquals(); }};    这与完全子类化相比要便捷许多。其是以开发者的一些负担作为代价以保持编译时期的类型检查(compile-time type checking)。Smalltalk Best Practice Pattern描述了另外的方案来解决不同实例的问题,这些实例是在共同的pluggable behavior(插件式行为)标题下的不同表现。该思想是使用一个单独的参数化的类来执行不同的逻辑,而无需进行子类化。    Pluggable behavior的最简单形式是Pluggable Selector(插件式选择器)。Pluggable Selector在一个实例变量中保存了一个Smalltalk的selector方法。该思想并不局限于Smalltalk,其也适用于Java。在Java中并没有一个selector方法的标记。但是Java reflection(反射) API允许我们可以根据一个方法名称的表示字符串来调用该方法。我们可以使用该种特性来实现一个Java版的pluggable selector。岔开话题而言,我们通常不会在平常的应用程序中使用反射。在我们的案例中,我们正在处理的是一个基础设施框架,因此它可以戴上反射的帽子。    JUnit可以让客户自行选择,是使用pluggable selector,或是实现上面所提到的匿名adapter类。正因如此,我们提供pluggable selector作为runTest方法的缺省实现。在该情况下,测试案例的名称必须要与一个测试方法的名称相一致。如下所示,我们使用反射来对方法进行调用。首先我们会查找Method对象。一旦我们有了method对象,便会调用它并传递其参数。由于我们的测试方法没有参数,所以我们可以传递一个空的参数数组。 

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的reflection API仅允许我们发现public的方法。基于这个原因,你必须将测试方法声明为public,否则将会得到一个NoSuchMethodException异常。在下面的设计快照中,添加进了Adapter和Pluggable Selector。 图4 TestCase应用Adapter(与一个匿名内部类一起)或Pluggable Selector 3.5 不必关心一个或多个-TestSuit为了获得对系统状态的信心,我们需要运行许多测试。到现在为止,JUnit能够运行一个单独的测试案例并在一个TestResult中报告结果。我们接下来的挑战是要对其进行扩展,以使其能够运行许多不同的测试。当测试调用者不必关心其运行的是一个或多个测试案例时,这个问题便能够轻松地解决。能够在该情况下度过难关的一个流行模式就是Composite(组合)。摘引其意图,“将对象组合成树形结构以表示‘部分-整体’的层次结构。Composite使得用户对单个对象和组合对象的使用具有一致性。”在这里‘部分-整体’的层次结构是让人感兴趣的地方。我们想支持能够层层相套的测试套件。Composite引入如下的参与者:n      Component:声明我们想要使用的接口,来与我们的测试进行交互。n      Composite:实现该接口并维护一个测试的集合。n      Leaf:代表composite中的一个测试案例,其符合Component接口。该模式告诉我们要引入一个抽象类,来为单独的对象和composite对象定义公共的接口。这个类的基本意图就是定义一个接口。在Java中应用Composite时,我们更倾向于定义一个接口,而非抽象类。使用接口避免了将JUnit提交成一个具体的基类来用于测试。所必需的是这些测试要符合这个接口。因此我们对模式的描述进行变通,并引入一个Test接口:public interface Test {public abstract void run(TestResult result);}TestCase对应着Composite中的一个Leaf,并且实现了我们上面所看到的这个接口。下面,我们引入参与者Composite。我们将其取名为TestSuit(测试套件)类。TestSuit在一个Vector中保存了其子测试(child test):

public class TestSuite implements Test {private Vector fTests= new Vector();}run()方法对其子成员进行委托(delegate):public void run(TestResult result) {for (Enumeration e= fTests.elements(); e.hasMoreElements(); ) {Test test= (Test)e.nextElement();test.run(result);}} 

  图5 TestSuit应用Composite

最后,客户必须能将测试添加到一个套件中,它们将使用addTest方法来这样做:public void addTest(Test test) {fTests.addElement(test);}注意所有上面的代码是如何仅对Test接口进行依赖的。由于TestCase和TestSuit两者都符合Test接口,我们可以递归地将测试套件再组合成套件。所有开发者都能够创建他们自己的TestSuit。我们可创建一个组合了这些套件的TestSuit来运行它们所有的。下面是一个创建TestSuit的示例:

public static Test suite() {TestSuite suite= new TestSuite();suite.addTest(new MoneyTest("testMoneyEquals"));suite.addTest(new MoneyTest("testSimpleAdd"));} 

    这会很好地工作,但它需要我们手动地将所有测试添加到一个套件中。早期的JUnit采用者告诉我们这样是愚蠢的。只要你编写一个新的测试案例,你就必须记着要将其添加到一个static的suit()方法中,否则其将不会运行。我们添加了一个TestSuit的便捷构造方法,该构造方法将测试案例类作为一个参数。其意图是提取(extract)测试方法,并创建一个包含这些测试方法的套件。测试方法必须遵循的简单的约定是,以前缀“test”开头且不带参数。便捷构造方法就使用该约定,通过使用反射发现测试方法来构造测试对象。使用该构造方法,以上代码将会简化为:public static Test suite() {return new TestSuite(MoneyTest.class);}    当你只是想运行测试案例的一个子集时,则最初的方式将依然有用。3.6 总结    现在我们位于JUnit走马观花的最后。通过模式的角度来阐述JUnit的设计,可如下图所示。  图6 JUnit模式总结注意TestCase作为框架抽象的中心,其是如何与四个模式进行相关的。成熟的对象设计的描述展示了这种相同的“模式密度”。设计的中心是一个丰富的关系集合,这些关系与所支持的参与者(player)相互关联。这是另外一种看待JUnit中所有模式的方式。在这个情节图板(storyboard)上,依次对每个模式的影响进行抽象地表示。于是,Command模式创建了TestCase类,Template Method模式创建了run方法,等等。(情节图板的标记是在图6中标记的基础上删除了所有的文字)。 图7 JUnit模式的情节图板关于情节图板有一点要注意的是,图的复杂性是如何在我们应用Composite时进行跃迁的。其以图示的方式证实了我们的直觉,即Composite是一个强大的模式,但它会“使得图变得复杂。”因此应该谨慎地予以使用。4 结论    最后,让我们作一些全面的观察:      模式    我们发现从模式的角度来论述设计是非常宝贵的,无论是在我们进行框架的开发中,还是我们试图向其他人论述它时。你现在正处于一个完美的位置来判定,以模式来描述一个框架是否有效。如果你喜欢上面的论述,请为你自己的系统尝试相同的表现风格。     模式密度    TestCase周围的模式“密度”比较高,其是JUnit的关键抽象。高模式密度的设计更加易于使用,但却更加难于修改。我们发现像这样一个在关键抽象周围的高模式密度,对于成熟的框架而言是常见的。其对立面则应适用于那些不成熟的框架-它们应该具有低模式密度。一旦你发现你所要真正解决的问题,你便能够开始“浓缩(compress)”这个解决方案,直到一个模式越来越密集的区域,而这些模式在其中提供了杠杆的作用。     用自己做的东西    一旦我们完成了基本的单元测试功能,我们自身就要将其应用起来。TestCase可以验证框架能够为错误,成功和失败报告正确的结果。我们发现随着框架设计的继续演变,这是无价的。我们发现JUnit的最具挑战性的应用便是测试其本身的行为。      交集(intersection),而非并集(union    在框架开发中有一个诱惑就是,包含每一个你所能够具有的特性。毕竟,你想使框架尽可能得有价值。然而,会有一种阻碍-开发者不得不来决定使用你的框架。框架所具有的特性越少,那么学起来就越容易,开发者使用它的可能性就越大。JUnit便是根据这种风格写就的。其仅实现了那些测试运行所完全基本的特性-运行测试的套件,使各个测试的执行彼此相互隔离,以及测试的自动运行。是的,我们无法抵抗对于一些特性的添加,但是我们会小心地将其放到它们自己的扩展包中(test.extensions)。该包中有一个值得注意的成员是TestDecorator,其允许在一个测试之前和之后可以执行附加的代码。      框架编写者要读他们的代码    我们花在阅读JUnit的代码上的时间比起编写它的时间要多出很多。而且花在去除重复功能上的时间几乎与添加新功能的时间相等。我们积极地进行设计上的实验,以多种我们能够想出的不同方式来添加新的类以及移动职责。通过对JUnit持续不断地洞察(测试,对象设计,框架开发),以及发表更深入的文章的机会,我们因为我们的偏执而获得了回报(并将依然获得回报)。    Junit的最新版本可从ftp://www.armaties.com/D/home/armaties/ftp/TestingFramework/JUnit/下载。5 致谢    感谢John Vlissides,Ralph Johnson和Nick Edgar,感谢他们仔细的阅读和善意的指正。     参考资料:      JUnit A Cook's Tour, www.junit.org      [GOF95] Erich Gamma etc., Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley, 1995. 中译本:《设计模式:可复用面向对象软件的基础》,李英军等译,机械工业出版社,2000 年9月。

本文地址:http://com.8s8s.com/it/it31848.htm