活用 XP: (五)测试管理
 

2009-08-20 作者:林星 来源:IBM

 
本文内容包括:
无论从那一点上来看,要保证软件的质量,测试工作是少不了的。而测试往往又是经常被忽略的。对于敏捷方法,精益编程而言,如何保证测试的有效性?如何减小测试的成本?是测试中首要考虑的两个问题。

测试过程

要做好测试可不是一件容易的事情。测试工作和软件开发密切相关,却又自成体系。测试并不是一个单独的阶段或活动,测试本身就是一个过程,具有自己的生命周期,从测试计划开始,到测试用例的制定,测试的结构设计,测试代码的编写。测试的生命周期和软件开发生命周期拧在一起,相互影响。当然,我们还是那句老话,罗马不是一天建成的。对我们来说,还是从简单的开始。

在我们谈及精益编程理论的时候,曾经讨论过全面质量管理的概念:生产过程的每一个环节都需要为质量负责,而不是把质量问题留给最后的质检员。这对于软件开发有着很好的借鉴。软件开发中最头疼的就是质量问题,因为人的行为过于不确定了。在经过漫长的软件开发周期之后,软件渐渐成型,但是缺陷也慢慢增多,试图在最后的关头解决长期积累的问题并不是一个好的做法。软件开发到了这种时候,发现和修改缺陷需要付出很大的代价。

我们说,最后关头的测试并不是不重要,但是, 软件质量问题应该在整个软件过程中予以重视。

测试的最小单位

测试问题的很重要的思路在于测试的管理上,如何管理一个项目中所有的测试,以及它们相关的文档,相关的代码,如何定义测试人员的职责,如何协调测试人员和开发人员之间的关系?

XP的测试优先和自动化测试实践是一个非常优秀的实践,我们也曾不止一次的提到该实践,但是对XP强调的单元测试,很多人都有一些误解:

  • XP中提供的例子过于简单,无法和生产环境相结合。XP中的单元测试只是为测试提供了一个具体的操作思路,但是它并不能取代其它的测试原理。如何进行测试,如何组织测试,如何管理测试,这些都要由不同的软件组织自己来进行定义。
  • 测试代码本身不能够适应变化。黑盒测试的理想状况是外部行为不因为内部行为的改变而改变。当需求或是设计发生变化的时候,一段代码的内部行为需要改变,但是外部行为却不需要变化,这样,针对外部接口进行的单元测试同样不需要改变,但是这个规则一旦被违反,我们就需要付出同时改变测试代码的双重代价了。因此,测试代码的设计本身就是很讲究的。

单元测试(有时候也称为类测试)是代码级别的测试,是测试的最小单位。XP非常看重这个最小单位。我们观察测试优先框架XUnit,发现它使用组合模式将大量的最小单位的单元测试组织起来,形成完整的测试网。所以,XP的思路非常的简单:最小单位的测试能够做好,全系统的测试就能够做好。这个思路未必就正确,但是注重最小单位的测试的思路是绝对正确的。每个部件都正确,最后的软件未必正确,但任何一个部件不正确,最后的软件一定是不正确的。

测试优先

测试优先和单元测试在XP中属于同一个实践,但是它们仍然是由区别的。测试优先强调行为,在写代码之前写测试,单元测试主要指的是测试的范围或级别。我们说,测试优先实践真正关心的,并不是测试是否要先于代码,关键在于你是否能够编写出适合于测试的代码,是否能够从测试的角度来考虑设计,考虑代码。

从另外的一个角度上说,坚持测试优先的实践,可以让你从一个外部接口和客户端的角度来考虑问题,这样可以保证软件系统各个模块之间能够较好的连接在一起,而开发人员的思考方式,也会逐步地从单纯的考虑实现,转移到对软件结构的思考上来。这才是测试优先的真正思路。而坚持先写测试,只不过是帮助你转变思维习惯的一种措施而已。对于一些优秀的程序员来说,只要能达成目的,是否测试优先,倒并不是最关键的了。

其实做测试是一件很难的事情,因为很多时候,我们不能够完全的模拟出测试环境,或者是完全模拟出测试环境的代价太高。软件开发总是在一个固定的时间和成本的前提下进行,因此我们必须尽可能用小的成本来达成我们的关键目标。很多关于测试的书中都提到诸如磁盘出错之类的错误是很难进行测试的,但实际上,还有很多很多的内容是难以进行测试的。例如,一个业务逻辑,它使用到了14个业务实体和其它的一些配合的类,如何测试它?使用Mock Object方法,建立测试Fixture的代价将会很高,此外,如果实体类是可以控制的(例如,该实体类可以使用程序来初始化数据,而不是从数据库中获取数据),这个测试的成本还可以接受,如果不是(例如,第三方提供的技术),这个成本将会更高。类似的情况还有很多,但是为什么会出现这些问题呢?其中一个很大的原因就是我们并没有真正的把测试作为软件开发的一个重要的组成部分,

