原文:http://www.javaworld.com/jw-12-2000/jw-1221-junit.html

JUnit是一种典型的工具包:如果遵循它最初的设计规范加以使用,JUnit可以帮助开发人员建立起一组相当健壮的测试;反之,它可能只会给你的项目带来一团乱麻。本文将列出一些有用的技巧,它们将帮助你避免让那团乱麻入侵你的项目。这些技巧有时候甚至会自相矛盾,但这是不可避免的。以我的经验来看,软件开发几乎没有什么又快又好的规则,而那些自称是法则的东西大多是会有些误导的吧。

本文中我们会实现两种对开发人员有用的工具类:

  • 从文件系统中取得类的集合,并自动生成测试套件
  • 一个可以更好地支持多线程测试的测试类

在做单元测试的时候,许多开发团队总想自己去建立一套测试框架来完成单元测试的任务。而开源的JUnit提供了一套很好的单元测试框架,从而扼杀了许多这种建立一次性测试框架的想法。如果把JUnit作为整个开发项目的一部分去完成,它将发挥出自己最大的能量,因为开发人员可以使用JUnit更加简单地撰写并执行测试用例。那么,我们该如何使用JUnit呢?

不要使用构造器来初始化你的测试用例

在构造器里初始化你的测试用例可能并不是一个好主意。
考虑一下:

public class SomeTest extends TestCase
   public SomeTest (String testName) {
       super (testName);
       // Perform test set-up
   }
}

想象一下,当程序在执行初始化的时候,初始化部分的代码抛出了一个IllegalSateException的异常。而当JUnit抓住这个异常以后,它会抛出一个AssertionFailedError,接着它会告诉你无法建立此测试类的实例。以下是一个此类异常消息的例子:

junit.framework.AssertionFailedError: Cannot instantiate test case: test1 at
junit.framework.Assert.fail(Assert.java:143) at
junit.framework.TestSuite.runTest(TestSuite.java:178) at
junit.framework.TestCase.runBare(TestCase.java:129) at
junit.framework.TestResult.protect(TestResult.java:100) at
junit.framework.TestResult.runProtected(TestResult.java:117) at
junit.framework.TestResult.run(TestResult.java:103) at
junit.framework.TestCase.run(TestCase.java:120) at
junit.framework.TestSuite.run(TestSuite.java, Compiled Code) at
junit.ui.TestRunner2.run(TestRunner.java:429)

这堆消息并没有提供给我们更多我们想要的信息,它只是在说无法建立这个类的实例。它没法指出是哪行代码抛出了这个异常,信息的缺失让开发人员很难从中找到异常的根源。
如果不在构造器里初始化测试数据,而是去重写setUp()这个方法,那你就能准确地定位任何在setUp()方法中抛出的异常。如果和先前的异常消息比较一下:

java.lang.IllegalStateException: Oops at bp.DTC.setUp(DTC.java:34) at
junit.framework.TestCase.runBare(TestCase.java:127) at
junit.framework.TestResult.protect(TestResult.java:100) at
junit.framework.TestResult.runProtected(TestResult.java:117) at
junit.framework.TestResult.run(TestResult.java:103)
...

这样的消息提供了更多的信息,你能知道这是一个IllegalStateException异常,而且也知道是从哪一行抛出来的。这样一来,找到并修复测试用例初始化时的错误就简单多了。

不要假定测试用例执行的顺序

你不该假定测试用例会以任何特殊的顺序执行。看看以下这段代码:

public class SomeTestCase extends TestCase {

    public SomeTestCase (String testName) {
       super (testName);
   }
   public void testDoThisFirst () {
   ...
   }
   public void testDoThisSecond () {
   }
}

由于JUnit使用了Reflection机制,它就不能确保测试用例会以任何一种特定的顺序来执行。在不同平台、不同的JVM上可能会有不同的结果,除非你在设计测试用例时就规定好了某种顺序。其实,不依赖于某种顺序的测试用例会更加健壮,因为打乱这些用例的顺序也不会使它们互相影响。如果用例依赖于某种顺序,那么即使你只是做了很小的改动,也将会很难定位缺陷发生的位置。

只有在少数情况下,依赖于某种顺序的测试用例才有意义。比如,当后一个用例的执行依赖于前一个用例留下的数据时,按照顺序执行的测试用例将会更加高效。这时,你需要使用一个静态的suite()方法来确保用例的执行顺序,比如这样:

public static Test suite() {
   suite.addTest(new SomeTestCase ("testDoThisFirst";));
   suite.addTest(new SomeTestCase ("testDoThisSecond";));
   return suite;
}

JUnit的API文档中并没有保证测试用例会以一定的顺序执行,因为JUnit使用了一个Vector来储存这些测试。但是,如果你使用了类似上面的代码,你就可以确保这些用例执行的顺序,因为它们已经被加到一个测试套件中了。

