![]() |
![]() |
||||||||||||||||||||||||||||||||||||||||||||
首页 作文 翻译 随笔 本站 English |
单元测试及框架:通向JUnit之路
胡健
|
/** * A simple phone number. This class formulates a phone * number representation. */ public class PhoneNumber { private String areaCode; private String number; /** * Constructor */ public PhoneNumber(String areaCode, String number) { this.areaCode = areaCode; this.number = number; } /** * Formulates a phone number representation * @return String a string of format like (131) 7891234 * or "--" if either area code or number is null */ public String getFullNumber() { if (this.areaCode != null && this.number != null) { return "(" + areaCode + ") " + number; } else { return "--"; } } } |
程序编译通过,一般来说,我得写一段测试代码,以验证我的程序是听话的。 这段代码是作为PhoneNumber的使用者,一个最直接了当的 方法便是写一个PhoneNumberTest,如下:
public class PhoneNumberTest { public static void main(String[] args) { PhoneNumber phoneNumber = new PhoneNumber("232", "1234567"); String numberStr = phoneNumber.getFullNumber(); if (numberStr.equals("(232) 1234567")) { System.out.println("Normal number test ok"); } else { System.out.println("Normal number test failed"); } } } |
这只是对PhoneNumber的一个测试。我们可能还需要另一个测试来看看当区号码 没有时的输出。当然,我们可以如法炮制来写另一个试例,如PhoneNumberTest2。同时,要 记住其他的类也需要相应的若干试例。把它们归纳抽象一下,一个试例可以用所谓的“命令” 模式来表达,即可用如下的TestCase类来表述一个测试试例:
public abstract class TestCase { private String name; public TestCase(String name) { this.name = name; } // To be overridden by subclass public abstract void run() { } } |
这样,上面的PhoneNumberTest便可实现成:
public class PhoneNumberTest extends TestCase { public void run() { PhoneNumber phoneNumber = new PhoneNumber("232", "1234567"); String numberStr = phoneNumber.getFullNumber(); if (numberStr.equals("(232) 1234567")) { System.out.println("normal number test ok"); } else { System.out.println("normal number test failed"); } } public static void main(String[] args) { PhoneNumberTest test = new PhoneNumberTest("normal test"); test.run(); } } |
运行PhoneNumberTest可以完成一个测试。但一个类就可能有很多试例,那么一个系统 就可能有非常非常之多的试例。要运行所有的试例,我们首先需要知道所有的试例类的名字。 然后可以用一个如下所示的一段代码来运行:
List allClassNames = getAllTestClassNames(); for (int i=0; i < allClassNames.size(); i++) { String className = (String) allClassNames.get(i); TestCase testCase = createInstanceForClass(className); testCase.run(); } |
上面这种方法其实是假设了所有的试例都在同一级上,似乎缺少结构性。其实,单元测试中试例主要 还是围绕着class和package来组织的。我们可以把一个class的所有试例放在一个测试包里,对一个 package来说,我们可以用一个包把这个package下的所有测试包(每个包对应于一个类)都可以集中 起来放在这个大包里。而对于运行者来说,一个要求便是每个包应该和单个试例有着同样的接口 (interface),即都现出同一付嘴脸。因此,“复合模式”(composite pattern)粉墨登场了,见下:
public interface Test { public void run(); } public abstract class TestCase implements Test { private String name; public TestCase(String name) { this.name = name; } // To be overridden by subclass public abstract void run() { } protected void printResult(boolean condition) { if (!condition) { System.out.println(this.toString + " failed"); } } // ... } public class TestSuite implements Test { private String name; private Vector tests = new Vector(10); public TestSuite(String name) { this.name = name; } // Runs a set of tests public void run() { for (Enumeration e= this.tests.elements(); e.hasMoreElements(); ) { Test test= (Test)e.nextElement(); test.run(); } } // Adds a test to the suite. public void addTest(Test test) { tests.addElement(test); } // ... } |
基于上面的结构,对PhoneNumber的测试程序可以由如下代码实现:
// file: PhoneNumberTest public class MoneyTest { public static TestSuite suite() { TestSuite suite = new TestSuite(); suite.addTest(new TestZero()); suite.addTest(new TestAdd()); return suite; } public static void main(String args[]) { suite().run(); } } class TestZero extends TestCase { public TestZero() { super("TestZero"); } public void run() { Money f12USD= new Money(12, "USD"); printFailure(f12USD.subtract(f12USD).isZero()); } } class TestAdd extends TestCase { public TestAdd() { super("TestAdd"); } public void run() { Money f12USD= new Money(12, "USD"); Money f14USD= new Money(14, "USD"); Money expected = new Money(260, "USD"); printFailure(f12USD.add(f14USD).equals(expected)); } } |
按照上面的思路,可以构建一个非常简单的单元测试框架。有兴趣的读者可以下载 (picounit.zip)并运行示范测试。
下面,我们对picounit进行一些改进。首先是对于测试结果的状态判定。实际上,很多语言 都提供了象assert()这样的语句。它主要是用于在程序中的某一点判定此时的 一种状态必须为真,否则便认为是出错,并将控制转移到出错/异常处理。在进行单元测试时, 我们可以使用这个思路,并定义一些测试中常用的assert函数,如:
assertTrue(boolean condition) assertEquals(int expected, int actual) assertEquals(Object expected, Object actual) assertNull(Object obj) assertNotNull(Object obj) assertSame(Object obj1, Object obj2) ... |
条件不满足时把控制转移到出错处理,在Java里,这一般是通过异常处理来实现的。 如下所示:
static public void assertTrue(String message, boolean condition) { if (!condition) throw new AssertionFailedError(message); } |
这里,AssertionFailedError是一类异常,如下定义:
public class AssertionFailedError extends Error { public AssertionFailedError () { } public AssertionFailedError (String message) { super (message); } } |
注意在Java中, 'Error'并不要求在应用程序代码中进行处理。这样这些assert 函数除了用于测试,还可以很方便地直接用于应用代码中作动态分析之用。(顺便提一下, Java从1.4后,引入了assert语句,所以JUnit中原来定义的assert改为 assertTrue。)这样,TestCase就成为:
public class TestCase implements Test { // ... protected void runTest() throws Throwable { } public void run() { try { runTest(); } catch (AssertionFailedError afe) { // do something } // ... } |
这里,runTest()成为了测试者需要写的method了。如:
public class MyTest extends TestCase { public void runTest() { PhoneNumber number = new PhoneNumber("131", "7891234"); assertEquals(number.getFullNumber(), "(131) 7891234"); } // ... } |
如果对测试过程更细化一下,我们可以把一次测试分成三段,建立测试数据/环境(setup()) 运行测试(runTest())以及撤销建立的数据/环境(tearDown())。 这可以用“模板模式”(template pattern)来表示:
public class TestCase { // ... protected void setUp() {} protected void tearDown() {} protected void runTest() throws Throwable {} public void run() { setUp(); try { runTest(); } catch (AssertionFailedError afe) { // do something } finally { tearDown(); } } // ... } |
到现在为止,我们还没有提到测试结果的问题。只是用printFailure 来在测试失败时打出失败信息,这当然是很不够的。如果我们运行一个系统的 所有测试后,我们除了要知道哪些测试失败外,我们还得知道一些统计数字。 因此,我们需要把需要的测试数据装到一个数据存储器中。这就是 TestResult的功用了。
public class TestResult { private Vector failures; private int runTests; /** * Constructor */ public TestResult() { this.failures= new Vector(); this.runTests= 0; } /** * Adds a failure to the list of failures. The passed in exception * caused the failure. */ public synchronized void addFailure(Test test, AssertionFailedError t) { failures.addElement(new TestFailure(test, t)); System.out.println("Failure on " + test.toString() + ": " + t.getMessage()); } /** * Gets the number of detected failures. */ public synchronized int failureCount() { return failures.size(); } /** * Informs the result that a test will be started. */ public void startTest(Test test) { final int count= test.countTestCases(); synchronized(this) { runTests += count; } } // .... } |
注意,在上面的startTest()中,我们需要一个countTestCases() 来计算一个测试(包括TestCase和TestSuite)中所含的单个 试例数目。综上所述,我们框架的核心部分就成了:
// file Test.java public interface Test { public void run(); public int countTestCases(); } // file TestCase.java public abstract TestCase implements Test { public int countTestCases() { return 1; } protected void setUp() {} protected void tearDown() {} protected void runTest() throws Throwable {} public void run(TestResult result) { setUp(); try { runTest(); } catch (AssertionFailedError afe) { // collect the failure result.addFailure(this, afe); } finally { tearDown(); } } // ... } // file TestSuite.java public TestSuite implements Test { private Vector tests; public void addTest(Test test) { tests.add(test); } // Runs a set of tests contained in this suite public void run(TestResult result) { for (Enumeration e= this.tests.elements(); e.hasMoreElements(); ) { Test test= (Test)e.nextElement(); test.run(result); } } // Counts the number of all test cases in this suite public int countTestCases() { int count= 0; for (Enumeration e= tests.elements(); e.hasMoreElements(); ) { Test test= (Test)e.nextElement(); count= count + test.countTestCases(); } return count; } } |
对PhoneNumber的测试由如下代码实现:
// file: PhoneNumberTest.java public class PhoneNumberTest { public static TestSuite suite() { TestSuite suite = new TestSuite(); TestCase testCase = new TestCase("TestNull") { public void runTest() { PhoneNumber phoneNo = new PhoneNumber(null, "7934931"); assertEquals("Area code is null", phoneNo.getFullNumber(), "Invalid Number"); } }; suite.addTest(testCase); testCase = new TestCase("TestNormal") { public void runTest() { PhoneNumber phoneNo = new PhoneNumber("131", "7934931"); assertTrue("righ number", phoneNo.getFullNumber().equals("(131) 7934931")); } }; suite.addTest(testCase); return suite; } public static void main(String args[]) { System.out.println("Start running ..."); suite().run(new TestResult()); System.out.println("end running ..."); } } |
PhoneNumberTest 中利用TestSuite把需要运行的测试包起来,然后创建一个新的TestResult,把它作为 testSuite.run() 的参数。testSuite.run(testResult)又分别调用各组成部分(Test)的run(testResult), 这里一个Test可以是一个单个测试,也可以是一个组套(测试包),所以对run(testResult)的调用是递归的。 当任何一个的测试被运行时,testResult也用来收集测试失败信息。当testSuite.run(testResult)返回 时,即所有测试都被运行后,testResult也把所有的失败测试的信息都带回来了。
这些在picounit基础上的扩展便构成了另一个框架,姑且称之为nanounit。
上面所述已经提供了一个比较完备的框架了,但还可以进一步改进以增加方便性和灵活性。 首先一个问题是如何高效地生成所需的一组测试。当然,一个测试可以显式地定义,如
PhoneNumberTest extends TestCase { public void runTest() { // my test case code } }
然后用new MyTestCase()来生成一个测试。
也可以用匿名类方式生成,如在nanounit中的PhoneNumberTest试例,即用匿名类来定义所需的runTest()。
不管是用显式定义还是匿名定义,都嫌有些麻烦。JUnit利用了Java的反射(reflection)功能来 解决这个问题。它约定,在一个TestCase类中,对所有以"test"开头的methods,都创建一个新的 TestCase,它的runTest()将去调用这个method。
public class PhoneNumberTest extends TestCase { public PhoneNumberTest(String name) { super(name); } public void testNull () { PhoneNumber phoneNo = new PhoneNumber(null, "7934931"); assertEquals("Area code is null", phoneNo.getFullNumber(), "Invalid Number"); } public void testNormal () { PhoneNumber phoneNo = new PhoneNumber("131", "7934931"); assertTrue("righ number", phoneNo.getFullNumber().equals("(131) 7934931")); } } |
然后,我们在TestSuite类中增加一个构造子,它能从一个TestCase来构造一个TestSuite:
public TestSuite(Class clazz) { get all method names of clazz for each method beginning with test TestCase newTest = (TestCase) clazz.newInstance(); newTest.setName(methodName); addTest(newTest); }
注意,每一个新生成的TestCase实例中都含有clazz(此例中是PhoneNumberTest类)所有的methods, 如testNull(), testNormal()。而我们要求只运行所需要的method。这是通过在runTest() 使用"反射"(reflection)来实现的。注意这时methodName已被设置为某个test的名称了, 如testNull。
public TestCase implements Test { private String methodName; public TestCase() { } public TestCase(String mn) { this.methodName = mn; } public void setName(String name) { This.methodName = name; } void runTest () { try { Method m = getMethod (methodName); m.invoke (); } catch (Exception ex) { // exception handling } } } |
上面PhoneNumberTest中所用的过程其实具有普遍性,即构造一个TestSuite,把所需要运行的测试加 到suite中,然后运行这组测试。我们可以把这部分独立出来,放到一个TestRunner(测试运行器)中。
public class SimpleTestRunner { /** * Constructs a TestRunner. */ public SimpleTestRunner() { } /** * Runs a single test and collects its results. * This method can be used to start a test run * from your program. ** public static void main (String[] args) { * SimpleTestRunner.run(suite()); * } **/ static public TestResult run(Test test) { SimpleTestRunner runner= new SimpleTestRunner(); return runner.doRun(test); } public TestResult doRun(Test suite) { TestResult result= new TestResult(); suite.run(result); return result; } private Test getTest(Class testClass) throws Exception { try { Method suiteMethod= testClass.getMethod("suite", new Class[0]); Test testSuite = (Test) suiteMethod.invoke(null, new Class[0]); return testSuite; } catch (Exception ex) { return new TestSuite(testClass); } } private Test getTest(String className) throws Exception { return getTest(Class.forName(className)); } public static void main(String args[]) { try { SimpleTestRunner runner= new SimpleTestRunner(); Test test = runner.getTest(args[0]); TestResult result = runner.doRun(test); } catch(Exception e) { System.err.println(e.getMessage()); } } } |
这些扩展便构成了另一个框架,称之为microunit(这基本上就是JUnit 2的框架了)。
到现在为止,测试结果都是存在TestResult中,我们可以在测试完成后显示结果。但也有要求在测试 运行中随时显示运行情况,常用的技术是使用观察者模式(observer)。
public interface TestListener { public void addError(Test test, Throwable t); public void addFailure(Test test, AssertionFailedError t); public void endTest(Test test); public void startTest(Test test); } public class SimpleTestListener implements TestListener { private PrintStream writer; private int column; public SimpleTestListener(PrintStream writer) { this.writer = writer; } public void addError(Test test, Throwable t) { this.writer.print("E"); } public void addFailure(Test test, AssertionFailedError t) { this.writer.print("F"); } public void endTest(Test test) { } public void startTest(Test test) { this.writer.print("."); if (this.column++ >= 40) { this.writer.println(); column= 0; } } } public class TestResult { private Vector failures; private int runTests; private Vector listeners; /** * Adds a failure to the list of failures. The passed in exception * caused the failure. */ public synchronized void addFailure(Test test, AssertionFailedError t) { failures.addElement(new TestFailure(test, t)); for (Enumeration e= listeners.elements(); e.hasMoreElements(); ) { ((TestListener)e.nextElement()).addFailure(test, t); } } public synchronized void addListener(TestListener listener) { listeners.addElement(listener); } public synchronized void removeListener(TestListener listener) { listeners.removeElement(listener); } // ... } public class SimpleTestRunner { private TestListener listener; public SimpleTestRunner(TestListener listener) { this.testListener = listener; } public TestResult doRun(Test suite) { TestResult result= new TestResult(); result.addListener(this.testListener); suite.run(result); return result; } // ... } |
SimpleTestRunner 中的listener 是Observer,而TestResult是Subject,它提供了TestListener
注册功能
public void addListener(TestListener listener)
当TestResult 的addFailure 被调用时, 所有注册的TestListener都被告知这一事件(调用TestListener
的addFailure(test, failure))。
这些扩展便构成了另一个框架,称之为milliunit (介于JUnit 2和3之间)。
作为一个小结,下表列出了本文中的四种演示框架的功能演进与对比。
package & feature | pico | nano | micro | milli |
---|---|---|---|---|
framework |
Test.java TestCase.java TestSuite.java |
Test.java TestCase.java TestSuite.java TestFailure.java AssertionFailedError.java TestResult.java |
Test.java TestCase.java TestSuite.java TestFailure.java AssertionFailedError.java TestResult.java |
Test.java TestCase.java TestSuite.java TestFailure.java AssertionFailedError.java Assert.java ComparisonFailure.java TestResult.java |
basic | + Template and Assertion | + Reflection | + separation of Assert and String comparison | |
textui | - | - | SimpleTestRunner.java |
SimpleTestRunner.java SimpleTestListener.java |
- | - | + TestRunner | + TestListener |
JUnit几乎已成了Java项目中作单元测试的标准。它在提升软件的质量方面发挥了巨大的作用。 著名软件开发专家Martin Fowler曾这样评价JUnit: Never in the field of software development was so much owed by so many to so few lines of code (在软件开发领域中,从未有过如此众多的代码行从如此少量的代码行中得到了如此巨大的益处。)
注:此评价似乎是化用了丘吉尔在1940年8月20日在英国议会的 演讲 中的一段经典之言。原文为: Never in the field of human conflict was so much owed by so many to so few. 这是用来赞扬当时在英伦之战中的英国皇家空军(Royal Air Force),其中"so many"是指英国人民, 而"so few"则是指RAF飞行员。