单元测试及框架:通向JUnit之路 -- 软件行者 [an error occurred while processing this directive]
SoftwarePractitioner.org


首页

作文

翻译

随笔

本站

English
  


单元测试及框架:通向JUnit之路

胡健
2006年6月

测试的基本概念

人非圣贤,孰能无过。编程时尽管双目圆睁、如履薄冰,编出的程序却总有不听话的地方。

通常有两种方法来提高我们对程序的信心,一是"确证"(verification)。 "确证"是指用正规形式方法或者不那么正规的方法,对程序代码进行推理演算, 以求证程序能达到预先的目的,如"程序正确性证明"。虽然这方面的研究进行 了几十年,但在实践中的应用却非常少。软件实践中还是大量地使用了另一种方法, 便是测试(testing)。测试是指对程序设置一些输入数据,运行该程序,然后将 得到的结果与期待的结果相比较,看是否相符。

对测试而言,一个重要的问题便是要选择输入数据集。如果可能的数据量很小,当然 可以作穷尽测试。但绝大多数情况下,可能的输入组合非常之大,穷尽测试是不太可 能的。因此,一个关键问题便是如何选择一个较小的输入数据集,使得对它们的测试 让我们有足够的信心认为其结果接近于穷尽测试。

一般来说,有两种类型的测试,一是所谓的黑盒测试(black-box testing),另一种 便是所谓的白盒测试(white-box testing),也有称为透明盒(clear-box/glass-box) 或结构(structural)测试的。在黑盒测试中,我们的测试只针对程序的功能说明,而 不管程序的具体实现,即不看程序代码。黑盒测试的最大好处是其测试过程不受实现的 潜在影响。如编码者可能对输入数据的一些不合理的假设,例如,输入参数是整数, 编码者可能假设只有正整数,而对0和负数则没作处理。如果读了代码后再去产生输入 数据,很可能会受到这种错误假设的误导。黑盒测试的第二个好处是其测试数据与实现 无关,实现如有改变,只要功能说明不变,测试就保持不变。

与黑盒测试相反,白盒测试则需要审阅程序代码。其主要的目的是要产生一些输入 数据,能够测试代码的每个语句(statement coverage)、分支(decision coverage),使得每条路径都能走到,即所谓的"路径完全(path-complete)"。

每次修改程序代码后,所有的测试都需要重新运行,这通常称为回归测试(regression testing)。

简言之,测试所包含的活动有:建立输入数据、运行/调用程序或模块、收集结果并比较, 以及经常性的回归测试。要让这些活动能高效地进行,一个好的工具是很重要的,它能使 这些活动很大程度上自动化。当然,建立合适的输入数据不是一件容易的事,需要相当的 智能与技巧。除此而外,其他步骤都可以用一个工具来自动处理。典型过程如下:

  • 初始化测试环境,如设立测试所需要的数据;
  • 调用需测试的单元,参数可以是来自工具里,也可取自一个数据文件;
  • 收集结果,并与期望值(可以是工具里也可以取自外部数据文件)比较。

以上所提到的都是关于测试的一般性方法。所说的"程序"可以是一个子系统,也可以是 一个模块。当然,众所周知,一个大程序,一般都是由若干程序模块组合而成的。实践 中,对一个系统,或子系统,主要是采用黑盒测试,或曰功能测试。而对模块则黑白 皆用。现代编程,多用OO(Object-Oriented)方法,一个程序模块大致相当于一个类 (class)。因此,单元测试主要是针对类来进行。

通向JUnit之路

目前应用最广的单元测试工具之一便是著名的JUnit,它是Java语言用的一个单元测试 框架(framework)。一般来说,如果你用它来作单元测试,你只需要知道怎么用就 行了。但我认为它的设计非常精巧,一个主要的特点是对设计模式(design patterns) 的娴熟运用(这是理所当然的,因为它的作者之一,E. Gamma,便是design patterns 的Gang of Four之一,另一位作者K. Beck便是Extreme Programming的创建者)。 实际上,它的设计思想已广泛地用于其他语言的单元测试工具的构造。下面就通过一个简单的 例子来介绍一下JUnit的设计思想。

第一步:单个测试(test case)与测试组套(test suite)

假设有如下一个程序:


/**
 * 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)并运行示范测试。

第二步:测试断言(test assertions)、测试环境建立(data setup)与测试结果(test results)

下面,我们对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() 来计算一个测试(包括TestCaseTestSuite)中所含的单个 试例数目。综上所述,我们框架的核心部分就成了:

// 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

第三步:反射(reflection)的应用与测试运行器(runners)

上面所述已经提供了一个比较完备的框架了,但还可以进一步改进以增加方便性和灵活性。 首先一个问题是如何高效地生成所需的一组测试。当然,一个测试可以显式地定义,如

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的框架了)。

第四步:测试监听者(listeners)

到现在为止,测试结果都是存在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飞行员。

参考资料

修改记录

  • 2006年6月30日:发表于SoftwarePractitioner.org。
 
[首页]   [作文]   [翻译]   [随笔]   [本站]   [English]
 
Creative Commons License
Except where otherwise noted, this site is licensed
under a Creative Commons Attribution-NonCommercial 2.5 License
.