单元测试系列之1:开发测试的那些事儿
引述:程序测试对保障应用程序正确性而言,其重要性怎么样强调都不为过。JUnit是必须事先掌握的测试框架,大多数测试框架和测试工具都在此基础上扩展而来,Spring对测试所提供的帮助类也是在JUnit的基础上进行演化的。直接使用JUnit测试基于Spring的应用存在诸多不便,不可避免地需要将大量的精力用于应付测试夹具准备、测试现场恢复、访问测试数据操作结果等边缘性的工作中。Mockito、Unitils、Dbunit等框架的出现,这些问题有了很好的解决方案,特别是Unitils结合Dbunit对测试DAO层提供了强大的支持,大大提高了编写测试用例的效率和质量。
这些文章摘自于我的《Spring 3.x企业应用开发实战》的第16章,我将通过连载的方式,陆续在此发出。欢迎大家讨论。
一种商品只有通过严格检测才能投放市场,一架飞机只有经过严格测试才能上天,同样的,一款软件只有对其各项功能进行严格测试后才能交付使用。不管一个软件多么复杂,它都是由相互关联的方法和类组成的,每个方法和类都可能隐藏着Bug。只有防微杜渐,小步前进才可以保证软件大厦的稳固性,否则隐藏在类中的Bug随时都有可能像打开的潘多拉魔盒一样让程序陷于崩溃之中,难以驾驭。
按照软件工程思想,软件测试可以分为单元测试、集成测试、功能测试、系统测试等。功能测试和系统测试一般来说是测试人员的职责,但单元测试和集成测试则必须由开发人员保证。
为什么需要单元测试
软件开发的标准过程包括以下几个阶段:『需求分析阶段』→『设计阶段』→『实现阶段』→『测试阶段』→『发布』。其中测试阶段通过人工或者自动手段来运行或测试某个系统的过程,其目的在于检验它是否满足规定的需求或弄清预期结果与实际结果之间的差别。测试过程按4个步骤进行,即单元测试、集成测试、系统测试及发版测试。其中功能测试主要检查已实现的软件是否满足了需求规格说明中确定了的各种需求,以及软件功能是否完全、正确。系统测试主要对已经过确认的软件纳入实际运行环境中,与其他系统成份组合在一起进行测试。单元测试、集成测试由开发人员进行,是我们关注的重点,下文对两者进行详细说明。
单元测试
单元测试是开发者编写的一小段代码,用于检验目标代码的一个很小的、很明确的功能是否正确。通常而言,一个单元测试用于判断某个特定条件或特定场景下某个特定函数的行为。例如,用户可能把一个很大的值放入一个有序List中,然后确认该值出现在List 的尾部。或者,用户可能会从字符串中删除匹配某种模式的字符,然后确认字符串确实不再包含这些字符了。
单元测试是由程序员自己来完成,最终受益的也是程序员自己。可以这么说,程序员有责任编写功能代码,同时也就有责任为自己的代码编写单元测试。执行单元测试,就是为了证明这段代码的行为和我们期望的一致。
在一般情况下,一个功能模块往往会调用其他功能模块完成某项功能,如业务层的业务类可能会调用多个DAO完成某项业务。对某个功能模块进行单元测试时,我们希望屏蔽对外在功能模块的依赖,以便将焦点放在目标功能模块的测试上。这时模拟对象将是最有力的工具,它根据外在模块的接口模拟特定操作行为,这样单元测试就可以在假设关联模块正确工作的情况下验证本模块逻辑的正确性了。
集成测试
单元测试和开发工作是并驾齐驱的工作,甚至是前置性的工作。除了一些显而易见的功能外,大部分功能(类的方法)都必须进行单元测试,通过单元测试可以保障功能模块的正确性。而集成测试则是在功能模块开发完成后,为验证功能模块之间匹配调用的正确性而进行的测试。在单元测试时,往往需要通过模拟对象屏蔽外在模块的依赖,而集成测试恰恰是要验证模块之间集成后的正确性。
举个例子,当对UserService这个业务层的类进行单元测试时,可以通过创建UserDao、LoginLogDao模拟对象,在假设DAO类正确工作的情况下对UserService进行测试。而对UserService进行集成测试时,则应该注入真实的UserDao和LoginLogDao进行测试。
所以一般来讲,集成测试面向的层面要更高一些,一般对业务层和Web层进行集成测试,单元测试则面向一些功能单一的类(如字符串格式化工具类、数据计算类)。当然,我们可能对某一个类既进行单元测试又进行集成测试,如UserService在模块开发期间进行单元测试,而在关联的DAO类开发完成后,再进行集成测试。
测试好处
在编写代码的过程中,一定会反复调试保证它能够编译通过。但代码通过编译,只是说明了它的语法正确。无法保证它的语义也一定正确,没有任何人可以轻易承诺这段代码的行为一定是正确的。幸运的是,单元测试会为我们的承诺做保证。编写单元测试就是用来验证这段代码的行为是否与我们期望的一致。有了单元测试,我们可以自信地交付自己的代码,减少后顾之忧。总之进行单元测试,会带来以下好处:
软件质量最简单、最有效的保证;是目标代码最清晰、最有效的文档;可以优化目标代码的设计;是代码重构的保障;是回归测试和持续集成的基石。
单元测试之误解
认为单元测试影响开发进度,一是借口,拒绝对单元测试相关知识进行学习(单元测试,代码重构,版本管理是开发人员的必备);二是单元测试是“先苦后甜”,刚开始搭建环境,引入额外工作,看似“影响进度”,但长远来看,由于程序质量提升、代码返工减少、后期维护工作量缩小、项目风险降低,从而在整体上赢了回来。
误解1:影响开发进度
一旦编码完成,开发人员总是会迫切希望进行软件的集成工作,这样他们就能够看到系统实际运行效果。这在外表上看来好像加快进度,而像单元测试这样的活动被看作是影响进度原因之一,推迟了对整个系统进行集成测试的时间。
在实践中,这种开发步骤常常会导致这样的结果:软件甚至无法运行。更进一步的结果是大量的时间将被花费在跟踪那些包含在独立单元里的简单Bug上面,在个别情况下,这些Bug也许是琐碎和微不足道的,但是总的来说,它们会导致推迟软件产品交付的时间,而且也无法确保它能够可靠运行。
在实际工作中,进行了完整计划的单元测试和编写实际的代码所花费的精力大致上是相同的。一旦完成了这些单元测试工作,很多Bug将被纠正,开发人员能够进行更高效的系统集成工作。这才是真实意义上的进步,所以说完整计划下的单元测试是对时间的更高效利用。
误解2:增加开发成本
如果不重视程序中那些未被发现的Bug可能带来的后果。这种后果的严重程度可以从一个Bug引起的用户使用不便到系统崩溃。这种后果可能常常会被软件的开发人员所忽视,这种情况会长期损害软件开发商的声誉,并且会对未来的市场产生负面影响。相反地,一个可靠的软件系统的良好的声誉将有助于一个软件开发商获取未来的市场。
很多研究成果表明,无论什么时候作出修改都要进行完整的回归测试,在生命周期中尽早地对软件产品进行测试将使效率和质量得到最好的保证。Bug发现得越晚,修改它所需的费用就越高,因此从经济角度来看,应该尽可能早地查找和修改Bug。而单元测试就是一个在早期抓住Bug的机会。
相比后阶段的测试,单元测试的创建更简单,且维护更容易,同时可以更方便地进行重构。从全程的费用来考虑,相比起那些复杂且旷日持久的集成测试,或是不稳定的软件系统来说,单元测试所需的费用是很低的。
误解3:我是个编程高手,无须进行单元测试
在每个开发团队中都至少有一个这样的开发人员,他非常擅长于编程,他开发的软件总是在第一时间就可以正常运行,因此不需要进行测试。你是否经常听到这样的借口?在现实世界里,每个人都会犯错误。即使某个开发人员可以抱着这种态度在很少的一些简单程序中应付过去,但真正的软件系统是非常复杂的。真正的软件系统不可以寄希望于没有进行广泛的测试和Bug修改过程就可以正常工作。编码不是一个可以一次性通过的过程。在现实世界中,软件产品必须进行维护以对操作需求的改变作出及时响应,并且要对最初的开发工作遗留下来的Bug进行修改。你希望依靠那些原始作者进行修改吗?这些制造出未经测试的代码的资深工程师们还会继续在其他地方制造这样的代码。在开发人员做出修改后进行可重复的单元测试,可以避免产生那些令人不快的负作用。
误解4:测试人员会测出所有Bug
一旦软件可以运行了,开发人员又要面对这样的问题:在考虑软件全局复杂性的前提下对每个单元进行全面的测试。这是一件非常困难的事情,甚至在创造一种单元调用的测试条件时,要全面考虑单元被调用时的各种入口参数。在软件集成阶段,对单元功能全面测试的复杂程度远远超过独立进行的单元测试过程。
最后的结果是测试将无法达到它所应该有的全面性。一些缺陷将被遗漏,并且很多Bug将被忽略过去。让我们类比一下,假设我们要清理一台电脑主机中的灰尘,如果没有把主机中各个部件(显卡、内存等)拆开,无论你用什么工具,一些灰尘还会隐藏在主机的某些角落无法清理。但我们换个角度想想,如果把主机每个部件一一拆开,这些死角中的灰尘就容易被发现和接触到了,并且每一部件的灰尘都可以毫不费力地进行清理。
单元测试之症结
测试在软件开发过程中一直都是备受关注的,测试不仅仅局限于软件开发中的一个阶段,它已经开始贯穿于整个软件开发过程。大家普遍认识到,如果测试能在开发阶段进行有效执行,程序的Bug就会被及早发现,其质量就能得到有效的保证,从而减少软件开发总成本。但是,相对于测试这个词的流行程度而言,大家对单元测试的认知普遍存在一些偏差,特别是一些程序员很容易陷入一些误区,导致了测试并没有在他们所在的开发项目中起到有效的作用。下面对一些比较具有代表性的症结进行剖析,并对于测试背后所蕴含的一些设计思考进行阐述,希望能够起到抛砖引玉的作用。
症结1:使用System.out.print跟踪和运行程序就够了
这个症结可以说是程序员的一种通病,认为使用System.out.print就可以确保编写代码的正确性,无须编写测试用例,他们觉得编写用例是在“浪费时间”。使用System.out.print输出结果,以肉眼观察这种刀耕火种的方式进行测试,不仅效率低下,而且容易出错。
症结2:使用System.out.print跟踪和运行程序就够了
在编码的时候,确实存在一些看起来比较难测试的代码,但是并非无法测试。并且在大多数情况下,还是由于被测试的代码在设计时没有考虑到可测试性的问题。编写程序不仅与第三方一些框架耦合过紧,而且过于依赖其运行环境,从而表现出被测试的代码本身很难测试。
症结3:测试代码可以随意写
编写测试代码时抱着一种随意的态度,没有弄清测试的真正意图。编写测试代码只是为了应付任务而已,先编写程序实现代码,然后才去编写一些单元测试。表现出来的结果是测试过于简单,只走形式和花架,将大量Bug传递给系统测试人员。
症结4:不关心测试环境
手工搭建测试环境,测试数据,造成维护困难,占据了大量时间,严重影响效率。对测试产 生的“垃圾”不清除,不处理。造成测试不能重复进行,导致脆弱的测试,需要维护好测试环境,做一个“低碳环保”的测试者。
症结5:测试环境依赖性大
测试环境依赖性大,没有有效隔离测试目标及其依赖环境,一是使测试不聚焦;二是常因依赖环境的影响造成失败;三是因依赖环境太厚重从而降低测试的效率(如依赖数据库或依赖网络资源,如邮件系统、Web服务)。
单元测试基本概念
被测系统:SUT(System Under Test)
被测系统(System under test,SUT)表示正在被测试的系统,目的是测试系统能否正确操作。这一词语常用于软件测试中。软件系统测试的一个特例是对应用软件的测试,称为被测应用程序(application under test,AUT)。
SUT也表明软件已经到了成熟期,因为系统测试在测试周期中是集成测试的后一阶段。
测试替身:Test Double
在单元测试时,使用Test Double减少对被测对象的依赖,使得测试更加单一。同时,让测试案例执行的时间更短,运行更加稳定,同时能对SUT内部的输入输出进行验证,让测试更加彻底深入。但是,Test Double也不是万能的,Test Double不能被过度使用,因为实际交付的产品是使用实际对象的,过度使用Test Double会让测试变得越来越脱离实际。
要理解测试替身,需要了解一下Dummy Objects、Test Stub、Test Spy、Fake Object
这几个概念,下面我们对这些概念分别进行说明。
Dummy Objects
Dummy Objects泛指在测试中必须传入的对象,而传入的这些对象实际上并不会产生任何作用,仅仅是为了能够调用被测对象而必须传入的一个东西。
Test Stub
测试桩是用来接受SUT内部的间接输入(indirect inputs),并返回特定的值给SUT。可以理解Test Stub是在SUT内部打的一个桩,可以按照我们的要求返回特定的内容给SUT,Test Stub的交互完全在SUT内部,因此,它不会返回内容给测试案例,也不会对SUT内部的输入进行验证。
Test Spy
Test Spy像一个间谍,安插在了SUT内部,专门负责将SUT内部的间接输出(indirect outputs)传到外部。它的特点是将内部的间接输出返回给测试案例,由测试案例进行验证,Test Spy只负责获取内部情报,并把情报发出去,不负责验证情报的正确性。
Mock Object
Mock Object和Test Spy有类似的地方,它也是安插在SUT内部,获取到SUT内部的间接输出(indirect outputs),不同的是,Mock Object还负责对情报(intelligence)进行验证,总部(外部的测试案例)信任Mock Object的验证结果。
Fake Object
经常,我们会把Fake Object和Test Stub搞混,因为它们都和外部没有交互,对内部的输入输出也不进行验证。不同的是,Fake Object并不关注SUT内部的间接输入(indirect inputs)或间接输出(indirect outputs),它仅仅是用来替代一个实际的对象,并且拥有几乎和实际对象一样的功能,保证SUT能够正常工作。实际对象过分依赖外部环境,Fake Object可以减少这样的依赖。
测试夹具:Test Fixture
所谓测试夹具(Fixture),就是测试运行程序(test runner)会在测试方法之前自动初始化、回收资源的工作。JUnit4之前是通过setUp、TearDown方法完成。在JUnit4中,仍然可以在每个测试方法运行之前初始化字段和配置环境,当然也是通过注解完成。在JUnit4中,通过@Befroe替代setUp方法;@After替代tearDown方法。在一个测试类中,甚至可以使用多个@Before来注解多个方法,这些方法都是在每个测试之前运行。说明一点,@Before是在每个测试方法运行前均初始化一次,同理@Ater是在每个测试方法运行完毕后均执行一次。也就是说,经这两个注解的初始化和注销,可以保证各个测试之间的独立性而互不干扰,它的缺点是效率低。另外,不需要在超类中显式调用初始化和清除方法,只要它们不被覆盖,测试运行程序将根据需要自动调用这些方法。超类中的@Before方法在子类的@Before方法之前调用(与构造函数调用顺序一致),@After方法是子类在超类之前运行。
一个测试用例可以包含若干个打上@Test注解的测试方法,测试用例测试一个或多个类API接口的正确性,当然在调用类API时,需要事先创建这个类的对象及一些关联的对象,这组对象就称为测试夹具(Fixture),相当于测试用例的“工作对象”。
前面讲过,一个测试用例类可以包含多个打上@Test注解的测试方法,在运行时,每个测试方法都对应一个测试用例类的实例。当然,用户可以在具体的测试方法里声明并实例化业务类的实例,在测试完成后销毁它们。但是,这么一来就要在每个测试方法中都重复这些代码,因为TestCase实例依照以下步骤运行。
创建测试用例的实例。 使用注解@Before注解修饰用于初始化夹具的方法。 使用注解@After注解修饰用于注销夹具的方法。 保证这两种方法都使用 public void 修饰,而且不能带有任何参数。
TestCase实例运行过程如下图所示:
之所以每个测试方法都需要按以上流程运行,是为了防止测试方法相互之间的影响,因为在同一个测试用例类中不同测试方法可能会使用到相同的测试夹具,前一个测试方法对测试夹具的更改会影响后一个测试方法的现场。而通过如上的运行步骤后,因为每个测试方法运行前都重建运行环境,所以测试方法相互之间就不会有影响了。
可是,这种夹具设置方式还是引来了批评,因为它效率低下,特别是在设置 Fixture 非常耗时的情况下(例如设置数据库链接)。而且对于不会发生变化的测试环境或者测试数据来说,是不会影响到测试方法的执行结果的,也就没有必要针对每一个测试方法重新设置一次夹具。因此在 JUnit 4 中引入了类级别的夹具设置方法,编写规范说明如下。
创建测试用例的实例。 使用注解BeforeClass 修饰用于初始化夹具的方法。 使用注解AfterClass 修饰用于注销夹具的方法。 保证这两种方法都使用 public static void 修饰,而且不能带有任何参数。
类级别的夹具仅会在测试类中所有测试方法执行之前执行初始化,并在全部测试方法测试完毕之后执行注销方法,如图16-2所示。
测试用例:Test Case
有了测试夹具,就可以开始编写测试用例的测试方法了。当然也可以不需要测试夹具而直接编写测试用例方法。
在JUnit 3中,测试方法都必须以test为前缀,且必须是public void的,JUnit 4之后,就没有这个限制,只要在每个测试方法标注@Test注解,方法签名可以是任意取名。
可以在一个测试用例中添加多个测试方法,运行器为每个方法生成一个测试用例实例并分别运行。
测试套件:Test Suite
如果每次只能运行一个测试用例,那么又陷入了传统测试(使用main()方法进行测试)的窘境:手工去运行一个个测试用例,这是非常烦琐和低效的,测试套件专门为解决这一问题而来。它通过TestSuite对象将多个测试用例组装成一个测试套件,则测试套件批量运行。需要特别指出的是,可以把一个测试套件整个添加到另一个测试套件中,就像小筐装进大筐里变成一个筐一样。
JUnit4中最显著的特性是没有套件(套件机制用于将测试从逻辑上分组并将这这些测试作为一个单元测试来运行)。为了替代老版本的套件测试,套件被两个新注解代替:@RunWith、@SuteClasses。通过@RunWith指定一个特殊的运行器,即Suite.class套件运行器,并通过@SuiteClasses注解,将需要进行测试的类列表作为参数传入。
创建步骤说明如下:
创建一个空类作为测试套件的入口(这个空类必须使用public修饰符,而且存在无参构造函数)。 将@RunWith、@SuiteClasses注释修饰这个空类。 把Suite.class作为参数传入@RunWith注释,以提示JUnit将此类指定为运行器。 将需要测试的类组成数组作为@SuiteClasses的参数。
断言:Assertions
断言(assertion)是测试框架里面的若干个方法,用来判断某个语句的结果是否为真或判断是否与预期相符。比如assertTrue这一方法就是用来判定一条语句或一个表达式的结果是否为真,如果条件为假,那么该断言就会执行失败。
在JUnit 4中一个测试类并不继承自TestCase(在JUnit 3.8中,这个类中定义了assertEquals()方法),所以你必须使用前缀语法(举例来说,Assert.assertEquals())或者静态地导入Assert类。这样我们就可以完全像以前一样使用assertEquals方法。
由于JDK 5.0自动装箱机制的出现,原先的12个assertEquals方法全部去掉了。例如,原先JUnit 3.8中的assertEquals(long,long)方法在JUnit 4中要使用assertEquals(Object,Object),对于assertEquals(byte,byte)、assertEquals(int,int)等也是这样。
在JUnit 4中,新集成了一个assert关键字。你可以像使用assertEquals方法一样来使用它,因为它们都抛出相同的异常(java.lang.AssertionError)。JUnit 3.8的assertEquals将抛出一个junit.framework.AssertionFailedError。注意,当使用assert时,你必须指定Java的"-ea"参数,否则断言将被忽略。
1 楼 mojunbin 2012-05-08 呵呵,虽然买了有书.但是感觉电子文档还是比看得比较便捷..顺便说下,国内关于Spring的书籍,最好的应该就是博主这本.个人认为..呵呵. 2 楼 mojunbin 2012-05-08 有个地方和书本有点不同.
症结2:使用System.out.print跟踪和运行程序就够了--->存在太多无法测试的东西(书本) 3 楼 huang_yong 2012-08-08 建议补充一些实际的例子,有助于讲解。