本来想去公司实习的,但公司没给我们安排,倒是给了一些在校实习题目给我们做。以前一直都在接触单片机开发方面的东西(以C语言为主),所以题目出来之后毅然选择了一个C++方向的题目,题目是做新浪微博发送微博和图片的单元测试。拿到CppUnit框架分析了十多天之后总算整理出来了框架的结构,然后下载C++版的微博SDK。拿到SDK才发现傻X了,接口不会用,查阅网上的资料,有说是官方的授权接口改了,已经不再支持了。自己就想,既然新浪都给了,肯定还是能用的吧,只不过能力有限,拿到新浪给定app_key怎么都发不了微博,实在是搞不定。
接口不会用,但事还是得做的,既然C++的不行,那就下个当下最流行的java版本sdk吧。下来后,根据网上查到的资料开始安装jdk,eclipse等等(本人第一次接触java)。配置好系统需求之后,开始导入sdk,配置授权码等等,捣鼓完一通之后,用sdk的example终于发出了第一个微博,这可把我高兴的,当晚吃的格外开心。既然能发微博,一切就走入正轨,心里有底了。接下来就是分析java版的junit,还好cppunit是从junit发展而来,算是见着老祖宗了。本着C++的语法,一点点的依葫芦画瓢去理解java,一点点找主线,又折腾了五六天,终于弄出了这个框架报告。
这个报告的捏造过程参照了很多位大牛的文档(文章末尾有参考),抄袭多于自身理解的东西。但有朝一日会明白的。
报告生成时间:2012-12-5
作者:wxh 哈尔滨工程大学
JUnit版本:JUnit4.11
版本获取地址:https://github.com/KentBeck/junit/downloads
一、Junit4概述
JUnit是一个优秀的开源Java单元测试框架,由两位世界级软件大师Erich Gamma 和 Kent Beck共同开发完成。Erich Gamma是《设计模式》的四作者之一,而Kent Beck则是极限编程的倡导者。两位大师在JUnit中大量运用了设计模式,正是这些优秀模式的灵活应用,使得这个经典的测试架构稳定高效,而且可扩展性非常强,如今JUnit已形成了xUnit系列,支持CppUnit、phpUnit,pythonUnit等等,此外一些公司也针对xUnit进行二次开发,发展了如谷歌的g-Test等新的测试框架,成为最为广泛使用的单元测试框架系列。
在 JUnit单元测试框架的设计时,设定了三个总体目标:
第一个是简化测试的编写,这种简化包括测试框架的学习和实际测试单元的编写;
第二个是使测试单元保持持久性;
第三个则是可以利用既有的测试来编写相关的测试。
二、Junit4框架代码
在eclipse中展开JUnit4的源代码,可以看到如右图的代码结构。基本上右图的代码可以分为三部分
1、框架核心Core:juint.framework 包含测试运行所 需的全部核心代码
2、扩展、运行、界面部分:
juint.externsions:提供多线程测试,循环测试、 测试前和测试后准备的扩展
Junit.runner:提供测试运行的基类
Juint.textui:以文本方式运行测试的运行器
3、支持部分:org.junit.* 大部分是与方便编写测试而写的中间代码,对于理解框架核心无需太多关注。
三、Junit4框架UML关系模型
3.1、类图关系模型
测试框架的UML类图模型
Test类是接口类,定义了测试用例的统一接口;
TestCase是被最终测试用例实现的抽象类;
TestSuite是专为运行多个测试用例,对测试用例进行有效管理而存在的;
每一个测试都会产生相应的结果,而结果的收集工作就由TestResult完成。
测试结果相关类
为了方便收集与管理测试结果,TestResult要用到TestFailure和TestListener类。TestFailure负责包装错误信息,而TestListener的引入则是为了方便扩展测试结果的表达。
以上类图对FrameWork包中的框架构成进行了一个大致的图示,关于具体设计模式,将在后续模式设计中重点表述。
3.2、框架调用顺序图
测试运行调用顺序图
调用解析:进入junit.testui的testrunner.java文件,查阅其run(Class<? extends TestCase> testClass)方法,这是每次测试的入口函数,其首先创建一个默认testsuite,然后调用其另一个带test参数的run方法,由此可见JUnit采用testsuite来组织测试用例,每次运行它都会创建一个默认testsuite,以此作为所有用例的根。
以上两次run()函数调用可以理解为第一次是将测试用例打包,第二次则是将打包后的用例正式进行测试运行。值得注意的是JUnit将测试用例当成树形结构来管理。所有测试用例在书中表现为是叶子节点,而suite则表现为非叶子节点,没个非叶子节点下面可以有任意多个非叶子节点和子节点,其结构如下图所示:
用例的树形组织结构
Run(test)方法中第一次调用doRun()方法,由于每次运行都需要有一个TestResult做为收集器,所以首要任务是创建一个result,然后对result初始化,如添加TestListener,准备好收集器之后,就可以正式使用该收集器调用用例的run()开始测试了。
关于用例运行时用例与result的交互以及其创建环境的过程顺序图如下:
单看TestCase的生命线可以看到,其开始调用的是run(result)即开始测试,经过一段准备之后,其顺序调用了setUp()、testRun()、tearDown(),刚好对应测试过程中的测试准备->测试->销毁资源三个过程,事实上这三个过程是result通过调用TestCase的runbare()方法而执行的。
而单看result的过程也有非常明显的主线:通知开始测试starttest(),正式测试runbare(),结束测试endtest()。关于runbare()的意图已经很明显,而开始于结束测试则是为了通知监听器,告诉监听对象现在所发生的状态。也即可以看到,当测试运行的时候,测试的信息一方面被result收集起来,同时也动态将最新的测试消息通过监听器传递给了外界,当然,监听器只是一个接口,外界需要侦听信息只要事先TestLicenner接口,然后再result中调用addlisenner(your lisenner)注册即可。
四、Junit4设计模式分析
4.1、Junit4中的模式
根据《设计模式之禅》(作者秦小波)中常用设计模式的列举,一共有23中常用的设计模式。在JUnit框架中,可以看到作者运用了包括:
1)、命令模式:将请求封装成对象,实现请求方(测试用例)和执行方(JUnit框架)的分离。便于编写测试代码。
2)、模板方法:定义一个操作中算法的框架(<<abstract>>TestCase),而将具体实现延迟到子类中(具体用例),使得子类可以不改变算法结构即可从定义概算发的某些特定步骤。
3)、收集模式:当需要从好几个method方法(TestCase::run())中收集结果信息时,定义一个用来接收结果的对象(TestResult),给该方法添加一个参数,将此对象作为参数传递给该方法。
4)、观察者模式:也叫订阅-发布模式,定义对象间一种一对多的依赖关系,使得每当一个被依赖对象(TestResult)改变状态,则所有依赖于它的对象(Lisenner)都会得到通知并被自动更新。
5)、组合模式:也叫合成模式或部分-整体模式,将对象组合成树形结构以表示“部分-整体”的层次结构,使得用户对单个对象和组合对象的使用具有一致性。
6)、装饰模式:动态的给一个对象(TestCase)添加一些额外的职能(由单次测试变成循环测试)。就增加功能来说,装饰模式比生成子类更为灵活。
7)、适配器模式:将一个类的接口变换成客户端所期望的另一种接口,从而使原本因接口不匹配而无法再一起工作的两个类能够在一起工作(本文未作分析)。
4.2、Junit4模式的应用分析
4.2.1、命令模式的应用
(本节未作太多分析,理解不深)
Cammand 模式告诉我们要为操作创建一个对象,并提供一个“execute”方法。
此处是TestCase类的定义
public abstract class TestCase implements Test {
…
}
从上可见TestCase类是 "public abstract"类型,之所这样定义是因为希望这个类能够很好的被重用。我们先暂且忽略它实现的Test接口。
每个TestCase都有一个名字,所以如果测试失败了,你才能区别是哪个测试失败了。
public abstract class TestCase implements Test {
private final String fName;
public TestCase(String name) {
fName= name;
}
public void run();
…
}
为了演示JUnit的演进发展方式,我们使用图例来对架构进行形象的描述。
下图描述了TestCase所使用的模式:
4.2.2、模板方法的应用
(个人认为这是最简单而又很有用的模式)
现在出现的问题是要给开发者一个方便的地方来“放置”他们的资源配置和待测试代码。把TestCase声明为abstract意味着开发者需要通过继承TestCase类来重用代码。然而,如果我们所能做的全部只是提供这样一个简单的父类,它无法满足我们期望的让编写测试变得更容易的基本目标。
幸运的是,TestCase为所有的测试提供了一个公共架构:先setUp测试的fixture,然后run测试代码,收集测试结果,最后tearDown测试的fixture。这种架构意味着每个测试都是在一个全新的fixture中运行,它运行不会对其它测试的产生任何影响,即每个测试用例都是独立的。
模板方法(Template Method)可以使我们在解决一些问题上变得更容易。模板方法被定义为:"Define the skeleton of an algorithm in an operation, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm’s structure." 其意思为:“在一个操作中预先定义算法的骨架,子类继承重定义该算法的一些具体步骤,这样可以再不改变算法结构的基础上很好的重用该算法“。
这正是我们所需要的,我们希望开发者能将写测试的fixture与写真正的测试代码分割开来,这种执行的顺序将会被所有的测试用例所采用。”
下面的即是template method的过程:
public void run() {
/***setUp();
* runTest();
* tearDown(); */
runBare() ;
}
public void runBare() throws Throwable {
Throwable exception = null;
setUp();
try {
runTest();
} catch (Throwable running) {
exception = running;
} finally {
try {
tearDown();
} catch (Throwable tearingDown) {
if (exception == null) exception = tearingDown;
}
}
if (exception != null) throw exception;
}
上面几个方法什么事情也不做,因为setUp 和 tearDown方法需要在被继承时重写。其定义如下面所示
protected void runTest() { }
protected void setUp() { }
protected void tearDown() { }
经过上边的分析,可以看到JUnit又采用了一种新的模式叫Template Method
4.2.3、收集模式的应用
当测试被执行时,我们需要关注每一个测试的结果吗?当然,测试的目的是为了保证被测试的代码能够被正确的运行。在测试完成之后,你希望能给测试的代码“打个分”,一个工作或不工作的统计数据。
如果测试失败和成功的几率是相等的,或者我们只是运行了一个测试用例,我们可以通过在TestCase中设置一个标志位来看查看结果。然而,测试结果的分布通常是极不对称的--他们通常都是能通过测试的。因此,我们感兴趣的只是那些失败了的用例,而成功用例我们更关心的只是统计个数而已。
《The Smalltalk Best Practice Patterns》书中提供了一个可用的模式:收集模式(Collecting Parameter)。收集模式建议当你需要从好几个method方法中收集结果信息时,你可以给该方法添加一个参数,将一个用来接收结果的对象传递给该方法。为此我们创建一个叫TestResult的对象来收集所运行的测试的结果。
public class TestResult extends Object {
protected int fRunTests;
public TestResult() {
fRunTests= 0;
}
protected void run(final TestCase test) {
startTest(test);
Protectable p = new Protectable() {
public void protect() throws Throwable {
test.runBare();
}
};
runProtected(test, p);
endTest(test);
}
}
为了使用收集模式,我们需要给TestCase.run()添加一个参数,另外希望能提供一个简单的TestCase扩展接口,以用于运行无参的run方法,让run方法自己生成一个TestResult对象,改写如下:
public void run(TestResult result) {
result.run(this);
}
public TestResult run() {
TestResult result = createResult();
run(result);
return result;
}
protected TestResult createResult() {
return new TestResult();
}
此处把运行测试委托给了result去执行,如果要追踪测试用例数,只需编写 startTest()如下:
public synchronized void startTest(Test test) {
fRunTests++;
}
此处将 startTest 声明为synchronized 以支持多线程运行测试。
如果测试总是正确运行的,那我们就没有必要写测试代码了。测试关注的是能发现失败,尤其是那些我们没有考虑到的失败。另外,测试能以我们期望的方式失败,比如处理一个不正确的结果,当然,测试也会以一些特殊的方式失败,比如向超出边界的数组写数据。不管是那种失败,我们都希望能执行所有的测试。
Junit区分failures和errors。Failure是可被预知且可以被断言所检测到的,而Errors是不可预知的,比如ArrayIndexOutOfBoundsException异常。Failures被标记为AssertionFailedError错误。从Error类派生AssertionFailedError :
public class AssertionFailedError extends Error {
public AssertionFailedError () {}
}
JUnit为了区分Failures和Errors,定义了类似于如下的过程:
public void realRuntest() {
try {
runTest();
} catch (AssertionFailedError e) {//此处捕获断言异常failures
addFailure(test, e);
} catch (Throwable e) {//此处捕获不可预知的错误errors
addError(test, e);
}
}
TestResult 中收集errors 的方法定义如下:
public synchronized void addError(Test test, Throwable t) {
fErrors.addElement(new TestFailure(test, t));
}
public synchronized void addFailure(Test test, Throwable t) {
fFailures.addElement(new TestFailure(test, t));
}
此处出现的TestFailure 是framework框架中的一个内部辅助类,目的是绑定失败测试与其异常类型,以便于之后产生测试报告。
public class TestFailure extends Object {
protected Test fFailedTest;
protected Throwable fThrownException;
}
AssertionFailedError 被测试用例的断言方法触发。Junit为不同的检测目的提供了一系列的断言方法(assert methods),下面是是最简单的一个:
protected void assertTrue(boolean condition) {
if (!condition)
throw new AssertionFailedError();
}
异常在测试运行过程中(setUp、runTest和tearDown)被抛出,即可能是预知的异常抛出(由断言抛出的AssertionFailedError异常),也可能是不可预知的错误抛出。其异常在runbare()方法中被捕获后,又再次向外抛出,最后在realRuntest()(实际上此方法是TestResult中run方法里的runProtected(test, p))中异常被分类并被记录下来。
4.2.4、观察者模式的应用
测试运行过程中我们形式希望能方便地看到测试运行的结果,因此我们必须进行报告测试的进行状况,或者打印到控制台,或者是文件,或者 GUI 界面,甚至同时需要输出到多种介质(JUnit4默认只支持控制台输出)。这就要求JUnit 需要提供方便的扩展接口,使得定制开发输出方式变得简单易行。于是这样就存在对象间的依赖关系,当测试进行时的状态发生时(TestCase 的执行有错误或者失败等) ,所有依赖这些状态的对象必须自动更新,但是 JUnit 又不希望为了维护一致性而使各个类紧密耦合,因为这样会降低它们的重用性,怎样解却这个问题?
Observer(观察者)模式提供了一个很好的解决方案。观察者模式又叫做发布-订阅(Publish-Subscribe)模式,源-监听器(Source/Listener)模式。具有以下意图“定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新” 。这听起来非常适合需求。在 JUnit 测试用例时,测试信息一旦发生改变,如发生错误或者失败,结束测试等,各种输出就要有相应的更新,如文本输出就要在控制台打印信息,GUI 则在图形中标记错误信息等。
为实现观察者模式,首先就要定义一个观察者,或叫监听器 TestListener,它是一个接口,定义了几个方法,说明它监听的几个方法。如测试开始,发生失败,发生错误,测试结束等监听事件的时间点。
public interface TestListener {
// An error occurred.
public void addError(Test test, Throwable t);
// A failure occurred.
public void addFailure(Test test, AssertionFailedError t);
// A test started.
public void startTest(Test test);
//A test ended.
public void endTest(Test test);
}
开发人员要扩展输出只需实现 TestListener即可。下面看在 TextUi 方式是如何实现的,它由一个ResultPrinter类实现。
public class ResultPrinter implements TestListener {
PrintStream fWriter;
public PrintStream getWriter() {
return fWriter;
}
public void startTest(Test test) {
getWriter().print(".");
}
public void addError(Test test, Throwable t) {
getWriter().print("E");
}
public void addFailure(Test test, AssertionFailedError t) {
getWriter().print("F");
}
public void endTest(Test test) {
}
}
在 JUnit 中使用 TestResult 来收集测试的结果,我们看看TestResult中有关Lisener的相关方法
public class TestResult extends Object {
//使用Vector来保存,事件的监听者
protected List<TestListener> fListeners;
public synchronized void addListener(TestListener listener) { // Registers a TestListener
fListeners.addElement(listener);
}
public synchronized void removeListener(TestListener listener) { //Unregisters a TestListener
fListeners.removeElement(listener);
}
public void startTest(Test test) { //Informs the result that a test will be started.
for (Enumeration e= cloneListeners().elements(); e.hasMoreElements(); ) {
((TestListener)e.nextElement()).startTest(test);
}
}
//Adds an error to the list of errors. The passed in exception caused the error.
public synchronized void addError(Test test, Throwable t) {
for (Enumeration e= cloneListeners().elements(); e.hasMoreElements(); ) {
((TestListener)e.nextElement()).addError(test, t);
}
}
//以下省略了addFailure和endTest代码
}
至此,利用观察者模式我们可以很方便的将测试结果发布给它的订阅者,订阅者可以是textui,gui或者是htmlui等等,订阅者只需要实现TestLisener,并注册到TestResult中即可很方便的得到实时的测试结果。关于TestResult与TestLisener的关系如下:
4.2.5、组合模式的应用
为了获取对所开发的系统的信心,我们需要许多测试支持,到现在为止JUnit可以运行单个测试并在TestResult中收集结果并通过Lisenner的实现显示结果。我们接着面临的问题是:拓展测试,以便于允许许多不同的测试用例。
如果能有一种机制,能够使得调用者不需要区分运行的是单个测试或者多个测试,那么这个问题可以被轻松的被现有的框架解决。
一个流行的模式可以解决这个问题,那就是组合(Composite)模式,引述组合模式的定义:"Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly." 意思:将对象组合成树形结构以表示“部分-整体”的层次结构,使得用户对单个对象和组合对象的使用具有一致性。
组合模式引入了以下几个成员:
Component:这是一个抽象角色,它给参加组合的对象规定一个接口。这个角色,给出共有的接口和默认行为。其实就我们的 Test 接口,它定义出 run 方法。
Composite:实现共有接口并维护一个测试用例的集合。就是复合测试用例 TestSuit
Leaf:代表参加组合的对象,它没有下级子对象,仅定义出参加组合的原始对象的行为,其实就是单一的测试用例 TestCase,它仅实现 Test 接口的方法。
组合模式告诉我们要引入一个 Component 抽象类,为 Leaf 对象和 composite 对象定义公共的接口。这个类的基本意图就是定义一个接口。在 Java 中使用 组合模式时,优先考虑使用接口,而非抽象类,因此引入一个 Test 接口。当然我们的 leaf 就是 TestCase了。其源代码如下:
//composite模式中的Component角色
public interface Test {
public abstract void run(TestResult result);
}
//composite模式中的Leaf角色
public abstract class TestCase extends Assert implements Test {
public void run(TestResult result) {
result.run(this);
}
}
下面,列出 Composite 源码。将其取名为 TestSuit 类。TestSuit 有一个属性 fTests (Vector类型)中保存了其子测试用例,提供 addTest 方法来实现增加子对象 TestCase ,并且还提供testCount 和 tests 等方法来操作子对象。最后通过 run()方法实现对其子对象进行委托(delegate) ,最后还提供 addTestSuite 方法实现递归,构造成树形。
public class TestSuite implements Test {
private Vector fTests= new Vector(10);
public void addTest(Test test) {
fTests.addElement(test);
}
public void addTestSuite(Class<? extends TestCase> testClass) {
addTest(new TestSuite(testClass));
}
public Enumeration tests() {
return fTests.elements();
}
public void run(TestResult result) {
for (Enumeration e= tests(); e.hasMoreElements(); ) {
Test test= (Test)e.nextElement();
test.run(result);
}
}
}
TestSuite中不区分单个或多个测试,针对每个test,testsuite都将run()的过程委托给具体的对象去执行,显然,如果当前是叶子对象即TestCase,则会调用TestCase的run方法,这就和前面的衔接上了,如果是个suite,那么将形成递归调用,最后递归到叶子节点也会调用TestCase的run方法。
分析了 Composite 模式的实现后我们列出它的组成,如下图:
4.2.6、装饰模式的应用
经过以上的分析知道 TestCase 是一个及其重要的类,它定义了测试步骤和测试的处理。但是作为一个框架,应该提供很方便的方式进行扩展,进行二次开发。允许不同的开发人员开发适合自己的 TestCase,如希望 Testcase 可以多次反复执行、可以处理多线程、可以测试 Socket 等扩展功能。当然使用继承机制是增加功能的一种有效途径,例如 RepeatedTest 继承 TestCase 实现多次测试用例,测试人员然后继承 RepeatedTest 来实现。但是这种方法不够灵活,是静态的,因为每增一种功能就必须继承,使子类数目呈爆炸式的增长,开发人员不能动态的控制对功能增加的方式和时机。JUnit 必须采用一种合理,动态的方式进行扩展。
解决这个问题可以使用装饰(Decorator)模式。Decorator(装饰)模式又名包装(Wrapper)模式。其意图是“动态地给一个对象添加一些额外的职责。就增加功能来说,Decorator 模式相比生成子类更为灵活” 。这样就可以动态的为 TestCase 增加职责,或者可以动态地撤销,动态的任意组合。
实现装饰模式必须增加 Decorator 角色,于是开发 TestDecorator类, 它首先要实现接口Test, 然后有一个私有的属性Test fTest, 接口的实现run都委托给fTest 的 run,该方法将由具体的装饰类来实现,以增强功能。代码如下:
public class TestDecorator extends Assert implements Test {
//将要装饰的类,给它增加功能
protected Test fTest;
public TestDecorator(Test test) {
fTest = test;
}
public void basicRun(TestResult result) {
fTest.run(result); //交给具体测试完成
}
public void run(TestResult result) {
basicRun(result);
}
}
由装饰类的定义可见装饰最终并不自己运行测试,还是把运行的工作交还给具体用例的run()方法,只不过这个方法已经被包装过了。实现装饰类之后,就可以开始准备给原有的测试添加功能,此处加入的循环测试功能。下面是一个具体的装饰类 RepeatedTest 它可以多次执行一个 TestCase,这增强了 TestCase 的职责
public class RepeatedTest extends TestDecorator {
private int fTimesRepeat;
public RepeatedTest(Test test, int repeat) {
super(test);
fTimesRepeat= repeat;
}
//为ConcreteComponent增加功能,可以执行多次
public void run(TestResult result) {
for (int i= 0; i < fTimesRepeat; i++) {
if (result.shouldStop())
break;
super.run(result); //委托父类完成
}
}
}
实现装饰模式后的UML模型如下,其中TestSetup实现的是在所有测试开始和结束的时候加入setUp和tearDwon,也即加入了一个全局的fixture,它对所的测试都起效。
使用装饰模式使得添加测试功能变得十分容易。
五、结束语
Junit是一个优秀的测试框架,大量模式的应用使得整个框架简洁而高效,而且难能可贵的是它拥有很强的扩展性。Junit的目标就是使测试变得更加容易,尽可能的去除一切需要重复的工作。如今Junit4已经与eclipse等继承开发工具深度融合,甚至可以一步生成待测试类的测试代码框架,极大的方便了测试工作的进行。分析Junit4的框架代码不仅可以更好的理解测试过程的执行,而且它还是一个很好的编程学习平台。大量运用的优秀设计模式对于分析问题,提高代码编写能力都非常有好处。
六、参考文档
1、Junit4.11代码源文件 Junit-4.11-src.jar
2、《JUnit A Cook's Tour》
官方文档:junit4.11/junit4.11/doc/cookstour/cookstour.htm
3、《Junit设计模式分析》 作者:刘兵 <程序员>6 期
4、《设计模式之禅》 作者:秦小波 机械工业出版社出本版