坚持测试优先的思考方式,可以大幅度的降低测试成本。现代的软件开发往往都依赖于特定的中间件或是开发平台,如果这些第三方产品没有提供一个强大的测试机制的话,要对最终的产品进行全面的测试往往是很难的。例如,在J2EE提供的Jsp/Serverlet环境,模拟Http的输入和输出是一件很难的事情。如果在软件设计阶段不考虑测试,那么最后的测试将会是寸步难行的。但是实际上,如果在软件设计时考虑到测试的困难程度,并将业务代码和环境控制代码区分开发,使之彼此之间没有过大的耦合。这样,测试工作就可以针对独立的业务代码进行,而这个成本就会低很多。

public class UserLog
{
	public Service()
		{
		//难以进行测试的代码
		//需要测试的业务代码
	}
}

注意到,在上面的示例类中,提供服务的代码分为两个部分,一部分是框架提供的、难以进行测试模拟的代码,这类的代码有很多,例如对HttpRequest的处理,模拟http的数据是比较复杂的。这就增大了测试的难度。而这部分的处理往往是平台提供的功能,不需要进行测试。第二部分是关键的业务代码,是测试的核心。那么,一方面构建测试环境难度较大,另一方面又需要对业务代码进行测试。因此我们自然就想到将待测的业务代码分离出来:

public class UserLog
{
   public static void Write(String name)
   {
	//写入用户信息;
   }
}
public class UserLogAdapter
{
public Service()
    {
	//难以进行测试的代码
	UserLog.Write(Name);
   }
}

这样,测试就可以针对UserLog进行,由于不需要复杂的测试环境,对UserLog进行测试的成本是很低的。在J2EE核心模式一书中,提到了一种向业务层隐藏特定表示层细节的重构思路:

虽然,这种重构方法的出发思路是避免界面层次的细节暴露给业务层,但是从另一个角度来说,也提高了业务层组件的可测试性。毕竟,构建一个用户信息,要比构建一个HttpServeltRequest要容易的多。

因此,最合理的引入测试的阶段是在需求阶段。需求阶段的测试工作的重点是如何定义测试计划,如何定义接受测试并获得客户的认可,在需求阶段结束的时候,必须保证所有的需求都是可测试的,都拥有测试用例,需求阶段另一个重要的测试任务是准备构建测试沙盒,建立一个测试环境,以及这个软件项目所需要的测试数据;在设计阶段,测试工作的重点则在于如何定义各个模块的详细测试内容,最好的方式是实现测试代码,并构建测试框架,对于一些比较复杂的项目,甚至还需要编写一些测试工具。实践中我们发现,在XUnit的基础上扩展出一个测试框架是一种简单但又实用的方法。XUnit的重点是对自动化测试提供了一个通用的框架,捕获异常,记录错误和失败,并利用组合模式对Test Case和Test Suite进行管理。实际上,还有很多工作是可以在XUnit框架上继续开展的,例如,软件开发中是不是存在较为通用的测试用例?这样,你就可以定义一些抽象的测试用例,并以此作为测试框架的基础。再比如,我们希望每天晚上在进行日集成的时候,测试结果能够通过短信直接发送到负责人的手机上,那么我们可以在框架中嵌入这部分的功能。这些都属于对测试框架的积累。对一个软件组织来说,很有必要花费时间对测试框架进行积累。这可以简化测试的工作量,并提升软件的质量。

测试过程

我们一开始说,测试有其自己的过程,虽然XP并没有花费太多的笔墨来描述自己的测试过程,但经过细心的观察,我们可以发现,在XP中同样存在着一个测试过程:

这个过程是从用户故事(或者是我们在上一章中推荐的用例)开始的,用户故事不但为版本计划提供了需求,而且为接受测试提供了测试场景。而对于客户参与的接受测试来说,它为每一次的迭代提供了反馈,包括bug的反馈和下次迭代信息的反馈。只有客户认可了接受测试,软件才能够发布小版本。这是XP过程最高层次的测试过程。

在上文中,我们提到引入测试最好的时机是在需求分析阶段。因为测试生命周期的起源活动-测试计划和测试用例都需要需求的支持。我们再参考RUP的过程:

我们看到,RUP建议在先启阶段就开始测试活动。在开发过程的前期就进行测试活动,其目的是为了提高软件的可测试性。软件设计如果没能够考虑软件的可测试性,那么测试的成本就会升高,软件质量随之下降。有时候,单元测试或是组件测试是很难进行的。因此,我们需要专门针对类或组件的可测试性进行测试。例如,对于一个实现企业流程的组件,之间涉及到大量的状态、事件、分支选择等等因素。对这样的组件进行组件测试的代价是非常高的。如果能够在组件设计的时候,能够考虑到测试性,例如,将组件拆分为粒度更小的子组件,或是在组件中内嵌供测试使用的方法,能够直接操纵组件的状态。在设计时充分考虑可测试性,是降低测试成本的关键。而设计测试的源泉,正是先启阶段中对需求的分析。对流程组件测试的依据,正是源于项目涉众对流程的需求。

