JUnit 3.8.x 框架设计(JUnit 源码阅读必备)

1 目标

1.1 背景

关于软件开发的假设

如果一个程序缺乏自动化测试,我们就认为它是无法工作的。

以前的假设(不合理的假设)是:

如果开发者向我们保证程序的功能是可用的,那么它就可以一直正常运行下去。

开发者编写和调试完功能代码后,工作尚未完成。必须编写测试,来证明程序的可用性。

实际情况

开发者很忙,很难腾出手来写测试代码。

1.2 开发目标

  1. 写一套单元测试框架,便于学习和使用,让开发者愿意写测试;
  2. 测试一旦写好,可以反复执行。即使多年之后,现在写的单元测试代码依然能发挥作用。另外,不同作者写的单元测试,可以在一起运行;
  3. 写新的测试时,不必总是从头搭建测试环境。测试环境初始化一次之后,就能在它的基础上,写很多测试用例。

2 设计 JUnit

2.1 从 TestCase 开始

2.1.1 问题描述

我们需要一个 Java 类,来表达测试用例的概念。它就是 TestCase。

在此之前,测试用例是一个模糊的概念,不同的人,有不同的测试方法。

比如:

  • 使用 Print 语句在控制台打印结果;
  • 在 debugger 中检测表达式的结果;
  • 自己编写测试脚本。

表达为 Java 类的好处:

  • 客观、明确的定义。便于管理。测试用例写好之后,可以被任何人,在任何时间、地点运行;
  • 开发者很熟悉,接受度高。

2.1.2 解决方案

命令模式(Command pattern)

一个测试用例,就是一个待执行的命令。最终调用 run 方法来执行。

TestCase 应该被继承,所以声明为抽象类。

TestCase 应该有一个名字 fName,这样就能区分不同的测试用例。出错的时候,可以知道具体是哪个测试用例出错了。

2.2 填充 run 方法

2.2.1 问题描述

开发者需要一个方便的地方,去写搭建测试环境的代码,以及在测试环境中运行的测试代码。

测试用例继承 TestCase。但目前 TestCase 的功能太少,没有实际意义。

2.2.2 解决方案

模版方法模式(Template Method)1

  1. 所有测试都有相同的结构:搭建测试环境、运行测试代码、检查运行结果、清理测试环境。
  2. 每个测试用例,都需要在全新的测试环境下运行,不能被其他测试用例污染。
public void run() { 
    setUp(); 
    runTest(); 
    tearDown(); 
}

2.3 记录测试结果 TestResult

2.3.1 问题描述

单元测试执行完毕之后,我们需要看到一个运行结果,告诉我们哪些测试通过了,哪些测试失败了。怎么实现这个功能?

2.3.2 解决方案

2.3.2.1 弃用方案

弃用方案:在 TestCase 中加一个 flag,表明测试用例是否正常执行。

弃用原因:测试用例出错的几率很小,大多数测试用例在运行时都是成功的。因此,我们只需要重点记录运行失败的测试用例。运行成功的测试用例,一句话概括就行了。在 TestCase 中加 flag,大多数情况下用不到,不合适。

2.3.2.2 收集者模式(Collecting Parameter)2

创建 TestResult 类,作为收集者。

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

将一个 TestResult 对象传递给 TestCase 的 run 方法,run 方法会通知 TestResult,测试用例开始执行了。

public void run(TestResult result) { 
    result.startTest(this); 
    setUp(); 
    runTest(); 
    tearDown(); 
}

TestResult 会记录已运行的测试用例的数量。

public synchronized void startTest(Test test) { 
    fRunTests++; 
}
2.3.2.3 保持兼容性

仍然需要对外提供简单的 run 接口。

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

2.3.3 失败(failures)与错误(errors)

测试代码中写的 assert 语句条件不满足,就是失败;执行过程中意外抛错(如除以零、数组越界),就是错误。

怎么实现?

  1. TestResult 中分别保存 failure 和 error 的列表;
  2. 在 run 里加 try、catch。 AssertionFailedError 视为 failure,其他抛错视为 error。分别加到 TestResult 中。
public void run(TestResult result) { 
    result.startTest(this); 
    setUp(); 
    try { 
        runTest(); 
    } 
    catch (AssertionFailedError e) { //1 
        result.addFailure(this, e); 
    }
    catch (Throwable e) { // 2 
        result.addError(this, e); 
    } 
    finally { 
        tearDown(); 
    } 
}

2.3.4 收集者模式的灵活应用

问:为什么通过内部抛错,外部 catch 的方式来记录运行结果,而不是遵循收集者模式的规范,把 TestResult 传递到测试用例的方法里面去?

答:避免污染测试用例的方法签名。测试方法遇到问题,只管抛错,不需要知道 TestResult 的存在,更不需要知道怎么使用它。解耦。

2.3.5 扩展性

TestResult 提供了好几种不同的实现:

  • 默认实现:收集结果,记录失败和错误的数量;
  • TextTestResult:收集结果,用文本的方式来表示结果;
  • UITestResult:供带图形界面的 Test Runner 使用。

客户可以实现自己的 TestResult 类。

2.4 避免无意义的子类:回到 TestCase

2.4.1 问题描述

我们需要一个统一的接口来运行测试。因此,要写一个测试用例,就需要继承 TestCase,并覆盖 runTest 方法。

然而,实际上一个测试类中,可能包含多个测试用例,它们被写成类中的方法,例如 testMoneyEquals、testMoneyAdd。一个类有多个不同的可执行方法,这与我们命令模式定义的接口并不匹配。(一个命令,只能有一个“执行”方法)

我们需要想办法,让上层的命令调用者,调用统一的接口,执行所有测试用例(即测试类中的各种测试方法)

2.4.2 解决方案

2.4.2.1 方案一:适配器模式(Adapter)3

适配器模式,可以转换接口。把原始接口,转换为客户端期望的接口。

2.4.2.1.1 类适配

每个测试用例,都变成一个子类,去适配父类的接口。

public class TestMoneyEquals extends MoneyTest { 
    public TestMoneyEquals() { 
        super("testMoneyEquals");
    } 
    protected void runTest () {
        testMoneyEquals();
    } 
}

问题:

  • 增加编写测试代码的复杂性,违背了方便易用的设计初衷
  • 类爆炸(class bloat):如果某个业务类,有20个方法,我要针对每个方法写一个测试用例,就需要创建 20 个只包含一个方法的子类!(a)开销太大,性能问题。(b)类太多,不好命名,不好管理。
2.4.2.1.2 改进:匿名内部类
TestCase test = new MoneyTest("testMoneyEquals ") { 
    protected void runTest() {
    testMoneyEquals();
} 
};
2.4.2.2 方案二:pluggable behavior 之 Pluggable Selector4

参数化类。跟泛型有点像。在一个实例变量中,存放方法选择器;根据不同的变量值,类会表现出不同的行为。

在 Java 中,利用反射实现 pluggable selector5

protected void runTest() throws Throwable { 
    Method runMethod= null; 
    try { 
        runMethod= getClass().getMethod(fName, new Class[0]); 
    } catch (NoSuchMethodException e) { 
        assertTrue("Method \""+fName+"\" not found", false); 
    } 
    try { 
        runMethod.invoke(this, new Class[0]); 
    } 
    // catch InvocationTargetException and IllegalAccessException 
}

测试用例的名字(fName),与测试方法的名字必须相同。

在 JUnit 中,此方案是 runTest 方法的默认实现

2.5 多就是一:TestSuite

2.5.1 问题描述

目前为止,JUnit 可以运行单个测试,并报告运行结果。但是,为了保障一个系统的正确性,我们需要运行很多测试。因此,下一个目标,是让 JUnit 能运行许多个不同的测试。

2.5.2 解决方案

2.5.2.1 组合模式(Composite)6

将对象组合成树形结构以表示“部分-整体”的层次结构,组合模式使得用户对单个对象和组合对象的使用具有一致性。

  • 组件:定义操作接口。
  • 组合:实现接口,内部维护一个测试用例的集合。
  • 叶子结点:代表一个实现组件接口的测试用例。
  • Component: declares the interface we want to use to interact with our tests.
  • Composite: implements this interface and maintains a collection of tests.
  • Leaf: represents a test case in a composition that conforms to the Component interface.
2.5.2.2 模式应用

组件:Test 接口

public interface Test { 
    public abstract void run(TestResult result); 
}

组合:TestSuite

public class TestSuite implements Test { 
    private Vector fTests= new Vector(); 

    public void run(TestResult result) { 
        for (Enumeration e= fTests.elements(); e.hasMoreElements(); ) { 
            Test test= (Test)e.nextElement(); 
            test.run(result); 
        } 
    }
}

叶子结点:TestCase(具体实现之前已经讨论过)

2.5.3 客户端调用 TestSuite

初期方案

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

改进后方案:

直接传递一个 class,在构造函数中,利用反射去查找测试方法。

public static Test suite() { 
    return new TestSuite(MoneyTest.class); 
}

如果只想运行某个类中的一部分测试用例,仍然可以使用第一个方案。

2.5.6 总结

2.5.6.1 模式图

成熟的OO设计,都会体现出类似的“模式密度”。

2.5.6.2 演进图

组合模式威力巨大,但也很复杂。慎用。

3 最后总结

3.1 设计模式

  • 用设计模式来讨论框架设计过程,非常有效;
  • 为自己的系统撰写文档时,可以考虑采用这样的行文方式。

3.2 模式密度

  • 在 JUnit 的核心类 TestCase 周围,有很高模式密度;
  • 对软件设计而言,模式密度越高,越容易使用,但也越难修改;
  • 成熟框架,都有较高的模式密度;反之,不成熟的框架,模式密度都较低;
  • 我们在开发系统的过程中,遇到问题时,不断对解决方案进行“压缩”,就能得到更高的模式密度。

3.3 自给自足

  • 在 JUnit 的基础功能实现后,JUnit 的后续开发都使用 JUnit 自己进行测试。

3.4 保持简单,不要面面俱到

  • 框架的功能越少,学习成本越低,开发者越容易使用;
  • JUnit 只实现了运行单元测试必须的功能:运行多个用例(TestSuite)、隔离测试用例、自动化;
  • 实在要添加新功能时,小心翼翼地加到扩展包中(test.extenstions)。

 

 

附:读后感

下面简单说下自己的体会。

一、设计模式相关

  1. 大量使用设计模式(大都源于 SmallTalk),可以看出设计模式真的是从实践经验中总结出来的,具备极强的实用价值。
  2. 增量套用设计模式,逐步构建复杂系统。感觉是比较清奇的思路。
  3. “模式密度”,可以衡量一个软件系统的成熟度。不成熟的系统,通过持续“压缩”,提高“密度”,可以逐步变成熟。
  4. 必要时,可以打破模式的规范(情况一般为:坚持贯彻模式的规范,会增加系统复杂性,却对灵活性没有提升或提升很小)。

二、代码质量

1、严格控制类文件的行数。很少有超过 200 行的类;极少有超过 300 行的类。

2、具体实现需要封装为一个抽象方法。比如下面这段代码中的 isPublicTestMethod 和 isTestMethod。极大增强可读性。

private void addTestMethod(Method m, List names, Class<?> theClass) {
    String name = m.getName();
    if (names.contains(name)) {
        return;
    }
    if (!isPublicTestMethod(m)) {
        if (isTestMethod(m)) {
            addTest(warning("Test method isn't public: " + m.getName() + "(" + theClass.getCanonicalName() + ")"));
        }
        return;
    }
    names.add(name);
    addTest(createTest(theClass, name));
}
 
private boolean isPublicTestMethod(Method m) {
    return isTestMethod(m) && Modifier.isPublic(m.getModifiers());
}
 
private boolean isTestMethod(Method m) {
    return m.getParameterTypes().length == 0 &&
            m.getName().startsWith("test") &&
            m.getReturnType().equals(Void.TYPE);
}

三、其他

1、保持简单。

尽量不要添加额外的功能,否则会增加客户的学习成本,让工具本身成为一种负担,没有人愿意用。

2、“观千剑而后识器”,阅读优秀项目的源码,是增长功力、见识的不二法门。

3、提供易于理解的开发文档。这一点,真是给 JUnit 的作者跪了,开发文档也写得深入浅出,太牛逼。

 

 


  1. 模版方法模式,定义了算法的骨架。由子类提供具体实现。 ↩︎
  2. 当你需要搜集多个方法的执行结果时,可以给这些方法加一个参数,把收集者传进去。 ↩︎
  3. "Convert the interface of a class into another interface clients expect" ↩︎
  4. The idea is to use a single class which can be parameterized to perform different logic without requiring subclassing. ↩︎
  5. 我们通常不在普通应用程序代码中使用反射。但我们正在做一个基础框架,因此用反射是可以的。 ↩︎
  6. "Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly." ↩︎