单元测试
单元测试是在软件开发过程中要进行的最低级别的测试活动,在单元测试活动中,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。 单元测试不仅仅是作为无错编码一种辅助手段在一次性的开发过程中使用,单元测试必须是可重复的,无论是在软件修改,或是移植到新的运行环境的过程中。因此,所有的测试都必须在整个软件系统的生命周期中进行维护。
简介
在一种传统的结构化编程语言中,比如C,要进行测试的单元一般是函数或子过程。在象C++这样的面向对象的语言中, 要进行测试的基本单元是类。对Ada语言来说,开发人员可以选择是在独立的过程和函数,还是在Ada包的级别上进行单元测试。单元测试的原则同样被扩展到第四代语言(4GL)的开发中,在这里基本单元被典型地划分为一个菜单或显示界面。
经常与单元测试联系起来的另外一些开发活动包括代码走读(Code review),静态分析(Static analysis)和动态分析(Dynamic analysis)。静态分析就是对软件的源代码进行研读,查找错误或收集一些度量数据,并不需要对代码进行编译和执行。动态分析就是通过观察软件运行时的动作,来提供执行跟踪,时间分析,以及测试覆盖度方面的信息。
单元测试详解
单元测试(模块测试)是开发者编写的一小段代码,用于检验被测代码的一个很小的、很明确的功能是否正确。通常而言,一个单元测试是用于判断某个特定条件(或者场景)下某个特定函数的行为。例如,你可能把一个很大的值放入一个有序list 中去,然后确认该值出现在list 的尾部。或者,你可能会从字符串中删除匹配某种模式的字符,然后确认字符串确实不再包含这些字符了。
单元测试是由程序员自己来完成,最终受益的也是程序员自己。可以这么说,程序员有责任编写功能代码,同时也就有责任为自己的代码编写单元测试。执行单元测试,就是为了证明这段代码的行为和我们期望的一致。
工厂在组装一台电视机之前,会对每个元件都进行测试,这,就是单元测试。 其实我们每天都在做单元测试。你写了一个函数,除了极简单的外,总是要执行一下,看看功能是否正常,有时还要想办法输出些数据,如弹出信息窗口什么的,这,也是单元测试,把这种单元测试称为临时单元测试。只进行了临时单元测试的软件,针对代码的测试很不完整,代码覆盖率要超过70%都很困难,未覆盖的代码可能遗留大量的细小的错误,这些错误还会互相影响,当BUG暴露出来的时候难于调试,大幅度提高后期测试和维护成本,也降低了开发商的竞争力。可以说,进行充分的单元测试,是提高软件质量,降低开发成本的必由之路。
对于程序员来说,如果养成了对自己写的代码进行单元测试的习惯,不但可以写出高质量的代码,而且还能提高编程水平。
要进行充分的单元测试,应专门编写测试代码,并与产品代码隔离。老纳认为,比较简单的办法是为产品工程建立对应的测试工程,为每个类建立对应的测试类,为每个函数(很简单的除外)建立测试函数。首先就几个概念谈谈老纳的看法。 一般认为,在结构化程序时代,单元测试所说的单元是指函数,在当今的面向对象时代,单元测试所说的单元是指类。以老纳的实践来看,以类作为测试单位,复杂度高,可操作性较差,因此仍然主张以函数作为单元测试的测试单位,但可以用一个测试类来组织某个类的所有测试函数。单元测试不应过分强调面向对象,因为局部代码依然是结构化的。单元测试的工作量较大,简单实用高效才是硬道理。
有一种看法是,只测试类的接口(公有函数),不测试其他函数,从面向对象角度来看,确实有其道理,但是,测试的目的是找错并最终排错,因此,只要是包含错误的可能性较大的函数都要测试,跟函数是否私有没有关系。对于C++来说,可以用一种简单的方法区隔需测试的函数:简单的函数如数据读写函数的实现在头文件中编写(inline函数),所有在源文件编写实现的函数都要进行测试(构造函数和析构函数除外)。
为什么要使用单元测试
我们编写代码时,一定会反复调试保证它能够编译通过。如果是编译没有通过的代码,没有任何人会愿意交付给自己的老板。但代码通过编译,只是说明了它的语法正确;我们却无法保证它的语义也一定正确,没有任何人可以轻易承诺这段代码的行为一定是正确的。
幸运,单元测试会为我们的承诺做保证。编写单元测试就是用来验证这段代码的行为是否与我们期望的一致。有了单元测试,我们可以自信的交付自己的代码,而没有任何的后顾之忧。
什么时候测试?单元测试越早越好,早到什么程度?XP开发理论讲究TDD,即测试驱动开发,先编写测试代码,再进行开发。在实际的工作中,可以不必过分强调先什么后什么,重要的是高效和感觉舒适。从老纳的经验来看,先编写产品函数的框架,然后编写测试函数,针对产品函数的功能编写测试用例,然后编写产品函数的代码,每写一个功能点都运行测试,随时补充测试用例。所谓先编写产品函数的框架,是指先编写函数空的实现,有返回值的随便返回一个值,编译通过后再编写测试代码,这时,函数名、参数表、返回类型都应该确定下来了,所编写的测试代码以后需修改的可能性比较小。
由谁测试?单元测试与其他测试不同,单元测试可看作是编码工作的一部分,应该由程序员完成,也就是说,经过了单元测试的代码才是已完成的代码,提交产品代码时也要同时提交测试代码。测试部门可以作一定程度的审核。
关于桩代码,老纳认为,单元测试应避免编写桩代码。桩代码就是用来代替某些代码的代码,例如,产品函数或测试函数调用了一个未编写的函数,可以编写桩函数来代替该被调用的函数,桩代码也用于实现测试隔离。采用由底向上的方式进行开发,底层的代码先开发并先测试,可以避免编写桩代码,这样做的好处有:减少了工作量;
测试上层函数时,也是对下层函数的间接测试;当下层函数修改时,通过回归测试可以确认修改是否导致上层函数产生错误。
在一种传统的结构化编程语言中,比如C,要进行测试的单元一般是函数或子过程。在象C++这样的面向对象的语言中, 要进行测试的基本单元是类。对Ada语言来说,开发人员可以选择是在独立的过程和函数,还是在Ada包的级别上进行单元测试。单元测试的原则同样被扩展到第四代语言(4GL)的开发中,在这里基本单元被典型地划分为一个菜单或显示界面。
单元测试不仅仅是作为无错编码一种辅助手段在一次性的开发过程中使用,单元测试必须是可重复的,无论是在软件修改,或是移植到新的运行环境的过程中。因此,所有的测试都必须在整个软件系统的生命周期中进行维护。
经常与单元测试联系起来的另外一些开发活动包括代码走读(Code review),静态分析(Static analysis)和动态分析(Dynamic analysis)。静态分析就是对软件的源代码进行研读,查找错误或收集一些度量数据,并不需要对代码进行编译和执行。动态分析就是通过观察软件运行时的动作,来提供执行跟踪,时间分析,以及测试覆盖度方面的信息。
一些流行的误解
在明确了什么是单元测试以后,我们可以进行\"反调论证\"了。在下面的章节里,我们列出了一些反对单元测试的普遍的论点。然后用充分的理由来证明这些论点是不足取的。
它浪费了太多的时间
一旦编码完成,开发人员总是会迫切希望进行软件的集成工作,这样他们就能够看到实际的系统开始启动工作了。 这在外表上看来是一项明显的进步,而象单元测试这样的活动也许会被看作是通往这个阶段点的道路上的障碍, 推迟了对整个系统进行联调这种真正有意思的工作启动的时间。
在这种开发步骤中,真实意义上的进步被外表上的进步取代了。系统能够正常工作的可能性是很小的,更多的情况是充满了各式各样的Bug。在实践中,这样一种开发步骤常常会导致这样的结果:软件甚至无法运行。更进一步的结果是大量的时间将被花费在跟踪那些包含在独立单元里的简单的Bug上面,在个别情况下,这些Bug也许是琐碎和微不足道的,但是总的来说,他们会导致在软件集成为一个系统时增加额外的工期, 而且当这个系统投入使用时也无法确保它能够可靠运行。
在实践工作中,进行了完整计划的单元测试和编写实际的代码所花费的精力大致上是相同的。一旦完成了这些单元测试工作,很多Bug将被纠正,在确信他们手头拥有稳定可靠的部件的情况下,开发人员能够进行更高效的系统集成工作。这才是真实意义上的进步,所以说完整计划下的单元测试是对时间的更高效的利用。而调试人员的不受控和散漫的工作方式只会花费更多的时间而取得很少的好处。
使用AdaTEST和Cantata这样的支持工具可以使单元测试更加简单和有效。但这不是必须的,单元测试即使是在没有工具支持的情况下也是一项非常有意义的活动。
它仅仅是证明这些代码做了什么
这是那些没有首先为每个单元编写一个详细的规格说明而直接跳到编码阶段的开发人员提出的一条普遍的抱怨, 当编码完成以后并且面临代码测试任务的时候,他们就阅读这些代码并找出它实际上做了什么,把他们的测试工作基于已经写好的代码的基础上。当然,他们无法证明任何事情。所有的这些测试工作能够表明的事情就是编译器工作正常。是的,他们也许能够抓住(希望能够)罕见的编译器Bug,但是他们能够做的仅仅是这些。
如果他们首先写好一个详细的规格说明,测试能够以规格说明为基础。代码就能够针对它的规格说明,而不是针对自身进行测试。这样的测试仍然能够抓住编译器的Bug,同时也能找到更多的编码错误,甚至是一些规格说明中的错误。好的规格说明可以使测试的质量更高,所以最后的结论是高质量的测试需要高质量的规格说明。 在实践中会出现这样的情况: 一个开发人员要面对测试一个单元时只给出单元的代码而没有规格说明这样吃力不讨好的任务。你怎样做才会有更多的收获,而不仅仅是发现编译器的Bug?第一步是理解这个单元原本要做什么, --- 不是它实际上做了什么。 比较有效的方法是倒推出一个概要的规格说明。这个过程的主要输入条件是要阅读那些程序代码和注释, 主要针对这个单元, 及调用它和被它调用的相关代码。画出流程图是非常有帮助的,你可以用手工或使用某种工具。 可以组织对这个概要规格说明的走读(Review),以确保对这个单元的说明没有基本的错误, 有了这种最小程度的代码深层说明,就可以用它来设计单元测试了。 我是个很棒的程序员, 我是不是可以不进行单元测试?
在每个开发组织中都至少有一个这样的开发人员,他非常擅长于编程,他们开发的软件总是在第一时间就可以正常运行,因此不需要进行测试。你是否经常听到这样的借口?
在真实世界里,每个人都会犯错误。即使某个开发人员可以抱着这种态度在很少的一些简单的程序中应付过去。 但真正的软件系统是非常复杂的。真正的软件系统不可以寄希望于没有进行广泛的测试和Bug修改过程就可以正常工作。
编码不是一个可以一次性通过的过程。在真实世界中,软件产品必须进行维护以对操作需求的改变作出反应, 并且要对最初的开发工作遗留下来的Bug进行修改。你希望依靠那些原始作者进行修改吗? 这些制造出这些未经测试的原始代码的资深专家们还会继续在其他地方制造这样的代码。在开发人员做出修改后进行可重复的单元测试可以避免产生那些令人不快的负作用。 不管怎样, 集成测试将会抓住所有的Bug
我们已经在前面的讨论中从一个侧面对这个问题进行了部分的阐述。这个论点不成立的原因在于规模越大的代码集成意味着复杂性就越高。如果软件的单元没有事先进行测试,开发人员很可能会花费大量的时间仅仅是为了使软件能够运行,而任何实际的测试方案都无法执行。
一旦软件可以运行了,开发人员又要面对这样的问题: 在考虑软件全局复杂性的前提下对每个单元进行全面的测试。 这是一件非常困难的事情,甚至在创造一种
单元调用的测试条件的时候,要全面的考虑单元的被调用时的各种入口参数。在软件集成阶段,对单元功能全面测试的复杂程度远远的超过独立进行的单元测试过程。 最后的结果是测试将无法达到它所应该有的全面性。一些缺陷将被遗漏,并且很多Bug将被忽略过去。
让我们类比一下,假设我们要清洗一台已经完全装配好的食物加工机器!无论你喷了多少水和清洁剂,一些食物的小碎片还是会粘在机器的死角位置,只有任其腐烂并等待以后再想办法。但我们换个角度想想,如果这台机器是拆开的, 这些死角也许就不存在或者更容易接触到了,并且每一部分都可以毫不费力的进行清洗。
成本效率不高
一个特定的开发组织或软件应用系统的测试水平取决于对那些未发现的Bug的潜在后果的重视程度。这种后果的严重程度可以从一个Bug引起的小小的不便到发生多次的死机的情况。这种后果可能常常会被软件的开发人员所忽视(但是用户可不会这样),这种情况会长期的损害这些向用户提交带有Bug的软件的开发组织的信誉,并且会导致对未来的市场产生负面的影响。相反地,一个可靠的软件系统的良好的声誉将有助于一个开发组织获取未来的市场。
很多研究成果表明,无论什么时候作出修改都要进行完整的回归测试,在生命周期中尽早地对软件产品进行测试将使效率和质量得到最好的保证。Bug发现的越晚,修改它所需的费用就越高,因此从经济角度来看, 应该尽可能早的查找和修改Bug。在修改费用变的过高之前,单元测试是一个在早期抓住Bug的机会。
相比后阶段的测试,单元测试的创建更简单,维护更容易,并且可以更方便的进行重复。从全程的费用来考虑, 相比起那些复杂且旷日持久的集成测试,或是不稳定的软件系统来说,单元测试所需的费用是很低的。
相关图表
这些图表摘自<<实用软件度量>>(Capers Jones,McGraw-Hill 1991),它列出了准备测试,执行测试,和修改缺陷所花费的时间(以一个功能点为基准),这些数据显示单元测试的成本效率大约是集成测试的两倍 系统测试的三倍(参见条形图)。 (术语域测试(Field test)意思是在软件投入使用以后,针对某个领域所作的所有测试活动)
这个图表并不表示开发人员不应该进行后阶段的测试活动,这次测试活动仍然是必须的。它的真正意思是尽可能早的排除尽可能多的Bug可以减少后阶段测试的费用。
其他的一些图表显示高达50%的维护工作量被花在那些总是会有的Bug的修改上面。如果这些Bug在开发阶段被排除掉的话,这些工作量就可以节省下来。当考虑到软件维护费用可能会比最初的开发费用高出数倍的时候,这种潜在的对50%软件维护费用的节省将对整个软件生命周期费用产生重大的影响。
结论
经验表明一个尽责的单元测试方法将会在软件开发的某个阶段发现很多的Bug,并且修改它们的成本也很低。在软件开发的后期阶段,Bug的发现并修改将会变得更加困难,并要消耗大量的时间和开发费用。无论什么时候作出修改都要进行完整的回归测试,在生命周期中尽早地对软件产品进行测试将使效率和质量得到最好的保证。 在提供了经过测试的单元的情况下,系统集成过程将会大大地简化。开发人员可以将精力集中在单元之间的交互作用和全局的功能实现上,而不是陷入充满很多Bug的单元之中不能自拔。
使测试工作的效力发挥到最大化的关键在于选择正确的测试策略,这其中包含了完全的单元测试的概念,以及对测试过程的良好的管理,还有适当地使用象AdaTEST和Cantata这样的工具来支持测试过程。这些活动可以产生这样的结果:在花费更低的开发费用的情况下得到更稳定的软件。更进一步的好处是简化了维护过程并降低了生命周期的费用。有效的单元测试是推行全局质量文化的一部分,而这种质量文化将会为软件开发者带来无限的商机。
单元测试的优点
优点一
它是一种验证行为。
程序中的每一项功能都是测试来验证它的正确性。它为以后的开发提供支缓。就算是开发后期,我们也可以轻松的增加功能或更改程序结构,而不用担心这个过程中会破坏重要的东西。而且它为代码的重构提供了保障。这样,我们就可以更自由的对程序进行改进。
优点二
它是一种设计行为。
编写单元测试将使我们从调用者观察、思考。特别是先写测试(test-first),迫使我们把程序设计成易于调用和可测试的,即迫使我们解除软件中的耦合。
优点三
它是一种编写文档的行为。
单元测试是一种无价的文档,它是展示函数或类如何使用的最佳文档。这份文档是可编译、可运行的,并且它保持最新,永远与代码同步。
优点四
它具有回归性。
自动化的单元测试避免了代码出现回归,编写完成之后,可以随时随地的快速运行测试。
单元测试的范畴
如果要给单元测试定义一个明确的范畴,指出哪些功能是属于单元测试,这似乎很难。但下面讨论的四个问题,基本上可以说明单元测试的范畴,单元测试所要做的工作。
1、 它的行为和我期望的一致吗?
这是单元测试最根本的目的,我们就是用单元测试的代码来证明它所做的就是我们所期望的。
2、 它的行为一直和我期望的一致吗?
编写单元测试,如果只测试代码的一条正确路径,让它正确走一遍,并不算是真正的完成。软件开发是一个项复杂的工程,在测试某段代码的行为是否和你的期望一致时,你需要确认:在任何情况下,这段代码是否都和你的期望一致;譬如参数很可疑、硬盘没有剩余空间、缓冲区溢出、网络掉线的时候。 3、 我可以依赖单元测试吗?
不能依赖的代码是没有多大用处的。既然单元测试是用来保证代码的正确性,那么单元测试也一定要值得依赖。
4、 单元测试说明我的意图了吗?
单元测试能够帮我们充分了解代码的用法,从效果上而言,单元测试就像是能执行的文档,说明了在你用各种条件调用代码时,你所能期望这段代码完成的功能。
JAVA测试工具介绍
分析 JUnit 框架源代码
概述
在测试驱动的开发理念深入人心的今天,JUnit 在测试开发领域的核心地位日渐稳定。不仅 Eclipse 将 JUnit 作为默认的 IDE 集成组件,而且基于 JUnit 的各种测试框架也在业内被广泛应用,并获得了一致好评。目前介绍 JUnit 书籍文章虽然较多,但大多数是针对 JUnit 的具体应用实践,而对于 JUnit 本身的机制原理,只是停留在框架模块的较浅层次。
本文内容完全描述 JUnit 的细致代码实现,在展示代码流程 UML 图的基础上,详细分析 JUnit 的内部实现代码的功能与机制,并在涉及相关设计模式的地方结合代码予以说明。另外,分析过程还涉及 Reflection 等 Java 语言的高级特征。
本文的读者应该对 JUnit 的基本原理及各种设计模式有所了解,主要是面向从事 Java 相关技术的设计、开发与测试的人员。对于 C++,C# 程序员也有很好的借鉴作用。
Junit 简介
JUnit 的概念及用途
JUnit 是由 Erich Gamma 和 Kent Beck 编写的一个开源的单元测试框架。它属于白盒测试,只要将待测类继承 TestCase 类,就可以利用 JUnit 的一系列机制进行便捷的自动测试了。
JUnit 的设计精简,易学易用,但是功能却非常强大,这归因于它内部完善的代码结构。 Erich Gamma 是著名的 GOF 之一,因此 JUnit 中深深渗透了扩展性优良的设计模式思想。 JUnit 提供的 API 既可以让您写出测试结果明确的可重用单元测试用例,也提供了单元测试用例成批运行的功能。在已经实现的框架中,用户可以选择三种方式来显示测试结果,并且显示的方式本身也是可扩展的。
JUnit 基本原理
一个 JUnit 测试包含以下元素:
表 1. 测试用例组成 开发代码部分 待测试类
A
操作步骤:
将 B 通过命令行方式或图形界面选择方式传递给 R,R 自动运行测试,并显示结果。
测试代码部分
通过扩展 TestCase 或者构造
TestSuit 方法 编写测试类 B
测试工具部分
一个测试运行器(TestRunner)R,可以选择图形界
面或文本界面
JUnit 中的设计模式体现
设计模式(Design pattern)是一套被反复使用的、为众人所知的分类编目的代码设计经验总结。使用设计模式是为了可重用和扩展代码,增加代码的逻辑性和可靠性。设计模式的出现使代码的编制真正工程化,成为软件工程的基石。
GoF 的《设计模式》一书首次将设计模式提升到理论高度,并将之规范化。该书提出了 23 种基本设计模式,其后,在可复用面向对象软件的发展过程中,新的设计模式亦不断出现。
软件框架通常定义了应用体系的整体结构类和对象的关系等等设计参数,以便于具体应用实现者能集中精力于应用本身的特定细节。因此,设计模式有助于对框架结构的理解,成熟的框架通常使用了多种设计模式,JUnit 就是其中的优秀代表。设计模式是 JUnit 代码的精髓,没有设计模式,JUnit 代码无法达到在小代码量下的高扩展性。总体上看,有三种设计模式在 JUnit 设计中得到充分体现,分别为 Composite 模式、Command 模式以及 Observer 模式。
回页首
一个简单的 JUnit 程序实例
我们首先用一个完整实例来说明 JUnit 的使用。由于本文的分析对象是 JUnit 本身的实现代码,因此测试类实例的简化无妨。本部分引入《 JUnit in Action 》中一个 HelloWorld 级别的测试实例,下文的整个分析会以该例子为基点,剖析 JUnit 源代码的内部流程。 待测试类如下:
图 1. 待测试代码
该类只有一个 add 方法,即求两个浮点数之和返回。
下面介绍测试代码部分,本文以 JUnit3.8 为实验对象,JUnit4.0 架构类同。笔者对原书中的测试类做了一些修改,添加了一个必然失败的测试方法 testFail,目的是为了演示测试失败时的 JUnit 代码流程。 完整的测试类代码如下:
图 2. 测试类代码
TestCalculator 扩展了 JUnit 的 TestCase 类,其中 testAdd 方法就是对 Calculator.add 方法的测试,它会在测试开始后由 JUnit 框架从类中提取出来运行。在 testAdd 中,Calculator 类被实例化,并输入测试参数 10 和 50,最后用 assertEquals 方法(基类 TestCase 提供)判断测试结果与预期是否相等。无论测试符合
预期或不符合都会在测试工具TestRunner 中体现出来。
实例运行结果:
图 3. 实例运行结果
从运行结果中可见:testAdd 测试通过(未显示),而 testFail 测试失败。图形界面结果如下:
图 4. 测试图形结果
JUnit 源代码分析
JUnit 的完整生命周期分为 3 个阶段:初始化阶段、运行阶段和结果捕捉阶段。
图 5. JUnit 的完整生命周期图(查看大图)
初始化阶段(创建 Testcase 及 TestSuite)
图 6. JUnit 的 main 函数代码
初始化阶段作一些重要的初始化工作,它的入口点在 junit.textui.TestRunner 的 main 方法。该方法首先创建一个 TestRunner 实例 aTestRunner。之后 main 函数中主体工作函数为 TestResult r =
aTestRunner.start(args) 。
它的函数构造体代码如下 :
图 7. junit 的 start(String[]) 函数
我们可以看出,Junit 首先对命令行参数进行解析:参数“ -wait ”(等待模式,测试完毕用户手动返回)、“ -c ”,“ -v ”(版本显示)。 -m 参数用于测试单个方法。这是 JUnit 提供给用户的一个非常轻便灵巧的测试功能,但是在一般情况下,用户会像本文前述那样在类名命令行参数,此时通过语句:
testCase = args[i];
将测试类的全限定名将传给 String 变量 testcase 。 然后通过:
Test suite = getTest(testCase);
将对 testCase 持有的全限定名进行解析,并构造 TestSuite 。
图 8. getTest() 方法函数源代码
TestSuite 的构造分两种情况 ( 如上图 ):
A:用户在测试类中通过声明 Suite() 方法自定义 TestSuite 。 B:JUnit 自动判断并提取测试方法。
JUnit 提供给用户两种构造测试集合的方法,用户既可以自行编码定义结构化的 TestCase 集合,也可以让 JUnit 框架自动创建测试集合,这种设计融合其它功能,让测试的构建、运行、反馈三个过程完全无缝一体化。 情况 A:
图 9. 自定义 TestSuite 流程图
当 suite 方法在 test case 中定义时,JUnit 创建一个显式的 test suite,它利用 Java 语言的
Reflection 机制找出名为 SUITE_METHODNAME 的方法,也即 suite 方法:
suiteMethod = testClass.getMethod(SUITE_METHODNAME, new Class[0]);
Reflection 是 Java 的高级特征之一,借助 Reflection 的 API 能直接在代码中动态获取到类的语言编程层面的信息,如类所包含的所有的成员名、成员属性、方法名以及方法属性,而且还可以通过得到的方法对象,直接调用该方法。 JUnit 源代码频繁使用了 Reflection 机制,不仅充分发挥了 Java 语言在系统编程要求下的超凡能力,也使 JUnit 能在用户自行编写的测试类中游刃有余地分析并提取各种属性及代码,而其它测试框架需要付出极大的复杂性才能得到等价功能。
若 JUnit 无法找到 siute 方法,则抛出异常,流程进入情况 B 代码;若找到,则对用户提供的 suite 方法进行外部特征检验,判断是否为类方法。最后,JUnit 自动调用该方法,构造用户指定的 TestSuite:
test = (Test)suiteMethod.invoke(null, (Object[]) new Class[0]);
情况 B:
图 10. 自动判断并提取 TestSuite 流程图
当 suite 方法未在 test case 中定义时,JUnit 自动分析创建一个 test suite 。代码由 :
return new TestSuite(testClass);
处进入 TestSuite(Class theclass) 方法为 TestSuite 类的构造方法,它能自动分析 theclass 所描述的类的内部有哪些方法需要测试,并加入到新构造的 TestSuite 中。代码如下:
图 11. TestSuite 函数代码
TestSuite 采用了Composite 设计模式。在该模式下,可以将 TestSuite 比作一棵树,树中可以包含子树(其它 TestSuite),也可以包含叶子 (TestCase),以此向下递归,直到底层全部落实到叶子为止。 JUnit 采用 Composite 模式维护测试集合的内部结构,使得所有分散的 TestCase 能够统一集中到一个或若干个 TestSuite 中,同类的 TestCase 在树中占据同等的位置,便于统一运行处理。另外,采用这种结构使测试集合获得了无限的扩充性,不需要重新构造测试集合,就能使新的 TestCase 不断加入到集合中。
在 TestSuite 类的代码中,可以找到:
private Vector fTests = new Vector(10);
此即为内部维护的“子树或树叶”的列表。
红框内的代码完成提取整个类继承体系上的测试方法的提取。循环语句由 Class 类型的实例 theClass 开始,逐级向父类的继承结构追溯,直到顶级 Object 类,并将沿途各级父类中所有合法的 testXXX() 方法都加入到 TestSuite 中。 合法 testXXX 的判断工作由:
addTestMethod(methods[i], names, theClass)
完成,实际上该方法还把判断成功的方法转化为 TestCase 对象,并加入到 TestSuite 中。代码如下图 :
图 12. addTestMethod 函数代码
首先通过 String name= m.getName(); 利用 Refection API 获得 Method 对象 m 的方法名,用于特征判断。然后通过方法
isTestMethod(Method m) 中的
return parameters.length == 0 && name.startsWith(\"test\") && returnType.equals(Void.TYPE);
来判别方法名是不是以字符串“ test ”开始。 而代码:
if (names.contains(name)) return;
用于在逐级追溯过程中,防止不同级别父类中的 testXXX() 方法重复加入 TestSuite 。 对于符合条件的 testXXX() 方法,addTestMethod 方法中用语句:
addTest(createTest(theClass, name));
将 testXXX 方法转化为 TestCase,并加入到 TestSuite 。其中,addTest 方法接受 Test 接口类型的参数,其内部有 countTestCases 方法和 run 方法,该接口被 TestSuite 和 TestCase 同时实现。这是 Command 设计模式精神的体现,
Command 模式将调用操作的对象与如何实现该操作的对象解耦。在运行时,TestCase 或 TestSuite 被当作 Test 命令对象,可以像一般对象那样进行操作和扩展,也可以在实现 Composite 模式时将多个命令复合成一个命令。另外,增加新的命令十分容易,隔离了现有类的影响,今后,也可以与备忘录模式结
合,实现 undo 等高级功能。
加入 TestSuite 的 TestCase 由 createTest(theClass, name) 方法创建,代码如下:
图 13. CreateTest 函数代码(查看大图)
TestSuite 和 TestCase 都有一个fName实例变量,是在其后的测试运行及结果返回阶段中该 Test 的唯一标识,对 TestCase 来说,一般也是要测试的方法名。在 createTest 方法中,测试方法被转化成一个 TestCase 实例,并通过:
((TestCase) test).setName(name); 用该方法名标识 TestCase 。其中,test 对象也是通过 Refection 机制,通过 theClass 构建的:
test = constructor.newInstance(new Object[0]);
注意:theClass 是图 8 中 getTest 方法的 suiteClassName 字符串所构造的 Class 类实例,而后者其实是命令行参数传入的带测试类 Calculator,它继承了 TestCase 方法。因此,theClass 完全具备转化的条件。
至此整个流程的初始化完成。
测试驱动运行阶段(运行所有 TestXXX 型的测试方法)
由图 7 所示 , 我们可以知道初始化完毕,即 testsuit() 创建好后 , 便进入方法 :
doRun(suite, wait);
代码如下 :
图 14. doRun 函数代码
该方法为测试的驱动运行部分,结构如下:
创建 TestResult 实例。
将 junit.textui.TestRunner 的监听器 fPrinter 加入到 result 的监听器列表中。
其中,fPrinter 是 junit.textui.ResultPrinter 类的实例,该类提供了向控制台输出测试结果的一系列功能接口,输出的格式在类中定义。 ResultPrinter 类实现了 TestListener 接口,具体实现了 addError、addFailure、endTest 和 startTest 四个重要的方法,这种设计是 Observer 设计模式的体现,在 addListener 方法的代码中:
public synchronized void addListener(TestListener listener) { fListeners.addElement(listener); }
将 ResultPrinter 对象加入到 TestResult 对象的监听器列表中,因此实质上 TestResult 对象可以有多个监听器显示测试结果。第三部分分析中将会描述对监听器的消息更新。
计时开始。
run(result) 测试运行。 计时结束。
统一输出,包括测试结果和所用时间。
其中最为重要的步骤为 run(result) 方法,代码如下。
图 15. run 函数代码
Junit 通过 for (Enumeration e= tests(); e.hasMoreElements(); ){ …… } 对 TestSuite 中的整个“树结构”递归遍历运行其中的节点和叶子。此处 JUnit 代码颇具说服力地说明了 Composite 模式的效力,run 接口方法的抽象具有重大意义,它实现了客户代码与复杂对象容器结构的解耦,让对象容器自己来实现自身的复杂结构,从而使得客户代码就像处理简单对象一样来处理复杂的对象容器。每次循环得到的节点 test,都同 result 一起传递给 runTest 方法,进行下一步更深入的运行。
图 16. junit.framework.TestResult.run 函数代码
这里变量 P 指向一个实现了 Protectable 接口的匿名类的实例,Protectable 接口只有一个 protect 待实现方法。而 junit.framework.TestResult.runProtected(Test, Protectable) 方法的定义为:
public void runProtected(final Test test, Protectable p) { try { p.protect(); } catch (AssertionFailedError e) { addFailure(test, e); } catch (ThreadDeath e) { // don't catch ThreadDeath by accident throw e; } catch (Throwable e) { addError(test, e); } }
可见 runProtected 方法实际上是调用了刚刚实现的 protect 方法,也就是调用了 test.runBare() 方法。另外,这里的 startTest 和 endTest 方法也是 Observer 设计模式中的两个重要的消息更新方法。 以下分析 junit.framework.TestCase.runBare() 方法:
图 17. junit.framework.TestCase.runBare() 函数代码
在该方法中,最终的测试会传递给一个 runTest 方法执行,注意此处的 runTest 方法是无参的,注意与之前形似的方法区别。该方法中也出现了经典的 setUp 方法和 tearDown 方法,追溯代码可知它们的定义为空。用户可以覆盖两者,进行一些 fixture 的自定义和搭建。 ( 注意:tearDown 放在了 finally{} 中,在测试异常抛出后仍会被执行到,因此它是被保证运行的。 )
主体工作还是在 junit.framework.TestCase.runTest() 方法中 , 代码如下 :
图 18. junit.framework.TestCase.runTest() 函数代码
该方法最根本的原理是:利用在图 13 中设定的 fName,借助 Reflection 机制,从 TestCase 中提取测试方法:
runMethod = getClass().getMethod(fName, (Class[]) null);
为每一个测试方法,创建一个方法对象 runMethod 并调用:
runMethod.invoke(this, (Object[]) new Class[0]);
只有在这里,用户测试方法的代码才开始被运行。
在测试方法运行时,众多的 Assert 方法会根据测试的实际情况,抛出失败异常或者错误。也是在“ runMethod.invoke(this, (Object[]) new Class[0]); ”这里,这些异常或错误往上逐层抛出,或者被某一层次处理,或者处理后再次抛出,依次递推,最终显示给用户。 流程图如下 :
图 19. JUnit 执行测试方法,并在测试结束后将失败和错误信息通知所有 test listener
测试结果捕捉阶段(返回 Fail 或 Error 并显示)
通过以下代码,我们可以看出失败由第一个 catch 子句捕获,并交由 addFailure 方法处理,而错误由第三个 catch 子句捕获,并交由 addError 方法处理。
图 20. 失败处理函数代码
图 21. 失败处理流程图
JUnit 执行测试方法,并在测试结束后将失败和错误信息通知给所有的 test listener 。其中 addFailure、addError、endTest、startTest 是 TestListener 接口的四大方法,而 TestListener 涉及到 Observer 设计模式。
我们尝试看看 addFailure 方法的代码:
图 22. addFailure 方法的代码
此处代码将产生的失败对象加入到了 fFailures,可联系 图 2,此处的结果在程序退出时作为测试总体成功或失败的判断依据。而在 for 循环中,TestResult 对象循环遍历观察者(监听器)列表,通过调用相应的更新方法,更新所有的观察者信息,这部分代码也是整个 Observer 设计模式架构的重要部分。 根据以上描述,JUnit 采用 Observer 设计模式使得 TestResult 与众多测试结果监听器通过接口 TestListenner 达到松耦合,使 JUnit 可以支持不同的使用方式。目标对象(TestResult)不必关心有多少对象对自身注册,它只是根据列表通知所有观察者。因此,TestResult 不用更改自身代码,而轻易地支持了类似于 ResultPrinter 这种监听器的无限扩充。目前,已有文本界面、图形界面和 Eclipse 集成组件三种监听器,用户完全可以开发符合接口的更强大的监听器。 出于安全考虑,cloneListeners() 使用克隆机制取出监听器列表:
private synchronized Vector cloneListeners() { return (Vector)fListeners.clone(); }
TestResult 的 addFailure 进一步调用 ResultPrinter 的 addFailure:
图 23. ResultPrinter 的 addFailure 函数代码
这里并没有将错误信息输出,而只是输出了错误类型:“ F “。错误信息由图 14 中的:
fPrinter.print(result, runTime);
统一输出。
这些设计细节皆可以由 TestRunner 的实现者自己掌握。
总结
鉴于 JUnit 目前在测试领域的显赫地位,以及 JUnit 实现代码本身编写的简洁与艺术性,本文的详尽分析无论对于测试开发的实践运用、基于 JUnit 的高层框架的编写、以及设计模式与 Java 语言高级特征的学习都具有多面的重要意义。
因篇幅问题不能全部显示,请点此查看更多更全内容