作者:Michel Casabianca
使用最流行的开放资源测试框架之一学习单元测试基础。
使用JUnit可以大量减少Java代码中程序错误的个数,JUnit是一种流行的单元测试框架,用于在发布代码之前对其进行单元测试。现在让我们来详细研究如何使用诸如JUnit、Ant和Oracle9i JDeveloper等工具来编写和运行单元测试。
为什么使用JUnit?
多数开发人员都同意在发布代码之前应当对其进行测试,并利用工具进行回归(regression)测试。做这项工作的一个简单方法是在所有Java类中以main()方法实施测试。例如,假设使用ISO格式(这意味着有一个以这一格式作为参数的构造器和返回一个格式化的ISO字符串的toString()方法)以及一个GMT时区来编写一个Date的子类。清单1 就是这个类的一个简单实现。
不过,这种测试方法并不需要单元测试限定语(qualifier),原因如下:
- 在一个类中进行测试的最小单元是方法,你应当对每个方法进行单独测试,以准确地找出哪些方法工作正常,哪些方法工作不正常。
- 即使前面的测试失败,也应当对各个方法进行测试。在此实施中,如果单个测试失败,后面的测试将根本不会运行。这就意味着你不会知道不良代码在你的实施中所占的百分比。
- 测试代码会出现在生成的类中。这在类的大小方面可能不是什么问题,但却可能会成为安全性因素之一:例如,如果你的测试嵌入了数据库连接密码,那么这一信息将很容易用于已发布的类中。
- 没有框架可以自动启动这一测试,你必须编写一个脚本来启动每一个测试。
- 在编写一个报告时,你必须编写自己的实现,并定义规则,以方便地报告错误。
JUnit框架就是设计用来解决这些问题的。这一框架主要是所有测试实例(称为"TestCase")的一个父类,并提供工具来运行所编写的测试、生成报告及定义测试包(test suite)。
让我们为IsoDate类编写一个测试:这个IsoDateTest类类似于:
import java.text.ParseException;
import junit.framework.TestCase;
/**
* Test case for <code>IsoDate</code>.
*/
public class IsoDateTest extends TestCase {
public void testIsoDate() throws
Exception {
IsoDate epoch=new IsoDate(
"1970-01-01 00:00:00 GMT");
assertEquals(0,epoch.getTime());
IsoDate eon=new IsoDate(
"2001-09-09 01:46:40 GMT");
assertEquals(
1000000000L*1000,eon.getTime());
}
public void testToString() throws
ParseException {
IsoDate epoch=new IsoDate(0);
assertEquals("1970-01-01
00:00:00 GMT",epoch.toString());
IsoDate eon=new IsoDate(
1000000000L*1000);
assertEquals("2001-09-09
01:46:40 GMT",eon.toString());
}
}
本例中要注意的重点是已经编写了一个用于测试的独立类,因此可以对这些文件进行过滤,以避免将这一代码嵌入到将要发布的文档中。另外,本例还为你希望在你的代码中测试的每个方法编写了一个专用测试方法,因此你将确切地知道需要对哪些方法进行测试、哪些方法工作正常以及哪些方法工作不正常。如果在编写实施文档之前已经编写了该测试,你就可以利用它来衡量工作的进展情况。
安装并运行JUnit
要运行此示例测试实例,必须首先下载并安装JUnit。JUnit的最新版本可以在JUnit的网站 www.junit.org免费下载。该软件包很小(约400KB),但其中包括了源代码和文档。要安装此程序,应首先对该软件包进行解压缩(junitxxx.zip)。它将创建一个目录(junitxxx),在此目录下有文档(在doc目录中)、框架的应用编程接口(API)文档(在javadoc目录中)、运行程序的库文件(junit.jar)以及示例测试实例(在junit目录中)。截至我撰写本文时,JUnit的最新版本为3.8.1,我是在此版本上对示例进行测试的。
图1 运行IsoDate测试
要运行此测试实例,将源文件(IsoDate.java和IsoDateTest.java)拷贝到Junit的安装目录下,打开终端,进入该目录,然后输入以下命令行(如果你正在使用UNIX):
export CLASSPATH=.:./junit.jar
javac *.java
或者,如果你正在Windows,输入以下命令行
set CLASSPATH=.;junit.jar
javac *.java
这些命令行对CLASSPATH进行设置,使其包含当前目录中的类和junit.jar库,并编译Java源文件。
要在终端上运行该测试,输入以下命令行:
java junit.textui.TestRunner IsoDateTest
此命令行将运行该测试,并在图 1所示的控制台上显示测试结果。
才在此工具可以运行类名被传递到命令行中的单个测试。注意:只有对命令行的最后测试才在考虑之内,以前的测试都被忽略了。(看起来像一个程序错误,是吧?)
JUnit还提供了利用AWT(抽象窗口工具包)或Swing运行测试的图形界面。为了利用此图形界面运行测试,在终端上输入以下命令行:
java junit.awtui.TestRunner IsoDateTest
或者使用Swing界面:
java junit.swingui.TestRunner IsoDateTest
此命令行将显示图 2所示的界面。要选择一个测试并使其运行,点击带有三个点的按钮。这将显示CLASSPATH(还有测试包,但我们将在后面讨论)中所有测试的列表。要运行测试,点击"Run"按钮。测试应当正确运行,并在图 2所示的界面中显示结果。
在此界面中你应当选中复选框"Reload Classes Every Run",以便运行器在运行测试类之前对它们进行重新加载。这样就可以方便地编辑、编译并运行测试,而不需要每次都启动图形界面。
在该复选框下面是一个进度条,在运行较大的测试包时,该进度条非常有用。运行的测试、错误和失败的数量都会在进度条下面显示出来。再下面是一个失败列表和一个测试层次结构。失败消息显示在底部。通过点击Test Hierarchy(测试层次结构)面板,然后再点击窗口右上角的"Run"按钮,即可运行单个测试方法。请记住,使用命令行工具是不可能做到这些的。
注意,当运行工具来启动测试类时,这些类必须存在于CLASSPATH中。但是如果测试类存储在jar文件中,那么即使这些jar文件存在于CLASSPATH中,JUnit也不能找到这些测试类。
图2 用于运行测试的Swing界面
这并不是一种启动测试的方便方法,但幸运的是,JUnit已经被集成到了其他工具(如Ant和Oracle9i JDeveloper)中,以帮助你开发测试并使测试能够自动运行。
编写Junit测试实例
你已经看到了测试类的源代码对IsoDate实施进行了询问。现在让我们来研究这样的测试文件的实施。
测试实例由junit.frameword.TestCase继承而来是为了利用JUnit框架的优点。这个类的名字就是在被测试类的名字上附加"Test"。因为你正在测试一个名为IsoDate的类,所以其测试类的名字就是IsoDateTest。为了访问除私有方法之外的所有方法,这个类通常与被测类在同一个包中。
注意,你必须为你希望测试的在类中定义的每个方法都编写一个方法。你要测试构造器或使用了ISO日期格式的方法,因此你将需要为以ISO格式的字符串作为参数的构造器和toString()方法编写一个测试方法。其命名方式与测试类的命名方式类似:在被测试方法(或构造器)前面附加"test"。
测试方法的主体通过验证assertion(断言)对被测方法进行询问。例如,在toString()实施的测试方法中,你希望确认该方法已经对时间的设定进行了很好的说明(对于UNIX系统来说,最初问世的时间为1970年1月1日的午夜)。要实施assertion,你可以使用Junit框架提供的assertion方法。这些方法在该框架的junit.framework.Assert类中被实施,并且可以在你的测试中被访问,这是因为Assert是TestCase的父类。这些方法可与Java中的关键字assert(是在J2EE 1.4中新出现的)相比。一些assertion方法可以检查原始类型(如布尔型、整型等)之间或对象之间是否相等(利用equals()方法检查两个对象是否相等)。其他assertion方法检查两个对象是否相同、一个对象是否为"空"或"非空",以及一个布尔值(通常由一个表达式生成)是"真"还是"假"。在表 1中对这些方法进行了总结。
对于那些采用浮点类型或双精度类型参数的assertion,存在一个第三种方法,即采用一个delta值作为参数进行比较。另外还要注意,assertEquals()和assertSame()方法一般不会产生相同的结果。(两个具有相同值的字符串可以不相同,因为它们是两个具有不同内存地址的不同对象。)因此,assertEquals()将会验证assertion的有效性,而assertSame()则不会。注意,对于表 1 中的每个assertion方法,你还有一种选择,就是引入另一个参数,如果assertion失败,该参数就会给出一条解释性消息。例如,assertEquals(int 期望值, int 实际值)就可以与一个诸如assertEquals(字符串消息,int期望值,int实际值)的消息一起使用。
当一个assertion失败时,该assertion方法会抛出一个AssertFailedError或ComparisonFailure。AssertionFailedError由java.lang.Error继承而来,因此你不必在测试方法的throws语句中对其进行声明。而ComparisonFailure由AssertionFailedError继承而来,因此你也不必对其进行声明。因为当一个assertion失败时会在测试方法中抛出一个错误,所以后面的assertion将不会继续运行。框架捕捉到这些错误并认定该测试已经失败后,就会打印出一条说明错误的消息。这个消息由assertion生成,并且被传递到assertion方法(如果有的话)。
现在将下面一行语句添加到testIsoDate()方法的末尾:
assertEquals("This is a test",1,2);
现在编译并运行测试:
$ javac *.java
$ java junit.textui.TestRunner IsoDateTest
.F.
Time: 0,348
There was 1 failure:
1) testIsoDate(IsoDateTest)junit.framework
.AssertionFailedError: This is a test expected:<1> but was:<2>
at IsoDateTest.testIsoDate
(IsoDateTest.java:29)
FAILURES!!!
Tests run: 2, Failures: 1, Errors: 0
JUnit为每个已处理的测试打印一个点,显示字母"F"来表示失败,并在assertion失败时显示一条消息。此消息由你发送到assertion方法的注释和assertion的结果组成(自动生成)。从这里可以看出assertion方法的参数顺序对于生成的消息非常重要。第一个参数是期望值,而第二个参数则是实际值。
如果在测试方法中出现了某种错误(例如,抛出了一个异常),该工具就会将其显示为一个错误(而不是由assertion失败而产生的一个"失败")。现在对IsoDateTest类进行修改,以将前面增加的一行语句用以下语句代替:
throw new Exception("This is a test");
然后编译并运行测试:
$ javac *.java
$ java junit.textui.TestRunner IsoDateTest
.E.
Time: 0,284
There was 1 error:
1) testIsoDate(IsoDateTest)java.lang.
Exception: This is a test at IsoDate
Test.testIsoDate(IsoDateTest.java:30)
FAILURES!!!
Tests run: 2, Failures: 0, Errors: 1
该工具将该异常显示为一个错误。因此,一个错误表示一个错误的测试方法,而不是表示一个错误的测试实施。
Assert类还包括一个fail()方法(该版本带有解释性消息),该方法将通过抛出AssertionFailedError来中断正在运行的测试。当你希望一个测试失败而不会调用一个判定方法时,fail()方法是非常有用的。例如,如果一段代码应当抛出一个异常而未抛出,那么可以调用fail()方法使该测试失败,方法如下:
public void testIndexOutOfBounds() {
try {
ArrayList list=new ArrayList();
list.get(0);
fail("IndexOutOfBoundsException
not thrown");
} catch(IndexOutOfBoundsException e) {}
}
JUnit的高级特性
在示例测试实例中,你已经同时运行了所有的测试。在现实中,你可能希望运行一个给定的测试方法来询问你正编写的实施方法,所以你需要定义一组要运行的测试。这就是框架的junit.framework.TestSuite类的目的,这个类其实只是一个容器,你可以向其中添加一系列测试。如果你正在进行toString()实施,并希望运行相应的测试方法,那么你可以通过重写测试的suite()方法来通知运行器,方法如下:
public static Test suite() {
TestSuite suite= new TestSuite();
suite.addTest(new IsoDateTest
("testToString"));
return suite;
}
在此方法中,你用具体示例说明了一个TestSuite对象,并向其中添加了测试。为了在方法级定义测试,你可以利用构造器将方法名作为参数使测试类实例化。此构造器可按如下方法实施:
public IsoDateTest(String name) {
super(name);
}
将上面的构造器和方法添加到IsoDateTest类(还需要引入junit.framework.Test和junit.framework.TestSuite),并在终端上输入:
图3:选择一个测试方法
$ javac *.java
$ java junit.textui.TestRunner IsoDateTest
.
Time: 0,31
OK (1 test)
注意,在添加到测试包中的测试方法中,只运行了一个测试方法,即toString()方法。
你也可以利用图形界面,通过在图3所示的Test Hierarchy面板中选择测试方法来运行一个给定的测试方法。但是,要注意当整个测试包被运行一次后,该面板将被填满。
当你希望将一个测试实例中的所有测试方法添加到一个TestSuite对象中时,可以使用一个专用构造器,该构造器将此测试实例的类对象作为参数。例如,你可以使用IsoDateTest类实施suite()方法,方法如下:
public static Test suite() {
return new TestSuite(IsoDateTest.class);
}
还有一些情况,你可能希望运行一组由其他测试(如在工程发布之前的所有测试)组成的测试。在这种情况下,你必须编写一个实施suite()方法的类,以建立希望运行的测试包。例如,假定你已经编写了测试类Atest和Btest。为了定义那些包含了类ATest中的所有测试和在BTest中定义的测试包的集合,可以编写下面的类:
import junit.framework.*;
/**
* TestSuite that runs all tests.
*/
public class AllTests {
public static Test suite() {
TestSuite suite= new TestSuite("All Tests");
suite.addTestSuite(ATest.class);
suite.addTest(BTest.suite());
return suite;
}
}
你完全可以像运行单个测试实例那样运行这个测试包。注意,如果一个测试在一个套件中添加了两次,那么运行器将运行它两次(测试包和运行器都不会检查该测试是否是唯一的)。为了了解实际的测试包的实施,应当研究Junit本身的测试包。这些类的源代码存在于JUnit安装的junit/test目录下。
图4:显示测试结果的报告
将一个main()方法添加到一个测试或一个测试包中有时是非常方便的,因此可以在不使用运行器的情况下启动测试。例如,要将AllTests测试包作为一个标准的Java程序启动,可以将下面的main()方法添加到类中:
public static void main(String[] args) {
junit.textui.TestRunner.run(suite());
}
现在可以通过输入java AllTests来启动这个测试包。
JUnit框架还提供了一种有效利用代码的方法,即将资源集合到被称为fixture的对象集中。例如,该示例测试实例利用两个叫作epoch和eon的参考日期。将这些日期重新编译到每个方法测试中只是浪费时间(而且还可能出现错误)。你可以用fixture重新编写测试,如清单2所示。
你定义了两个参考日期,作为测试类的段,并将它们编译到一个setUp()方法中。这一方法在每个测试方法之前被调用。与其对应的方法是tearDown()方法,它将在每个测试方法运行之后清除所有的资源(在这个实施中,该方法事实上什么也没做,因为垃圾收集器为我们完成了这项工作)。现在编译这个测试实例(其源代码应当放在JUnit的安装目录中)并运行它:
$ javac *.java
$ java junit.textui.TestRunner IsoDateTest2
.setUp()
testIsoDate()
tearDown()
.setUp()
testToString()
tearDown()
Time: 0,373
OK (2 tests)
注意:在该测试实例中建立了参考日期,因此在任何测试方法中修改这些日期都不会对其他测试产生不利影响。你可以将代码放到这两个方法中,以建立和释放每个测试所需要的资源(如数据库连接)。
JUnit发布版还提供了扩展模式(在包junit.extensions中),即test decor-ators,以提供像重复运行一个给定的测试这样的新功能。它还提供了一个TestSuite,以方便你在独立的线程中同时运行所有测试,并在所有线程中的测试都完成时停止。
利用Ant使测试自动化
如前面所述,测试运行器是非常原始的。如果你正在运行Ant来编译你的工程,那么编译文件是运行单元测试的好方法。(关于Ant的介绍,请参考我的文章《Ant简介》(Starting with Ant),发表于Oracle杂志2002年11/12月号中)。
假设你的源文件在src目录中,所生成的类在tmp目录中,并且junit.jar库位于工程的libdirectory目录中,那么你可以编译Java源文件,并使用清单3中所示的编译文件(在工程的根目录中)运行单元测试。
这个编译文件的核心是运行单元测试的测试目标。运行这些测试是这个目标junit的唯一任务。为了运行这一可选任务,必须首先将junit.jar库放到Ant安装目录下的lib目录中,然后下载并安装同一目录中的Ant可选任务库。清单3中的示例嵌套了一个classpath类,它包括JUnit库和工程的类;示例中还嵌套了一个batchtest元素,它利用一个选择适当源文件的fileset元素定义了将要运行的测试。这个任务还包括haltonfilure和haltonerror属性,它们告诉Ant在遇到一个失败或错误时是否应当停止。如果将它们的值设置为"真",那么Ant在遇到第一个失败或错误时将会停止,编译将会失败(显然,这表示在运行测试过程中存在有问题)。另一方面,如果将它们的值设置为"假",其结果就不是非常明确了(即使测试失败,编译也会成功),但所有测试仍将运行。printsummary属性指示Ant是否显示运行测试的输出。数值withOutAndErr可以在开发测试时方便地告诉Ant显示标准输出和错误输出。数值off表示不显示任何内容,而on只显示测试报告(没有测试类的输出)。junit任务具有很多属性,详细内容请参考Ant的文档。
为了测试这一编译文件,你需要建立名字为src、tmp和lib的目录。将junit.jar库放到lib目录中,并将前面看到的示例Java源文件放到src目录中。打开终端,进入该工程的根目录,并输入ant,其结果为:
$ ant
Buildfile: build.xml
clean:
[delete] Deleting directory
/Users/casa/doc/oracle
/junit/prj/tmp
[mkdir] Created dir: /Users/casa
/doc/oracle/junit/prj/tmp
bin:
[javac] Compiling 4 source files
to /Users/casa/doc/oracle
/junit/prj/tmp
test:
[junit] Running IsoDateTest
[junit] Tests run: 1, Failures:
0, Errors: 0, Time elapsed:
0,005 sec
[junit] Running IsoDateTest2
[junit] Tests run: 2, Failures: 0,
Errors: 0, Time elapsed: 0,031 sec
[junit] Output:
[junit] setUp()
[junit] testIsoDate()
[junit] tearDown()
[junit] setUp()
[junit] testToString()
[junit] tearDown()
all:
BUILD SUCCESSFUL
Total time: 8 seconds
Ant还可以生成非常有用的HTML格式的测试报告。为了生成这样的报告,将前面的测试目标用以下目标代替:
<target name="test" depends="bin"
description="Run JUnit tests">
<junit haltonfailure="false"
printsummary="withOutAndErr">
<classpath refid="cp"/>
<batchtest todir="${tmp}">
<fileset dir="${src}"
includes="**/*Test*.java"/>
</batchtest>
<formatter type="xml"/>
</junit>
<junitreport todir="${tmp}">
<fileset dir="${tmp}"
includes="TEST-*.xml"/>
<report format="frames"
todir="${tmp}"/>
</junitreport>
</target>
这一目标与前面的目标相同,只是该目标在batchtext元素中增加了一个新的属性--todir,它告诉Ant在tmp目录中生成可扩展的标记语言(XML)报告。该目标还增加了一个新的junitreport元素,以便由XML文件生成一个HTML报告。这一元素要求在安装Ant的lib目录中安装Xalan库(详细内容见Ant文档的junitreport部分:ant.apache.org/manual/install.html)。这一元素还定义了使用todir属性生成的文件的目标目录。通过嵌套一个fileset元素来定义为生成这一报告而需要处理的XML文件。期望的输出格式利用嵌套的报告元素来实现。该对象将生成一个诸如图4所示的报告。
这类报告在使单元测试自动运行时特别有用(比如在夜间编译期间)。在这些情况下,错误或失败不会中断测试,因此你必须将前面提到的junit任务的haltonfailure和haltonerror属性设置为"假"。这一报告对于衡量实施进程也非常有用(比如当你必须重写已有代码时,或者在实施之前已经编写了测试的情况下)。
Ant对启动JUnit图形运行器也非常有用。下面的对象将会启动Swing测试运行器:
<target name="testui" depends="bin"
description="Run graphical JUnit">
<java classname="junit.swingui.TestRunner"
classpathref="cp"
fork="true"/>
</target>
你应当在终端中运行这一对象,并且在另一个终端或你喜欢的IDE中使用Ant对其进行编译。这种方式使你不必在每次想要测试代码时都启动图形运行器。
在Oracle9i Jdeveloper中的JUnit集成
Oracle9i JDeveloper并没有基于网络集成JUnit,但是下载并安装这一插件只需要几分钟的时间。为了完成此过程,选择JDeveloper的"Help"菜单下的"Check for Updates"项。这样将会打开IDE更新向导,以连接到Oracle技术网站,下载该插件并安装它。当安装该插件后,需要关闭并重启Oracle9i JDeveloper。注意,向导还会下载相关的文档。
通过为每个任务提供向导,这个插件极大地提高了开发人员编写测试实例、测试包和fixture等的工作效率。要调用这些向导,点击"File"菜单下的"New"项,然后选择"General/Unit Tests"类,并从右侧的窗体中选择合适的向导。你也可以从界面上启动测试套件。
当准备好对项目进行代码测试后,应当首先使用专用向导来编写fixture,然后测试实例向导可以利用它们集成到测试实例中。另外,还有一些用来生成自定义测试fixture的向导以及生成商务组件和数据库连接测试fixture的向导。这后两种向导生成专用代码,以使用setUp()和tearDown()方法设置和发布商务组件或数据库连接。
当完成fixture后,下一步应当使用合适的向导来生成测试实例,这些向导可以让你选择要测试的类和方法。你还可以选择在这个测试中使用的fixture。这将生成一个使用测试方法的主体完成的代码框架。最后应当生成套件来运行你的测试。这个专用向导让你选择要包括在套件中的测试,并为你生成整个类。要启动一个测试套件,点击浏览器中的文件,并选择Run。这将会启动图形界面并运行套件的测试。
在"Help"菜单中选择"Help Topics",你将会在JDeveloper文档中找到关于如何使用这些向导的详细教程。这会打开帮助系统窗口。点击"Unit Testing with JUnit"项,然后选择合适的教程。
JUnit和JDeveloper之间的这种集成使你能够只编写单元测试中你感兴趣的那部分的代码,而让工具为你编写重复的代码。
JUnit最佳实践
下面是一些在使用JUnit时应当注意的技巧:
- 在实施之前编写测试代码。这是一种合同驱动的实施方式。
- 只测试那些可能会中断的方法(也就是说,在多数情况下不应测试setter和getter方法)。要尽可能地多进行测试,以避免回归测试。当测试一个较大的应用程序时,你可以在夜间编译时运行所有测试。
- 一定要使用正确的JUnit扩展来测试特殊的应用程序(如使用Castus测试J2EE应用程序)。
值得花费的时间
到现在,你应当已经清楚地知道使用JUnit框架和合适的工具实施单元测试是多么快速而简单。关于单元测试的下一个目标是使你的CTO相信你在实施测试时所必须花费的时间是为了以后节省更多的时间。但是,当你考虑在检查老代码、修正错误和发布一个调试过的版本上所花费的时间(它可能花费整个一天)时,在开发过程的早期阶段捕获的代码错误毫无疑问是一项很好的投资。这里并没有考虑当错误代码不再位于块的顶部时开发人员必须遵循的"black magic"步骤,这些步骤包括:标记代码,制作一个分支、修正代码错误、进行发布,以及将代码修正合并到块中。所有这些步骤都非常耗时,并且容易产生错误。
要开始使用单元测试和JUnit,请访问JUnit网站: www.junit.org。你将找到大量有用的文档(包括使用JUnit实施测试的详细说明书)、一个与JUnit集成的IDE列表,以及关于JUnit扩展工具的详细内容。
Michel Casabianca ( casa@sweetohm.net)是In-Fusio(一家为移动用户提供游戏服务的法国公司)的一名软件工程师,同时也是XML Pocket Reference(O'Reilly出版,2001年)一书的合著者。
表1:编写测试实例中所使用的判定方法
|
assertEquals(期望原型,实际原型) |
检查两个原型是否相等 |
assertEquals(期望对象,实际对象) |
利用对象的equals()方法检查两个对象是否相等 |
assertSame(期望对象,实际对象) |
检查具有相同内存地址的两个对象是否相等 |
assertNotSame(期望对象,实际对象) |
检查具有不同内存地址的两个对象是否不相等 |
assertNull(对象 对象) |
检查一个对象是否为空 |
assertNotNull(对象 对象) |
检查一个对象是否为非空 |
assertTrue(布尔条件) |
检查条件是否为真 |
assertFalse(布尔条件) |
检查条件是否为假 | | | | |
|
|