测试的一些实践问题

严格按照先维护测试,再维护代码的顺序要实现变更。在实践中,测试优先常常发生的一个问题是,设计变更影响到测试代码的时候,开发人员往往会绕过测试代码,直接修改代码。

在刚刚接触测试优先思路的时候,我严格按照先写测试的做法编写代码,但是当代码需要修改时,有时候只是一些非常小的修改,这时候我仍然保持原有的习惯,直接对代码进行了修改,在完成代码的修改之后,我突然意识到测试代码需要修改,于是我又修改了代码,由于只是一个小修改,我认为没有必要再运行测试了。这件事情很快被我遗忘了,但隐患就此埋下。到了两天后的集成测试时,测试程序捕捉到了这段代码的错误,经过调试,发现当时认为简单的修改忽略了一种极端的情况。定位错误,调试代码,并通过测试的时间远远超过了当初贪图省事节省的时间。所幸的是,代码在下一个检查点(集成测试)被发现出来。

完善测试网。在我学习并实践测试优先的时候,我所处的团队正处于项目的中期,已经有大量的没有实现测试的代码被创建出来,当时我采取的思路是,新编写的代码必须遵循新的测试方法,旧有的代码保持现状。这样做可以节省一定的成本,但是很快我们发现,投入力量把现有的代码加上测试是绝对值得的。加上测试的代码能够迅速回应变化,仅仅这一点,就值得我们重建测试网。此外,由于需要构建测试,我们还发现了原有代码中一些接口定义不合理或是不规范的地方。

而在另一些一开始就采用测试优先思路的项目中,往往遇到的问题是,随着项目的进展,后期的测试代码越来越优秀。这时候,我们需不需要对原有的测试代码进行改进呢?答案是肯定的,你一定会从中获益的,对于自动化测试来说,修改测试代码并重新运行测试的代价并没有你想象中的那么大。

完美的测试是不存在的,但是测试可以越来越完美。我们在文章一开始就提到了全面质量管理(TQM)的思路,TQM认为,产品生产的每个过程都会对最后的产品质量产生影响,每个人都需要对质量负责。对于软件开发也是一样,开发过程的任何步骤都会对软件质量产生影响,要提高软件质量,并不是加强测试力量就能够做到的,需要在整个过程中保证软件的质量。构建测试并不断改进测试的行为贯穿于整个开发过程,为质量提供了基础的保证。

自动化测试

自动化测试是XP测试活动的另一个优秀思路。在我们讨论迭代的时候,曾经简单讨论过回归和自动化测试。只有测试实现了自动化,回归测试才能实现,重构才能够贯彻,而迭代也才能够进行。所以XP一直强调它的实践就像是拼图,只有全部实现才能够完全展现其魅力。单单从这个角度,我们就能够体会到这句话的含义了。

对于一个自动化测试系统而言,有几个部分是特别重要的:

数据准备:对于一个简单的TestCase而言,数据准备的工作在Setup中就完成了处理(参见JUnit),但是现实开发过程中的测试数据通常比较复杂,因此有必要准备单独的数据提供类。对于一个完整的企业应用系统而言,往往包含数千的测试用例,而相应的测试数据量也极为庞大,这时候,我们还需要有专门的机制来生成和管理测试数据。

测试数据和特定的项目有关,因此不存在一个标准的建立测试数据的规范。所以我们在XUnit框架中看到,框架仅仅只是把建立数据这个活动给抽象出来,并未做额外的处理。但对于自动化测试而言,为各个单元测试建立独立的测试数据是很有必要的。测试数据的独立性是测试用例独立性的前提。测试数据大部分采用脚本的形式建立,包括输入数据和输出数据两个部分。例如,对于一个业务实体,就可以使用一个脚本来对它的属性赋值。脚本文件的形式有很多,例如配置文件、数据库数据脚本等。

验证:验证是将待测试的方法返回的结果值和预定的结果值进行比较,以判断该方法是否成功执行。结果值总是和输入值相匹配,因此,我们经常将结果值和输入值放在同样的脚本中处理。比较通用的验证方式是采用断言机制,此外,还包括错误记录、浏览测试结果,产生测试报告等功能。

桩:桩(Stub)是自动化测试中常用的一种技巧。在OO设计中,类和类之间往往都有关系,我们如何对一个依赖于其它类的类进行单独的测试呢?很多的软件设计中都存在难以模拟错误的现象。例如对磁盘出错、网络协议出错的情况就难以模拟。测试桩的思路就是为了解决这些问题,一个桩并不是真正的对象,但是能够提供待测对象感兴趣的数据或状态,这样,待测试对象就能够顺利的使用依赖对象,或是模拟事件。


火龙果软件/UML软件工程组织致力于提高您的软件工程实践能力,我们不断地吸取业界的宝贵经验,向您提供经过数百家企业验证的有效的工程技术实践经验,同时关注最新的理论进展,帮助您“领跑您所在行业的软件世界”。
资源网站: UML软件工程组织