避免测试带来的副作用

测试用例的副作用会带来两个问题:

  • 如果它们改变了外部的数据,其它依赖于这些数据的用例就会受到影响
  • 你将不得不手工修改外部数据,才能重复进行测试

在第一种情况中,一个测试用例可能会执行通过。但如果把这个测试用例加到某一组测试套件中,它就可能会导致套件中的其他用例执行失败。而且你会很难调试这些错误,用例失败的根源可能离显示出错的地方很远。

在第二种情况中,测试用例可能会去修改某些系统状态或者数据,如果不手工把它们改回来,第二次执行该用例可能就会失败了,就比如测试用例所测的函数要去删除数据库里的某些数据。所以,当你不得不在测试中加入一些手工操作时,请三思而后行。因为首先,这些手工操作需要被记录到文档中;第二,这些用例就再也不是“无人值守”的测试了,你再也不能把这些测试放到晚上执行,而自己却在一边睡大觉了。

写测试用例时请重写setUp()和tearDown()方法

考虑一下如下的代码:

public class SomeTestCase extends AnotherTestCase {
   // A connection to a database
   private Database theDatabase;
   public SomeTestCase (String testName) {
       super (testName);
   }
   public void testFeatureX () {
   ...
   }
   public void setUp () {
       // Clear out the database
       theDatabase.clear ();
   }
}

你能发现以上这段代码的错误吗?setup()方法应该调用super.setUp()方法来确保AnotherTestCase所定义的环境已经被初始化了。当然,有一个例外:如果你所设计的基类中,最多只有静态的测试数据,就不会有什么问题了。

在读取测试数据是避免使用绝对路径

测试经常需要从文件系统的某个路径中读取测试数据,考虑一下以下这段代码:

public void setUp () {
   FileInputStream inp ("C:\\TestData\\dataSet1.dat");
   ...
}

如果要使用以上这段代码,就必须确保数据在C:\TestData这个位置。在两种情况下,这种假设可能会出问题:

  • 在测试人员电脑的C盘中可能没有足够的磁盘空间,所以只能把数据保存在另一个磁盘上
  • 测试可能需要在另一个平台上运行,比如Unix

我可能以使用以下方法:

public void setUp () {
   FileInputStream inp ("dataSet1.dat");
   ...
}

如果想要使用这种方法,必须确保程序运行时的目录和测试数据的目录相一致。如果有几个不同的测试用例需要有这种限制,那除了改变当前的测试数据的目录就没有办法把这些测试用例整合在一个测试套件中了。

为了解决这个问题,可以使用Class.getResource()或者Class.getResourceAsStream来取得数据集。使用这种方式就意味着测试数据的路径是于程序运行时目录的相对路径。

如果可能的话,测试数据应该和源代码一起被保存在一个配置管理系统中。然而,如果你使用了如前所述的机制,你需要另写一个脚本把这些测试数据从配置管理系统复制到测试环境的运行目录中。另一种方法是把这些测试数据直接存储在测试环境中。如果使用这种方法,你需要一个与物理地址无关的机制来指定测试数据。你可以使用类,你可以写如下的代码,来映射一个类到一个指定的目录中:

InputStream inp = SourceResourceLoader.getResourceAsStream (this.getClass (), "dataSet1.dat");

这样一来,你只要决定如何来做这些类与测试数据之间的映射。你可以用一个系统参数来指定该资源树的根目录。然后类的完整的包名可以用来确定测试数据所在目录,测试数据便会从那个目录载入进来。对于Unix和Windows Nt两种不同的操作系统来说,映射的方式显得更加直截了当,这样就可以用“.”来代替File.separatorChar了。

把测试和测试对象放在同一目录下

如果测试类和被测试类的代码放在一个目录下,那么在一次build时,会同时编译测试和被测试类。这样一来就促使你在开发过程中保持测试用例和被测试方法的同步。事实上,如果单元测试不能和测试对象同步,那它就因过期而根本毫无用处。

给测试用例起些好名字

给测试起些如TestClassUnderTest的名字。比如说,如果一个测试类是用来测试MessageLog这个类的,就该叫TestMessageLog。那样哪个测试对应哪个测试对象会更加明了。测试类中的每个用例应该描述它是在测些什么:

  • testLoggingEmptyMessage()
  • testLoggingNullMessage()
  • testLoggingWarningMessage()
  • testLoggingErrorMessage()

合适的名字能帮助开发人员更好地理解每个测试用例的意图。

确保测试结果与时间无关
如果有可能,避免使用一些可能会过期的数据;这样的数据需要手工或用程序来更新。通常来说用测试去改变测试对象的一些初始化属性都是很简单的,就好象改变它“今天”的概念。这样测试用例就可以顺利运行,而不用去更新测试数据了。

相关文章:JUnit最佳实践[译][二]JUnit最佳实践[译][三]

分享到: