2.2 一个简单的单元测试框架

为编写软件而做的所有工作都是为了满足客户需求。[1]确定这些需求非常困难,它们每天都可能在变化;软件开发人员可能在每周召开的例行项目会议中发现,自己花费一周时间所做的工作并不是用户真正想要的。

如果得不到一个可供参照的系统,然后在它的基础上提出改进意见,人们无法清晰地说明软件需求。最好应该确定一个小的系统,设计、编写、测试这个小系统。在提出改进意见之后,再重新完成它。以迭代方式开发程序的能力是面向对象方法的最大优势之一,但是这需要有才干的程序员,这些程序员应该能够精心制作扩展力非常强的代码。修改现有程序是困难的。

另外一个修改程序的动力来自程序设计人员自己。技艺超群的程序员频繁地改进代码的设计。其实,那些软件行业的旗舰公司推出的被改得乱七八糟、错综复杂的产品,有哪件不被产品维护程序员一再诅咒为费解而又难以修改的拼凑物呢?由于经营成本方面的考虑,迫使编程人员损害系统的功能性,放弃了代码的可扩展性,而这正是保证代码持久性所需要的。“如果程序没有坏掉,就不要修改它”,最终让路于“没法修改它—重写算了。”这种状况必须改变。

幸运的是,现在软件行业越来越倾向于代码重构了,重构是通过改造系统内部的代码从而改进程序的设计,并且不改变程序的行为。[2]这种改善包括从某个函数中摘录出一个新函数,或者相反,组合几个成员函数为一个成员函数;用某个对象替换一个成员函数;参数化一个成员函数或类;有条件地替换多态性。代码重构有助于代码的进化。

不管修改程序的动力来自于用户还是来自于程序员,今天的修改可能破坏昨天的工作。需要有一种方式来构造代码,随着时间的流逝,这些代码应该能够经得起变化和改进所带来的负面效应。

极限编程(extreme programming, XP)[3]仅仅是众多支持快速开发实践方法中的一种。在这一节中,要探究一种便于使用的自动单元测试工具框架,它能够使我们成功地开发出灵活的可扩展的程序。(注意:测试工程师是软件专业人员,以测试其他人编写的代码为生,在软件开发活动中,这些人更是必不可少的。在这里只不过想描述一种方法,这种方法能够帮助软件开发人员开发出更好的代码。)

通过编写单元测试程序,开发者能够对下面的两点关键内容获得足够的信心:

1)我理解需求。

2)我的代码符合需求(以我所学的知识来说,它们是最好的)。

先编写单元测试程序是一种能够确保将要编写的代码能够正确工作的最好方法。这种简单的工作能够帮助程序员将精力集中于所要完成的任务上,先编写单元测案例试然后编写代码或许能够导致更快地完成工作,比直接编写代码更快。用极限编程的术语来说就是:

测试程序+编码比直接编码更快。

先编写测试程序同样能够防止边界条件破坏程序,使程序的代码更加健壮。

如果系统无法正常工作,而代码却通过了所有的测试程序,那么问题多半不是出在代码中。“代码已经通过了所有测试程序”就是一个很好的理由。

2.2.1 自动测试

单元测试是什么样子的呢?有太多的开发人员常常只希望他们的代码在获得符合要求的输入时能够产生预期的输出,他们只是用眼睛检查程序的输出。这种方法存在两个危险。首先,程序的输入不可能总是符合要求。大家都知道应该检查程序输入数据的边界,但是当程序员竭尽全力来使程序能够工作时,很难顾及考虑这些事情。如果在编写程序代码之前首先编写测试程序,就可以以测试工程师的角度来问自己,“什么情况会造成程序的破坏呢?”编写测试程序能够证明所写的函数不会被破坏掉,然后程序员再以开发者的身份来完成这些函数。首先编写测试程序能够使程序员写出更好的代码。

第二个危险是用眼睛观察来检查程序的输出,这是很乏味的而且容易出错。这样的事情计算机也能做,并且不会出错。最好用布尔表达式的集合来表示测试问题,让测试程序来报告编码的任何错误。

例如,假设想要构造一个Date类,这个类有以下的特性:

·可以用一个字符串(YYYYMMDD)、三个整数(Y, M,D)或者什么也不用(获得当前日期)来初始化日期值。

·Date对象能够生成年、月、日的值或“YYYYMMDD”形式的字符串。

·所有相关量能够进行有效的比较、能够计算两个日期的差(在年、月、日中)。

·日期的比较能够跨越任意个世纪(例如,1600和2200)。

这个类能够用三个整数分别表示年、月、日。(确保表示年的整数至少是16位(bit)的,以满足上面所说的最后一个特性。)Date类的接口可能如下所示:

2.2 一个简单的单元测试框架 - 图1

2.2 一个简单的单元测试框架 - 图2

在实现这个类之前,读者可以先编写测试程序,使读者牢固地掌握需求。读者可能提出如下代码:

2.2 一个简单的单元测试框架 - 图3

在这个普通的测试案例中,函数test()维护着两个全局变量nPass和nFail。惟一需要程序员用眼睛检查的是最终的得分结果。如果测试失败,更复杂的test()函数能够显示适当的消息。在这一章的后面描述的测试框架不但包括这样一个测试函数,而且还包括其他一些东西。

现在,可以逐步实现Date类,使其通过测试,然后可以继续进行反复测试直到满足所有需求。由于是首先编写测试程序,程序员会更多地注意考虑其他边边角角的情况,而这些情况可能破坏即将实现的程序,会使程序员在第一时间就更加注意编写出正确的代码。这样的练习可能会使读者写出下面的用于测试Date类的一种描述:

2.2 一个简单的单元测试框架 - 图4

这个测试案例可以开发得更加完整。例如,在这里还没有测试程序的可持续性。至此作者所要表达的意思应该已经很清楚了。Date类的完整实现在附录中的Date.h和Date.cpp文件中[4]

2.2.2 TestSuite框架

读者可以从万维网(World W ide Web)下载某些C++自动单元测试工具,例如CppUnit。[5]在这里,本节的目的不仅仅为了介绍一种易于使用的测试结构,而且要使读者容易理解它,甚至在必要的时候修改它。因此,怀着“只要能用,做最简单的”的信念,[6]作者开发了测试套件框架(TestSuite Framework),从其名字的命名就可以看出TestSuite中包含两个主要的类:Test和Suite。

Test类是一个抽象基类,可以从这个类派生用户自己的测试对象。Test类保存着测试时成功和失败的次数,测试失败时能够显示相关测试条件等信息。只需重写成员函数run()就行了,在这个函数中应该定义一些布尔型的测试条件,并且依次调用test_()宏来测试它们。

为了使用这个框架来定义测试Date类的案例,可以继承Test类,下面的程序就是这个测试案例:

2.2 一个简单的单元测试框架 - 图5

2.2 一个简单的单元测试框架 - 图6

运行这个测试案例很简单,只需实例化一个DateTest对象并调用它的成员函数run()就可以了。

2.2 一个简单的单元测试框架 - 图7

Test:report()函数显示前面的输出,并且把测试失败的次数作为返回值,这个值也适合作为main()函数的返回值。

Test类使用运行时类型识别(RTTI)[7]来取得测试类的类名(例如,DateTest),并将这个类名用于测试结果报告。默认情况下Test类将测试结果送到标准输出,如果想要把测试结果写到文件中可以使用setStream()成员函数。在本章的后面,读者将会看到Test类的实现。

test()宏能够将失败的布尔条件摘录成文本形式,并且使这段文本包含test()宏所在文件的文件名和test()宏所在行的行号。[8]为了观察测试失败时会发生什么情况,可以在代码中故意引入错误,例如:可以颠倒上一个例子代码中DateTest:testOps()函数在第一次调用test()时所用的测试条件。程序的输出准确地显示了在哪里、哪个测试出现了错误。

2.2 一个简单的单元测试框架 - 图8

除了test()之外,框架中还包括函数succeed()和fail(),这两个函数用于无法使用布尔测试的情况。当测试类的时候可能抛出异常的,这时候应该使用这两个函数。在测试的时候创建一个会触发异常的输入集。如果异常没有发生,就表明程序中出现了错误,这时候应该调用fail(),就可以清楚地显示一段消息并且修改测试失败次数的值。如果期望的异常发生了,就应该调用succeed_()修改测试成功次数的值。

为了举例说明这两种情况,假设现在已经修改了Date类两个非默认构造函数的异常规格说明。如果输入参数不能表示一个合法的日期,这两个构造函数会抛出DateError异常(嵌套在Date类内的一个类型,派生自std:logic_error):

2.2 一个简单的单元测试框架 - 图9

现在,成员函数DateTest:run()可以调用下面的函数来测试异常处理了:

2.2 一个简单的单元测试框架 - 图10

在这两种情况下,如果函数中不抛出异常,就表明程序出错了。注意,由于这种测试不计算布尔表达式的值,所以必须用手工方式向fail_()中传递一个消息作为参数。

2.2.3 测试套件

实际的软件项目通常包含很多类,需要一种组织测试用例的方式,使程序设计人员能够通过按一个按钮来测试整个项目。[9]Suite类可以将测试案例集中到一个函数单元中。程序设计人员可以使用addtest_()成员函数添加一个Test对象到Suite中,也可以使用addSuite()将现有的一个测试套件添加到Suite中。为了演示Suite的使用,将第3章中用到Test类的程序集中到一个单独的测试套件中。注意,这些文件在第3章的文件子目录中。

2.2 一个简单的单元测试框架 - 图11

2.2 一个简单的单元测试框架 - 图12

上述的5个测试案例完全包含在头文件中。因为TrimTest包含一个静态数据,而静态数据必须定义在实现文件中,所以TrimTest不但需要一个头文件,而且还需要实现文件。程序输出的头两行是StringStorage测试的结果。程序员必须向测试套件的构造函数传递一个参数,这个参数就是测试套件的名字。成员函数Suite:run()调用它所包含的每一个测试案例的Test:run()函数。Suite:report()函数所做的工作与此差不多,也可以将每个测试案例的测试报告输出到不同的流中,而不使用那个属于测试套件的报告。如果用addSuite()添加的测试案例已经被指定了流指针,那么这个测试案例将使用这个流。否则,测试案例使用Suite对象指定的输出流。(就像Test一样,测试套件的构造函数有1个可选的第2个参数,这个参数的默认值为std:cout。)Suite的析构函数并不能自动删除它包含的指向Test对象的指针,因为这些Test对象并不需要保留在堆上;而这些工作由Suite:free()来完成。

2.2.4 测试框架的源代码

在代码解压包中,测试框架的源代码包含在一个叫做TestSuite的文件子目录中,可以从www.MindView.net网站上得到这个代码的解压包。为了使用这个测试框架,读者必须在头文件的查找路径中包含TestSuite子目录,在库文件的查找路径中包含TestSuite子目录,这样才能链接相关的目标文件。下面是Test.h头文件:

2.2 一个简单的单元测试框架 - 图13

2.2 一个简单的单元测试框架 - 图14

Test类有3个虚函数:

·虚析构函数

·reset()函数

·纯虚函数run()

我们在第1卷中曾说过,通过基类指针释放在堆上分配的派生类对象是错误的,除非基类有1个虚析构函数。任何想成为基类的类(如果类中出现了至少1个其他虚函数,就说明这个类想成为基类)应该有1个虚的析构函数。Test:reset()的默认实现只是将成功和失败计数器的值重置为零。读者可以重写这个函数,让它重置派生测试对象中数据的状态;确保在重写函数中明确调用Test:reset(),使其重置计数器的状态。因为需要在派生类中重写Test:run()函数,所以它是一个纯虚成员函数。

在预处理的时候,test()和fail()宏能够取得其所在文件的文件名和其所在行的行号。刚开始的时候并没有在这两个宏的名字后面加下划线,但是fail()宏与ios:fail()产生冲突,造成了编译器错误。

下面是Test类其余函数的实现:

2.2 一个简单的单元测试框架 - 图15

2.2 一个简单的单元测试框架 - 图16

Test类不但保存着成功测试的次数和失败测试的次数,而且保存着Test:report()显示测试结果所需的流。test()和fail()宏在预处理的时候取得当前文件的文件名和当前行的行号信息,并把文件名传递给do_test(),把行号传递给do_fail(),这两个函数则显示一个消息并修改相关的计数器。我们认为测试对象没有理由使用拷贝和赋值操作,所以通过将这两个函数的原型声明为私有并且忽略他们各自的函数体来禁止这两种操作。

下面是Suite类的头文件:

2.2 一个简单的单元测试框架 - 图17

2.2 一个简单的单元测试框架 - 图18

Suite类在vector中保存指向Test对象的指针。请注意addtest()成员函数上的异常规格说明。当读者向测试套件中添加一个测试案例的时候,Suite:addtest()检查传递到这个函数的指针是否为空;如果为空指针,则抛出TestSuiteError异常。由于在这种情况下不可能把一个空指针传递给测试套件,所以addSuite()用断言检查测试套件所包含的每一个测试案例,就像其他函数遍历测试案例的vector一样(请参考下面的实现)。像Test类一样,Suite类禁止拷贝构造函数和赋值操作。

2.2 一个简单的单元测试框架 - 图19

2.2 一个简单的单元测试框架 - 图20

在本教材的剩余部分中将使用TestSuite框架。

[1]这一部分基于Chuck的文章,“The Simplest Automated Unit T est Framework That Could Possibly W ork”C/C++Users Journal, Sept.2000。

[2]关于这个主题的一本好书是Martin Fowler的《Refactoring:Improving the Design of Existing Code》(Addison-Wesley,2000)。参见http://www.refactoring.com。重构在极限编程中是一种至关重要的实践。

[3]参考Kent Beck所著《Extreme Programming Explained:Embrace Change》,Addison W esley 1999。轻量级方法,例如XP已经使the Agile Alliance得到了加强(参见http://www.agilealliance.org/home)。

[4]本书所写的Date类是能够国际化的,也就是说它支持宽字符集(wide character set)。我们将在下一章的结尾介绍宽字符集。

[5]参考http://sourceforge.net/projects/cppunit可以得到更多信息。

[6]这是极限编程的主要原则之一。

[7]“运行时类型识别”将在第9章中讨论。使用typeinfo类的name()成员函数。如果读者使用Microsoft Visual C++,必须指定一个编译器选项/GR。如果没有指定这个参数,在运行时将会出现非法访问错误。

[8]用字符串化运算(stringizing,通过#预处理运算符)以及预定义的宏FILELINE。参考本章随后部分的代码。

[9]也可以使用批处理文件和shell脚本文件。Suite类是一种基于C++的组织相关测试用例的方法。