JUnit 3.8.x 框架设计(JUnit 源码阅读必备)
- Java
- 2019-03-14
- 193热度
- 0评论
导航
1 目标
1.1 背景
关于软件开发的假设
如果一个程序缺乏自动化测试,我们就认为它是无法工作的。
以前的假设(不合理的假设)是:
如果开发者向我们保证程序的功能是可用的,那么它就可以一直正常运行下去。
开发者编写和调试完功能代码后,工作尚未完成。必须编写测试,来证明程序的可用性。
实际情况
开发者很忙,很难腾出手来写测试代码。
1.2 开发目标
- 写一套单元测试框架,便于学习和使用,让开发者愿意写测试;
- 测试一旦写好,可以反复执行。即使多年之后,现在写的单元测试代码依然能发挥作用。另外,不同作者写的单元测试,可以在一起运行;
- 写新的测试时,不必总是从头搭建测试环境。测试环境初始化一次之后,就能在它的基础上,写很多测试用例。
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
- 所有测试都有相同的结构:搭建测试环境、运行测试代码、检查运行结果、清理测试环境。
- 每个测试用例,都需要在全新的测试环境下运行,不能被其他测试用例污染。
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 语句条件不满足,就是失败;执行过程中意外抛错(如除以零、数组越界),就是错误。
怎么实现?
- TestResult 中分别保存 failure 和 error 的列表;
- 在 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)。
附:读后感
下面简单说下自己的体会。
一、设计模式相关
- 大量使用设计模式(大都源于 SmallTalk),可以看出设计模式真的是从实践经验中总结出来的,具备极强的实用价值。
- 增量套用设计模式,逐步构建复杂系统。感觉是比较清奇的思路。
- “模式密度”,可以衡量一个软件系统的成熟度。不成熟的系统,通过持续“压缩”,提高“密度”,可以逐步变成熟。
- 必要时,可以打破模式的规范(情况一般为:坚持贯彻模式的规范,会增加系统复杂性,却对灵活性没有提升或提升很小)。
二、代码质量
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 的作者跪了,开发文档也写得深入浅出,太牛逼。
- 模版方法模式,定义了算法的骨架。由子类提供具体实现。 ↩︎
- 当你需要搜集多个方法的执行结果时,可以给这些方法加一个参数,把收集者传进去。 ↩︎
- "Convert the interface of a class into another interface clients expect" ↩︎
- The idea is to use a single class which can be parameterized to perform different logic without requiring subclassing. ↩︎
- 我们通常不在普通应用程序代码中使用反射。但我们正在做一个基础框架,因此用反射是可以的。 ↩︎
- "Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly." ↩︎
文中提到的“收集者模式”,应该就是“访问者模式”