第章 测试
每个开发人员,无论是多么有经验和细心,在编写软件时都会发生错误。特别是随着API大小和复杂度增加时就会变得不可避免了。测试的目的就是尽早找到这些缺陷,以便在影响你的用户之前解决好这些问题。
现代软件开发中非常依赖第三方API。一旦你的API被广泛使用,那么只要你的代码中有不足和缺点的话就会影响很多用户以及他们的终端用户程序。
正如前面提到过的,如果你实现的代码充满错误、不可预知或常常崩溃,那么你的用户将最终会去寻找其它可替代的解决方案。因此,负责任的测试在API开发过程中是一个非常重要的环节,因为这可以增加产品的可靠性和稳定性。这最后可以帮助你的API在市场上获得一席之地。
提示
编写自动化测试是你能做的最重要的事情,可以确保不会破坏你的用户的程序。
本章涵盖了你可以采用的各种自动化测试类型,如单元测试、集成测试和性能测试。我还会留意如何编写良好的测试,这和如何设计和实现经得起测试的API是同等重要的。最后,在快结束讨论时我会介绍一些你可以在项目中使用的测试工具,关注下如何利用这些流行的自动化测试框架来编写测试。期间我还会讨论如何配合QA团队处理问题,使用质量指标和如何把测试融入到构建过程中。
10.1 编写测试的原因
工程师不喜欢编写测试是一种常见的谬论。根据我的经验,每个优秀的开发人员都深知测试的必要性和绝大多数都会在他们的代码中通过测试来捕获到错误。最后,软件工程师都是靠技术吃饭的,他们通常都会为自己的作品感到骄傲。如果他们预计到会开发出粗制滥造的产品,他们很可能会感到十分沮丧。然而,如果项目管理部门不明确地把测试纳入时间表,那么工程师就得不到他们用于开发这些测试所需要的资源。一般情况下,一个软件项目可以在数据驱动、质量驱动或特性驱动中做出选择。你可以选择其中的两个,而不是全部。例如,在瀑布式开发过程中,特性膨胀(feature creep)和无法预料的问题能够在项目后期的任何时间内通过保留的测试轻易地消除。因此,一个试图花时间编写自动化测试的工程师看起来像在一定的时间内或特性驱动的过程中没那么有效率。如果工程师被授权关注质量,那么我相信他们肯定有机会体会到为他们的代码编写测试所带来的好处。
我在皮克斯时深有体会,我们决定引入一个新的政策:工程师必须为他们的代码编写单元测试,而且所有的非GUI代码都要100%覆盖到。也就是说,测试代码要检验所有的非GUI代码行。这并没有引发大家的*,我们发现开发人员认为这可以很好地利用他们的时间。关键的有力因素是:我们在每次迭代后都增加了时间,所有的开发人员都可以专注于编写他们自己的代码测试。即使在实行这个政策两年后,大家普遍认为编写测试所带来的好处大于成本开销,而且保持100%的目标覆盖率仍然是适当的。
为了让你心服口服,这里列出一些你应该为你的项目采用测试的理由:
q增强信心:经过大量的自动化测试可以让你信心满满地修改API的行为而不会破坏功能。换种说法,测试可以让你减少对修改实现的担心。这对于老旧系统是相当普遍的:工程师很难修改代码的某个部分,因为那些部分是相当复杂和不透明的,修改它的行为会导致意想不到的后果(Feathers,
2004)。此外,编写过有问题的代码的工程师可能已经离职了,这意味着就没人知道代码中隐藏了哪些缺陷。
q确保向后兼容性:知道下面这些是很重要的:你对新版本的API所做的修改不会破坏向后兼容性,而API代码是基于旧版本的API或旧版本API所生成的数据文件编写的。自动化测试可以用来从先前的版本中捕获工作流程和行为,以便于这些都能在最新版本的库中获得通过。
q节省费用:大家都知道这样一个事实:在开发周期中的后期修复缺陷的开销要比在早期高得多。这是因为缺陷已经深深地潜入到代码中,要解决它常常需要更新很多数据文件。例如,Steve McConnell给出的证据表明在发布后修复错误的成本是开发中的10-25倍(McConnell,
2004)。开发一个自动化测试程序可以让你更早地发现缺陷 ,以便于可以尽早修复,因此也就更经济实惠了。
q整理用例:API用例表示支持的是用户应该能够完成的工作流。在你实现API前,为这些用例开发测试可以让你知道何时完成需要的功能。这些相同的测试可以用在一个持续的基准上来捕获在这些高级工作流上的任何退步(regression,回到不稳定的状态。详见回归测试。)。
q遵守规则:对于安全要求比较高的软件可能需要通过监管测试(regulatory test),如联邦航空管理局认证(Federal
Aviation Administration certification)。此外,在你的软件注册某类商标时,一些组织会验证你的软件是否符合他们的标准。例如,开放地理信息联盟(Open Geospatial Consortium,OGC)会对打上“OGC认证通过”的软件进行一致性测试。自动化测试可以用来保证你的程序是符合这些管理规则和标准需求的。这些要点可以概括起来说是自动化测试可以帮助你的决定你正在构建的东西是否是正确的(也就是为确保没有故障而对程序所进行的检查)。
值得注意的是如果编写很多测试的话也会有副作用。随着测试程序大小的增加,对这些测试的维护也会相应的增加。这会导致只是对代码做个简单的修改,这只需要花几分钟的时间,而更新测试却需要花好几个小时。这将是一种糟糕的局面,因为工程师们肯定不乐意为了一个小修改而浪费太多的时间在更新测试上面。我将在接下来的部分讨论如何避免这种情况。然而,还需要注意的是如果对公共API的修改有疑问,那么这种额外的障碍可能是一件好事,这会强制工程师考虑向后兼容性的冲击对现有用户的影响。
[排版 P293 开始]
未测试代码的开销
多年来,发生过很多软件失败的例子。众多网站都有这些错误的目录,如软件故障列表:http://www.sereferences.com/software-failure-list.php。这里的很多例子,如果有更彻底的测试就可以避免很多灾难性的失败。例如,在1996年5月,一个软件错误导致美国银行的823位储户的银行账号入账达924,844,208.32美元。美国银行家协会说这是美国银行史上最大的一个错误。发生这件事的原因是银行给它的ATM交易软件添加了新的消息代码,但是没有在所有的ATM协议上进行测试。
再举个说明向后兼容性测试必要性的例子,2005年11月的东京证券交易所因为一次系统升级而导致近一整天的交易中断。
无法满足客户的需求可能对业务造成巨大影响。例如,在2007年11月,美国的一个地方*打了一个几百万美元的官司,因为其采用的一家软件服务供应商提供的刑事司法信息软件的质量有问题,无法满足需求。
最后,再举一个符合型测试(compliance test)重要性的例子,在2009年1月,监管机构禁止从美国一家大型医疗保险公司销售医疗保险计划,因为电脑出错,导致“严重威胁医疗保险受益人的安全和健康。”
[排版 P293 结束]
10.2 API测试的类型
测试一个API和测试一个最终用户程序有很大的不同。然而,还是有适用于这两者的各种技术。例如,下面是常见的软件测试分类:
(1).白盒测试:测试是根据对源码的认识而编写的,通常是使用编程语言编写的。
(2).黑盒测试:测试是基于产品的规范编写的,不需要知道底层的实现。虽然也可以仅仅通过API规范来编写代码来测试API,但是通常都是手动测试最终用户程序的。
(3).灰盒测试:白盒和黑盒测试的组合,黑盒测试的执行需要知道软件的实现细节。
这些术语可以应用到API测试和最终用户程序测试中。然而,API并不是最终用户程序:它们只能通过编写代码来调用库文件内定义的函数。因此,有几种传统的软件测试技术不适用于API测试。
例如,术语系统测试指的是对一个完整的集成系统进行测试。这通常认为是由用户运行的一个实际中的程序。虽然可以把一个大型的API看成一个完整的集成系统,但是我这里不会这么看。我把API看成一个用于构建整个系统的构建模块或组件。因此,我不会把系统测试看成是API测试的一部分。
此外,自动化GUI测试领域通常也不适用于API。也就是说,编写运行在最终用户程序上的自动化脚本和模拟用户交互的任务,如:单击按钮或输入文本。这个的唯一例外就是如果你是在编写一个创建这些按钮和文本输入部件的GUI工具包。然而,在这种情况下:为了实现自动化测试,你(和你的用户)可以创建一个自定义的测试工具来定位和与部件交互。[l1]例如,Froglogic为Qt程序提供了一个自动化GUI测试工具,叫做Squish。
一般说来,手动测试技术不适用于API测试,因为没有要操作的用户接口。因此,这里我们关注的是通过编写代码进行的测试和能够自动化的测试。自动化测试是以编程方式定期执行的,如在你构建API的过程中。如果测试不是自动的,那么它们就很可能根本没有运行,这会破坏编写它们的意图。
最后,我这里要关注的主要功能测试策略是单元测试和集成测试。单元测试验证软件是否完成程序员预期的功能,而集成测试满足的是软件是否解决用户的需求。你也可以编写API非功能需求的验证。性能测试不是一个非功能测试,我会在本章的稍后讲解这个主题。然而,现实中有很多非功能测试技术的其它类型。下面的列表给出几种最常用的:
q性能测试(Performance testing):验证API的功能,满足最低的速度或内存的使用要求。
q负荷测试(Load testing):在一个系统上施加需求或压力,测量它处理这种负荷的能力。这常常是模拟很多用户或每秒执行很多API请求的测试。有时这也叫做压力测试。
q扩展性测试(Scalability testing):确保系统能够处理生成的大量而复杂的数据,而不仅仅是简单的测试数据集。这有时也叫做容量(capacity)或体积(volume)测试。
q耐久度测试(Soak testing):尝试让软件长时间的运行来满足用户对稳定性的要求,并可以处理持续的应用(如:没有什么大的内存泄露、计数器溢出或计时器相关的错误)。
q安全测试(Security testing):确保代码符合所有的安全需求,如:保密性、鉴权、授权、完整性和敏感信息的可用性。
q并发测试(Concurrency testing):验证代码中的多线程行为,确保该行为正确和不会有死锁。
提示
API测试应包括单元和集成测试的组合。非功能技术也可以适当的采用,如:性能、并发性和安全测试。
10.2.1 单元测试
单元测试是用来验证源代码的单个最小单元的,如单个方法或类。单元测试的目的是隔离API最小的可测试的部分并在隔离环境下验证他们功能的正确性。
这些测试类型运行起来都很快并常常采用一系列返回true[l2](真)或false(假)的断言的形式,任何false的结果都表示测试失败。常常这些测试和要测试的代码是在同一个地方(如在tests子目录),当代码本身编译时,它们被编译和在同一点运行。单元测试倾向于是开发人员根据对实现的了解来编写的。因此,单元测试是一种白盒测试技术。
提示
单元测试是一种白盒测试技术,用来在隔离环境下验证函数和类的行为。
为了给定一个单元测试的具体例子,让我们来看看一个要测试的把字符串转换成double类型的函数:
[代码 P295 第一段]
这个函数接收一个字符串参数并返回一个布尔值来表明这个转换是否成功。如果成功的话,double值将写入到result引用参数中。给定这个函数后,下面的单元测试执行一系列测试来确保它是符合预期的。
[代码 P295 第二段]
这里要注意的是各种用来测试每个操作结果的辅助函数的使用:Assert()、AssertFalse()和AssertEqual()。这些函数在遵循Junit风格的单元测试框架中是比较常见的,不过有时也采用相似的函数命名或使用大写字母。如果这些Junit风格的断言有出现失败的情况,那么整个测试都会失败,通常会有一个针对错误的描述信息。
[排版 P296 开始]
Junit起初是由Kent Beck和Erich Gamma设计的用于Java编程语言的单元测试框架。它的设计是根据两个关键的设计模式:命令和组合。
每个测试用例都是定义了一个或多个test*()方法的一个命令对象,如:testMyObject(),还包括可选的setUp() 和tearDown()方法。多个测试用例可以整理到一个测试套件中去。也就是说,测试套件是会自动调用所有test*()的测试用例的组合。
Junit还支持很多基于断言测试的方法,如:assertEquals()、assertNull()、assertTrue()和assertSame()。如果有任何一个传入的表达式的计算结果为false的话,那么测试用例就会标记成失败。
自从当初的Junit的流行,这个框架就被移植到很多其它语言,成为知名的xUnit。例如,Python的PyUnit、C的Cunit和C++的CppUnit,其中还包括其它的实现。
[排版 P296 结束]
刚刚给定的那个例子是有意弄得简单。然而,在现实软件方法或对象中的测试常常依赖于系统中的其它对象或者外部资源,如磁盘上的文件、数据库中的记录或远程服务器上的软件。这导致有两种不同的单元测试的类型。
(1).固定配置(Fixture setup):经典的单元测试方式是在每个单元测试运行前初始化一个一致的环境,或叫固定。例如,确定独立的对象和单态被初始化,拷贝文件的特定集合到一个已知的地方,或利用准备好的初始数据集来装载一个数据库。这常常是在每个测试的setUp()来完成,这让测试配置步骤和实际测试操作区分开来。相关的tearDown()函数常常用来在测试结束后清理环境。这种方式的一个好处就是同样的固定配置可以被很多测试所重用。
(2).存根/模拟对象(Stub/mock objects):用这种方法,进行测试的代码和系统的其余部分相隔离,这种实现是创建存根或模拟对象来代替单元外的任何依赖(Mackinnon等,2001)。例如,如果一个单元测试需要和一个数据库进行通信,那么可以创建一个存根数据库对象来接收单元生成的查询子集,接着返回对封装好的数据的响应,这样就不用和真实的数据库相连接。这样做的结果是测试就完全隔绝开来,不会受到数据库问题、网络问题或文件系统权限的影响。然而,这也是有不利的地方:创建这些存根对象是比较麻烦的,它们常常无法在其它单元测试中重用。不过,模拟对象是比较灵活的,可以针对单独的测试进行定制。我将在本章的稍后讨论这些内容的细节。
提示
如果你的代码依赖于一个不可靠的资源,如数据库、文件系统或网络,那么可以考虑使用存根或模拟对象来生成更加稳定的单元测试。
10.2.2 集成测试
比起单元测试,集成测试主要关注一起合作的几个组件间的相互作用。理想的情况是每个组件都经过了单元测试。
即使你单元测试的覆盖率很高,集成测试也仍然是很有必要的,因为代码所进行的单独单元测试是在隔离情况下进行的,并不能保证它们放在一起运行时不会出现问题或满足你的功能需求和用例。例如,某个组件的接口可能和另一个组件是不兼容的或者由一个组件提供的信息不是另一个组件所需要的。因此,集成测试的目的就是确保API中的所有组件能够正常和一致地协同运行,保证用户能够执行他们所需要的任务。
集成测试通常是针对API规范开发的,如所有的自动生成的API文档,因此不需要理解内部实现细节。也就是说,是从用户的角度来编写的。因此,集成测试是一种黑盒测试技术。
提示
集成测试是一种黑盒测试技术,用来验证几个组件间的相互作用。
通常可以使用和编写单元测试相同的工具来实现集成测试。然而,集成测试常常包括更复杂的方式来验证一系列操作是否是成功的。例如,有个测试生成一个输出文件,用来比较“黄金”或“基础”版本[l3],那是存储在版本控制系统中的测试。一旦有预期的失败情况出现,就需要一种有效率的工作流程来更新基线版本(baseline
version),如在数据文件中添加新的元素或通过修改一个函数的行为来修复错误。
因此,一个好的集成测试框架包括API生成的每个文件类型的比较函数(或diff命令)。例如,有个API有一个ASCII配置文件,只在配置值或数目改变时才触发集成测试的失败,而文件中的配置顺序的改变或使用不同的空格字符做为配置的分隔符都不会触发。再举个例子,有个API生成一个图像做为它的结果。因此,你需要一种方法来比较输出的图像和基线版本的图像。例如,PDI/Dreamworks的研发部开发了一个图像差异感知程序来为他们的电影验证渲染出来的图像在他们的动画系统中修改后从视觉上看还是一样的。这种感知允许实际中的像素值有微小的偏差以避免不必要的感知失败(Yee 和 Newman,
2004)。
[图 P298 第一张]
图10.1
Willem van Schaik PNG图像测试套装的一个子集,叫做PngSuite。详见:http://www.schaik.com/。
从最后一个例子中可以看出,集成测试也有可能是数据驱动的。也就是说,单个测试程序也可以使用不同的输入数据来多次调用。例如,有个C++解析器用来解析单个集成测试,读取一个.cpp源代码文件并输出它的派生或抽象语法树。这个测试可以被不同的C++源程序调用并且它的每次输出都可以和正确的基线版本相比较。相似地,libpng库有一个pngtest.c程序用来读取一个图像并又对它进行写操作。这个测试是通过数据驱动的方式运行的,使用的是Willem
van Schaik的PNG图像套装,叫做PngSuite。图10.1显示的是PngSuite中的一些图像。这种集成测试保证libpng中的新改动不会破坏它读写各种PNG文件格式的能力,包括基本块的处理、比较、交错、alpha透明度、滤镜、伽马和图像备注等其它属性。
API的集成测试可以由开发人员执行,但是在大型的组织中也可以由QA团队来执行。事实上,一个QA工程师可能把这个操作当成API测试,这意味着常常是由QA负责的。这里我避免使用明确的术语API测试,因为本章的全部是关于测试API的。
和单元测试相比,集成测试有不同的关注点,可以由不同的团队负责,通常必须在完全成功构建后再运行。因此,比起单元测试,这些测试类型通常是在不同的目录。例如,它们可以在*源码目录下的同级目录中,而不需要放置在实际代码中的源码目录里。这种策略也反映出黑盒集成测试和白盒单元测试本质的不同。
10.2.3 性能测试
典型地,你的用户对你的API有一定的性能要求。例如,如果你编写了一个用来游戏中实时检测3D对象碰撞的库,那么这个实现在每个帧期间应该运行得足够快,不会降低用户游戏的速度。因此,你会为碰撞检测代码编写一个性能测试,如果超过了预期的性能阀值就会导致测试失败。
再举一个例子,当苹果开发他们的Safari Web浏览器时,最关注的是页面呈现的速度。因此,他们为每个测试添加了性能测试和定义了可接受的速度阀值。接着,他们在合适的位置加入一道工序,如果性能测试超过它的阀值时,签入将会被拒绝。在可以签入之前,工程师必须优化他们的代码(或者用其他人的代码,如果他们的代码已经是最佳的了)。
提示
关键用例的性能测试可以帮助你避免在不知不觉中引入速度或内存退步。(译者注:这里的退步是指,在软件项目中,如果一个模块或功能以前是正常工作的,但是在一个新的构建中出了问题,那这个模块就出现了一个“退步”(Regression),从正常工作的稳定状态退化到不正常工作的不稳定状态。)
一个相关的问题是压力测试,你要验证你的实现可以满足现实世界中用户的需求。例如,一个网站需要同时处理很多用户(的请求)或一个粒子系统需要处理数千或数百万粒子。
这些被列为非功能测试,因为不是在测试API的具体特性,而是验证在用户的环境中它的操作行为。也就是说,测试的是API的非功能需求。
编写自动化性能测试的好处在于你可以确保新的改动不会对性能有什么负面影响。例如,和我共事过的一个高级工程师曾经重构了一个数据加载API,他使用一个std::string对象来代替一个char缓冲来存储读取自一个数据文件的连续字符。当这个改动发布后,用户发现系统要花10倍多的时间来加载他们的数据文件。最后是使用一个std::vector<char>来修复这个问题的,因为在那个容器中使用append()方法的性能表现是更好的。在它发布给用户之前,监测加载大量数据文件的时间的性能测试可以发现这种退步。
提示
如果性能对于你的API是比较重要的话,可以考虑为你的关键用例编写性能测试,这样可以避免在不知不觉中引入的性能退步。
然而,性能测试比起单元或集成测试是比较不容易编写的。其中的一个原因是性能测试的结果是浮点型的,每次运行都可能是不一样的。它们并不是不连续的true或false值。因此,建议对于这种每次测试运行的变化应该指定一个宽容范围。例如,你在碰撞检测算法中指定了10ms(毫秒)的阀值,但是在把测试标记为失败前,允许有15%的波动。还有一种技术是只在数据点连续超过异常峰值的阀值时才导致性能测试的失败。
还有,最好有专用的硬件来运行性能测试,以便机器中的其它进程不会影响到测试结果。即使有了专用的机器,你还可能需要研究一下关闭某些系统的后台进程,以便它们不会影响性能测试的计时。从这里也可以看出为什么性能测试是比较难以把握的:机器会影响到性能测试。这也意味着对于运行测试的不同级别的机器需要存储有不同的阀值。
一个性能测试更复杂的方面是信息超载的问题。你可能会终结于[l4]在不同的硬件上的每种性能测试的数目达到数以百计或数以千记的组合,每种生成的多个数据点都要消耗一整天。因此,你要把所有的性能结果存储到一个数据库中。而且,如果你没有自动化测量来突出显示超出性能阀值的测试,那么你可能绝不会注意到性能上的退步。然而,对于很多测试,你很可能被错误所淹没,花费绝大部分的时间去更新基准值。此时,你应该考虑到数据挖掘问题。换句话说,尽可能的搜集数据,接着进行定期的数据库检索,选取最影响性能的5个或10个变动,通过人工来标记那些调查结果。
Mozilla提供了一个性能测试的好例子。他们实现了一个为多个产品在一系列硬件上进行性能测试的系统。结果可以在一个交互网站上浏览到,可以同时显示一个或多个性能测试的图表(见图10.2)。例如,火狐浏览器有各种性能测试,如启动时间、关闭时间、页面加载时间和DHTML性能。
[图 P300 第一张]
图10.2
火狐在http://graphs.mozilla.org/上的性能测试。本例显示的是火狐3.5的启动时间。
火狐性能图表网站可以让你选择一个火狐的版本,选择你感兴趣的测试,接着可以看看在特定机器上的性能测试结果。接着你可以为其它机器添加结果到图表,用来比较那些设置的性能(读取性能图表要注意的是y轴是否是从0开始的。如结果是经过垂直缩放以适应屏幕的,那么一个看起来很大的波动在实际中只是一个很小的百分比变动。)
10.3 编写良好的测试
现在我已经涵盖了API测试的基本类型,接着我将集中讨论如何编写这些自动化测试。我将涵盖编写良好测试所相关的品质,还有就是编写高效和全面的测试的标准技术。我还会讨论如何与QA团队有效地分享测试。
10.3.1 良好测试的特征
在我讨论编写自动化测试之前,我将给出一些良好测试的高级属性。当你要构建自己的测试套装时,这些基本的品质要求你要时刻牢记。总体而言,你对待测试代码要像你对待主要的API代码一样有严格的标准。如果你开发的测试符合下面所描述的品质要求,那么将得到易于维护和健壮的测试套装,相当于为你的API开发提供一张宝贵的安全防护网。
q快速:你的测试套装的运行速度应该相当快,以便有测试失败就可以很快得到反馈。单元测试应该始终是非常快的:大约每个测试只要几秒钟。执行实际用户工作流程的集成测试或根据很多输入文件运行的数据驱动测试,可能执行较长的时间。然而,有几种方法可以解决这个问题,多创建单元测试,而少些集成测试。还有,你可以拥有不同的测试种类:“快速”(或“登记”或“连续”)测试是在每个构建周期间运行,而“慢速”(“完成”或“认可”)测试只是偶尔运行一下,如在发布前夕。
q稳定:测试应该是可重复的、独立的和一致的:你每次运行特定的测试版本都应该得到相同的结果。如果一个测试发生错误或连续地失败,那么你将会对测试结果的有效性的信心将会被削弱。你甚至可能打算临时关闭测试,当然这也违背了测试的目的。使用模拟对象,就是所有的单元测试的依赖都是模拟的,这种方式可以让测试获得独立且稳定的实验环境。这也是测试对日期或时间依赖的代码的唯一可行的方式。
q可移植:如果你的API在多个平台上都有实现,那么测试也应该可以工作在这些平台上。运行于不同平台上的测试代码的最常见的不同就是浮点比较。舍入误差、架构差异和编译器差别都会导致数学操作在不同的平台上生成有些许不同的结果。因此,浮点比较应该允许有小的误差或很小的正数[l5],而不是精确地比较。需要注意的是这种误差应该和所涉及的数字大小与使用的浮点类型的精度相关。例如,单精度浮点型表示成6位或7位精度。因此,小正数0.000001在比较诸如1.234567时就是合适的,当比较123456.7时,小正数0.1却是更合适的。
q高编码标准:测试代码应该和API保持一样的编码标准:你不应该因为这些代码不会直接被用户执行而降低标准。测试应该有良好的稳定,以便清楚地知道在测试什么和可能发生的失败结果。如果你为API代码强制进行代码审查,那么你应该在测试代码中也这么做。相似地,你在编写测试时也要保持工程师的良好习惯。如果有可能把通用的测试代码编写成可重用的测试库,那么你就应该去实现。随着你的测试套装规模的增加,你会得到数百个或数千个测试。因此,对测试代码健壮性和可维护性的需求就变得和API代码一样了。
q可再生的失败:如果一个测试失败了,它应该是可以容易地再次生成这个失败。这就意味着要尽可能多地记录关于失败的信息,尽可能准确地标出实际的失败点,让开发人员可以容易地在调试器中运行失败测试。一些系统采用随机测试(也叫做一次性测试,ad-hoc testing),测试空间是非常庞大的,选取随机的样例进行测试。在这些情况下,你要确保可以容易地再次生成在特定条件下的失败,因为重新运行测试后是随机选取的样例并可能通过测试。
10.3.2 要测试什么
最后,我们进入了动手编写测试的环节。编写单元测试的方法和编写集成测试的方法是不同的。这是因为单元测试知道代码的内部结构,如循环和判断。然而,这两种情况的目标都是验证API的功能。为此,有很多标准的QA技术可以用来测试API。这里列出一些最合适的:
q条件测试:当编写单元测试时,你应该利用对所测试的代码的了解来运用单元中的任何if/else、for、while和switch表达式的组合。这可以确保代码中所有可能的路径都会被测试到。(当我讲到代码覆盖工具时,我会在本章的稍后部分讨论语句覆盖和判定覆盖的细节。)
q等价类(Equivalence classes):等价类是指有一组测试输入,拥有全部一样的预期行为。等价类划分技术试图寻找导致不同类行为的测试输入。例如,考虑一个平方根函数,文档中说明接受的值范围是0到65535。在这种情况下,就有三个等价类:负数、有效范围内的数、大于65535的数。因此,你应该使用这三个等价类的值来测试这个函数,如:-10、100和100000。
q边界条件:绝大多数错误都是发生在预期值的边界附近。你是不是无意间编写过很多数值溢出的错误?边界条件分析的重点就是围绕这些边界值进行的测试。例如,如果你正在测试这样一个例子:往长度为n的链表中插入一个元素,那么你就应该在插入位置0、1、n-1和n进行测试。
q参数测试:对一个给定的API调用进行测试,应该让函数的所有参数多样化,这样才可以验证全范围的功能。例如,stdio.h的函数fopen()接收的第二个参数是指定文件模式。可以采用的值为“r,”、“w,”和“a,”加上可选的“+”和“b”字符。因此,这个函数的全面测试将包括12种模式参数的组合,用以验证完全的行为宽度[l6]。
q返回值断言:这种测试形式保证在不同的输入参数组合下,确保函数返回正确的结果。这些结果可以是函数的返回值,但是也可以包含额外的输出参数,通过指针或引用来传递。例如,下面有个简单的整数相乘函数:
[代码 P303 第一段]
现在可以提供某个范围内的(x, y)输入,并把结果和一张已知的正确值表相对照。
qgetter/setter对:在C++ API中使用getter/setter方法是很普遍的,我也主张你应该总是偏好使用这些函数,而不是直接在类中暴露成员变量。你在测试时应该调用setter方法??之前调用getter方法??返回一个适当的默认结果,接着在setter后调用getter返回相应的值,例如:
[代码 P303 第二段]
q操作顺序:改变操作顺序来执行相同的测试(如果可能的话)可以帮助发现任何执行假定和非正交的行为顺序。也就是说,API调用是否拥有为实现某个流程所依赖的未注明的缺陷。
q回归测试:只要有可能的话,应尽量保持与早期API版本的向后兼容性。因此,应通过测试来验证这个目标。例如,有个测试尝试读取由旧版本的API生成的数据文件,这是为了确保最新的版本仍然可以正确读取。重要的是,当API被修改时,这些数据文件并不需要修改成新的格式。也就是说,你最终得到的实时数据文件,对当前版本来说是最新的,而旧的数据文件可以验证API的向后兼容性。
q负面测试(Negative testing):这项测试技术通过构造或强制生成错误的条件来看看代码在意外的情况下是如何反应的。例如,如果有个API尝试读取磁盘上的一个文件,负面测试可能是试图删除那个文件或者把它设置成不可读的。当API无法读取文件中的内容时,再看看它的反应。再举一个负面测试的例子就是给一个API调用提供非法的数据。例如,在信用卡支付系统中接受信用卡号的测试应该包括无效的信用卡号(负面测试)和有效的信用卡号(正面测试,positive testing)。
q缓冲区溢出(buffer overrun):缓冲区溢出或溢出(overflow)是指写内存时越过了分配的缓冲区的边界。这会导致未分配内存的修改,常常会导致数据错误和最终的崩溃。数据污染(data corruption)错误很难追踪,因为崩溃可能发生在实际的缓冲溢出事件发生后的一段时间内。因此,检测编写API的代码不会超过内存缓冲区是不错的做法。这个缓冲区可能是类中的一个内部私有成员或可能是你传入API调用的一个参数。例如,string.h中的函数strncpy()最多从一个字符串拷贝n个字符到另一个字符串。这必须通过提供一个大等于n个字符的源字符串来测试,接着再验证不超过n个字符(包括null终止符,\0),写入到目标缓冲中。
q内存所有权:内存错误是导致C++程序崩溃的一个常见原因。所有动态返回分配内存的API调用都要用文档注明API是否持有内存或者是否由用户负责释放它。这些规范应该通过测试来确保它们是正确的。例如,如果是由用户负责释放内存,那么一个测试可以两次请求对象并断言那两个指针是不同的。更进一步的测试可以是否内存,接着从API多次请求对象以确认不会发生内存污染或崩溃。
qNULL输入:另一个导致C++崩溃的常见原因是向函数插入一个NULL指针,接着立即尝试在没有检测NULL的情况下解除指针引用。因此,你应该测试所有的接收指针参数的函数,确保它们在传入一个NULL指针时的行为是正常的。
10.3.3 关注测试结果
测试API中的每一条代码路径基本上是不可能的。因此,你要做出决定,测试全部功能的哪个子集。为了帮助你专注于测试工作,下面的列表列举了七种最重要的测试要点:
(1).专注于测试API的主要用例或流程。
(2).专注于测试涵盖了多个特性或提供了最广泛的代码覆盖率。
(3).专注于最复杂、高风险的代码。
(4).专注于设计上比较糟糕的部分。
(5).专注于那些需要高性能或安全性的特性。
(6).专注于测试那些可能对用户造成最坏影响的问题。
(7).专注于测试那些可以在开发周期早期就可以完成的特性。
10.3.4 和QA合作
如果你幸运地拥有一个不错的、支持你测试的QA团队的话,那么他们能够为你分担编写自动化测试的责任。例如,标准的做法是开发人员为QA编写并拥有单元测试,而QA编写并拥有集成测试。
不同的软件开发模型生成不同的QA交互。例如,传统的瀑布方法,测试是在发布前执行的最后一个环节,这意味着QA常常被当成一个不同的小组,质量目标常常因为开发进度的延误而受到负面地影响。相比之下,更加敏捷的开发过程,如:Scrum,是把QA嵌入到开发过程中,负责短期冲刺或迭代测试。
在这两种情况下,和QA工程师合作的好处是他们成了你的第一个用户。因此,他们可以帮助你确定你的API是否满足功能和业务方面的需求。
正如前面说过的,API测试通常需要编写代码,因为API是用来构建最终用户程序的软件。这意味着你的QA工程师应该会编写代码,这样才能有效地进行集成测试。关于这个,微软历来使用两大术语来对QA工程师进行分类:
软件测试工程师(Software Test Engineer,STE):一般没有多少编程经验,甚至没有很强的计算机科学的背景。STE本质上执行的是手动的黑盒测试。
测试组的软件设计工程师(Software Design Engineer in Test,SDET):能够编写代码,可以执行白盒测试、编写工具和生成自动化测试。
就API测试而言,你需要的是一名SDET的QA工程师,而不是STE。然而,即使是SDET,大多数也不会使用C++编程,大多数只会编写脚本语言代码。因此,为API提供脚本绑定可以为你的QA团队提供更大的机会,让他们可以为自动化集成测试做出贡献(更多关于添加脚本的细节请参见第十一章)。还有一种技术是编写数据驱动的测试程序。前面有个pngtest.c例子就是关于这个的:由开发人员编写的单独程序,可以用来让QA工程师生成大量的数据驱动的集成测试。
10.4 编写可测试代码
测试API并不是等开发过程结束后才开始的。当你在设计和实现API时就应该行动起来,这样可以提高你的能力,编写出健壮和大量的自动化测试。换句话说,就是在开发过程中,你就要考虑到如何测试某个类。下面的章节编写的软件,是更适合自动化的单元测试和集成测试的各种技术。
10.4.1 测试驱动型开发
测试驱动开发(Test-Driven Development,TDD),或者叫做测试优先编程(Test-First Programming),包括编写自动化测试来验证想要的功能,这是发生在编写该功能代码之前。当然,这些测试在起初会失败。接下来的目标就是通过尽快编写最少的代码来通过这些测试。最后通过代码重构来优化或根据需要来清理实现代码(Beck, 2002)。
TDD的一个重要方面是修改是逐步增加的,每次一点点。你先编写一个简短的测试,接着编写足够多的代码来让测试通过,如此反复。在每次小更改后,你重新编译代码并重新运行测试。每次逐步修改一点点的目的是,如果测试遇到失败,那么这只可能是最后一次通过测试后所编写的代码造成的。让我们看一个这方面的示例。我要编写一个小测试用来验证MovieRating(电影评定等级)类的行为(Astels, 2003)。
[代码 P305 第一段]
给定上面这个初始化的测试代码后,你现在就可以编写最简单的代码来让测试通过。这里有一个例子满足这个目标。(在这些例子中,我将在API方法中使用内联,这样可以更清楚地知道测试代码是如何演进的。)
[代码 P306 第一段]
很明显,这个API并没有做很多事情,但是它允许让测试通过。因此,现在你可以继续添加更多的测试代码。
[代码 P306 第二段]
现在,是时候编写最少代码让这个测试通过了。
[代码 P306 第三段]
下面再编写另一个测试让实现变得更加通用。
[代码 P306 第四段]
现在,你应该扩展这个实现,返回添加的评定等级的数值和那些评定等级的平均值。实现这个的最简单方式是记录所有评定等级的当前总和,还有添加的评定等级的数值。例如:
[代码 P306 第五段]
很明显,你可以继续通过这种方式添加更多的测试来验证调用GetAverageRating()在评级为0的情况下不会崩溃,还可以检测超过范围的评级值也可以得到正确地处理,不过我想你已经明白基本原理了。
测试驱动开发的一大好处是在你开始编写任何代码时就迫使你考虑API。你也得考虑到如何使用API。也就是说,你在扮演用户的角色。TDD的另一个效果是你只实现测试需要的内容。换句话说,你的测试取决于你需要编写的代码(Astels, 2003)。这可以帮助你避免过早优化和让你能够专注于整体的行为。
提示
测试驱动开发意味着你要先编写单元测试,接着再编写代码让这些测试通过。这可以让你对API的关键用例保持关注。
TDD并不只限于你的API的初始开发。它在维护代码时也是有用的。例如,当在API中发现错误时,你应该先为正确的行为编写一个测试。当然,这个测试开始会失败。接着,你可以修复这个错误。你会知道错误何时修复,因为测试的状态会变成通过的状态。一旦错误修复了,你可以进行回归测试,这样可以确保将来不会再出现同样的错误。
10.4.2 存根和模拟对象
有个流行的技术让单元测试更加稳定和更具弹性:就是创建可以代表系统中真实对象的测试对象。这让你为了达到测试的目的,可以通过一个轻量级可控的替身来代替不可预知的资源。不可预知的资源的例子包括文件系统、外部库和网络。替身对象也可以用来测试在真实系统中难以模拟的错误条件,以及在某个时刻触发的事件或是基于一个随机数生成器。
很明显,这些替身对象和它们所模拟的真实对象拥有相同的接口。然而,实现这些对象还有几种不同的方式。下面的列表给出了几种选项和为每种情况介绍了通用的术语。
q伪对象(Fake object):拥有功能行为的对象,但是使用的是简化的实现来帮助测试。例如,使用内存中的文件系统来模拟和逻辑磁盘的交互。
q存根对象(Stub object):一个返回事先准备好的或固定的响应的对象。例如,ReadFileAsString()存根仅仅是简单地返回一个硬编码的字符串做为文件的内容,而不是从磁盘中读取文件的内容。
q模拟对象(Mock object):一个拥有预编程行为的指令对象,执行验证其方法的调用序列。例如:有个模拟对象指定了一个GetValue()函数,当调用的前两次返回10,之后返回20。它也可以验证函数是否被调用了,只调用3次、至少5次或类中的这个函数的调用是符合给定的顺序的。
因为存根对象和模拟对象常常难于理解,所以让我们通过一个例子说明一下。我所使用的例子是小孩子玩的纸牌战争游戏。这是一个简单的游戏,一副牌平均分给两个玩家。每个玩家翻开显示他们最顶上的那张牌,谁的牌大,谁就获得这两张牌。如果遇到同样大小的牌,就放置三张面朝下的牌,而第四张面朝上。这时谁的牌大谁就赢得桌面上的所有牌。最后赢家是收集到所有的牌。(译者注:更多的游戏细节请参见: http://en.wikipedia.org/wiki/War_(card_game) 规则略有不同。)
我将用三个类来为这个游戏建模:
(1).纸牌(Card):表示单张纸牌,可以和另一张牌比大小。
(2).整副牌(Deck):用来保存洗牌和发牌的函数。
(3).战争游戏(WarGame):用来管理游戏逻辑,结束整个游戏和返回游戏的胜利者。
在游戏的时候,Deck对象返回一张随机的牌。不过,出于测试的目的,你可以创建一个存根对象返回预先定义好顺序的牌。假设WarGame对象通过它的构造函数接收Deck做为参数,你可以很容易地测试WarGame的逻辑,只要给它传送一个定义了特定的和可重复的牌序列的StubDeck。
这个StubDeck是从Deck类继承的,这表示你必须把Deck设计成一个基类。也就是说,出于测试目的,应该把析构函数和其它任何需要重写的方法都设置成虚的。下面给出Deck类的声明。
[代码 P308 第一段]
因此,我们的StubDeck类就可以从Deck继承了,重写的Shuffle()方法什么也不用做,因为你不需要随机化牌的顺序。接着,StubDeck的构造函数就可以创建特定顺序的牌。然而,这意味着存根类是把单张牌顺序硬编码了。有种更为通用的解决方案是用AddCard()方法扩展这个类。接着你就可以使用StubDeck来编写多个测试,只要简单地调用几次AddCard(),在把它传入到WarGame之前让纸牌顺序按照一定顺序准备好就可以了。这样做的一种方法是往Deck基类(因为它修改私有状态)中添加一个受保护的AddCard()方法,接着在StubDeck中暴露成公共的。你可以这样编写:
[代码 P309 第二段]
因此,那就是存根对象的样子(事实上,这也可以认为是一个伪对象,因为它提供了完整的功能,除了随机性元素)。现在,让我们看看用模拟对象测试是什么样子。
模拟和存根对象的最大不同是模拟对象坚持行为验证。也就是说,模拟对象用来为对象记录所有函数的调用和它验证诸如函数调用次数的行为,参数会被传入到函数里,或者是几个函数调用的顺序。手动编写代码来执行这种操作是比较枯燥且容易出错的。因此,最好依赖模拟测试框架来为你自动完成这项工作。这里我使用的是谷歌模拟框架(Google Mock framework, http://code.google.com/p/googlemock/)来说明如何使用模拟对象来测试我们的WarGame类。你要这么做的第一件事就是使用Google Mock提供的便捷宏来定义模拟对象。
[代码 P309 第三段]
MOCK_METHOD0是用来表示函数不包含参数,这适用于Deck基类的所有方法。如果你有个带有一个参数的方法,那么你就可以使用MOCK_METHOD1,等等。现在,让我们使用这个模拟来编写一个单元测试。正如我使用Google Mock来创建我们的模拟,我也会使用Google Test(谷歌测试)做为测试框架。如下所示:
[代码 P310 第二段]
现在看看比较巧妙的那两行EXPECT_CALL()代码。第一个陈述的是我们至少要调用一次这个模拟对象的Shuffle()方法,第二个陈述的是DealCard()方法应该调用整整52次,第一个调用将返回Card("JS"),而第二个调用会返回Card("2H")。值得注意的是这种方法意味着你不需要为模拟对象暴露AddCard()方法。模拟对象会隐式验证所有的做为它的析构函数一部分的预期,如果这些预期有任何一个无法满足,测试就会失败。
提示
存根和模拟对象都可以返回固定的响应,但是模拟对象也会执行调用行为验证。
就这个会如何影响API设计而言,有个隐含之处是你可能希望考虑一个访问不可预知的资源的模型,该模型是在可以传给实现逻辑类的基类中具体化的,如在前面给定的例子中:你可以把Deck对象传递给WarGame对象。这允许你在测试代码中可以使用继承来替代一个存根或模拟版本。这从本质上说是依赖注入模式,依赖对象被传入到一个类中,而不是类直接负责创建和存储那些对象。
然而,有时候在实际中要把所有的外部依赖都封装并传入是没那么容易的。在这些情况下,你仍然可以使用存根或模拟对象,而是使用继承来代替功能[l7],你可以在链接时注入它们。在这种情况下,你应该把存根/模拟类命名成你要替换的类的名字。接着你可以把测试程序链接到测试代码,而不是真正的实现代码。使用我们讲过的ReadFileAsString()例子,你可以创建这个函数的一个可选的版本,返回固定的数据。接着,利用存根链接对象.o文件到我们的测试程序,用来取代持有真正实现的对象文件。这种方式是非常强大的,这在你要访问文件系统、网络等时是非常必要的抽象。如果你的代码要从标准库中直接调用fopen(),那么你就无法在链接时用存根代替这个,直到你提供了你的代码中要调用的所有其它标准库函数的存根。
10.4.3 测试私有代码
本书一再强调的是在设计良好的API开发过程中要提供一个逻辑抽象的同时隐藏好实现细节。然而,这也会造成编写单元测试的困难。为了实现覆盖全部的代码,你需要多次编写访问类的私有成员的单元测试。给定一个叫MyClass的类,这可以通过两种不同的方式来实现:
(1).成员函数:声明一个公共的MyClass::SelfTest()方法。
(2).友元函数:创建一个MyClassSelfTest()*函数并把它声明成MyClass中的友元函数。
我曾在第六章详细说明了应该避免使用友元类的几条原因,不过在这里友元函数是安全的,因为MyClassSelfTest()函数和MyClass的实现一样,都是定义在相同的库中,如此可以阻止用户在他们自己的代码中重新定义这个函数。值得注意的是,Google测试框架提供了一个FRIEND_TEST()宏来支持这种类型的友元函数测试。然而,因为这两种选项在功能上是等价的,而且我们除非在绝对需要的情况下才会使用友元,因此我将专注于这些选项的第一个:添加一个公共SelfTest()方法到类中,以便测试它的内部细节,不过这里讨论的也可以同样地应用到友元函数解决方案中去。
例如,这里有一个简单的包含自测方法的边框类。
[代码 P311 第一段]
因此,SelfTest()方法可以直接从单元测试中调用,这是为了执行额外的各种私有方法的验证。虽然这种方法不符合期望的品质要求,但是这对于测试是十分方便的。换句话说,你不得不让你的公共API变得没那么合适了,因为有个用户不应该调用的API方法,而且你也会在BBox实现中嵌入测试代码,让库变得臃肿。
对于第一种情况,你有几种方式可以阻止用户使用这个函数。有种没什么价值的方法是简单地给方法添加一个注释:这个方法不供外部使用。更进一步的做法是,你在生成的API文档中移除这个方法,这样的话用户就不会知道对它的引用(除非他直接查看你的头文件)。在Doxygen工具中,你可以在函数周围添加\cond和\endcond命令来实现这个。
[代码 P312 第二段]
现在要关注的是自测函数会让代码变得臃肿,如果你觉得有必要的话,是有几种方法可以解决这个问题的。其中一种方法是在单元测试代码中实现SelfTest()方法,而不是在API代码中。例如,在test_bbox.cpp中,而不是在bbox.cpp中。这仅仅是因为你在.h文件中声明一个方法并不意味着你得定义它。然而,这会带来一个与使用友元相似的安全漏洞。也就是说,你的用户可以在他们自己的代码中定义SelfTest()方法,这样就可以修改对象的内部状态。虽然这个函数的接口限制他们这么做,因为他们无法传递任何参数或接收任何结果,但是他们仍然可以使用全局变量来解决这个问题。
另一种方法是对测试代码使用条件编译。例如:
[代码 P312 第三段]
这种方法的缺点是你得构建两个API版本:一个编译的是包含自测代码(用-DTEST或/DTEST编译),另一个是不含自测代码。如果构建的另一个是有问题的,你可以编译自测代码到你的库中的debug版本,但是在构建发布版本时予以移除。
提示
使用SelfTest()成员函数来测试一个类的私有成员。
值得注意的是:如果你希望为一个C API提供一个自测函数,那么这是一个简单得多的提议。例如,你可以在.c文件中定义一个外部链接SelfTest()函数。也就是说,这是在Windows中用__declspec(dllexport)修饰的一个非静态函数,但是在.h文件中没有提供原型。接着你在测试代码中声明函数原型,以便做为单元测试中调用函数的一部分。通过这种方式,函数不会出现在头文件或任何API文档中。事实上,用户要发现这个调用的唯一方式就是在共享库中查阅所有公共符号。
10.4.4 使用断言
断言是验证代码中所做的假设的一种方式。你可以使用断言函数或宏来为假设编码。如果表达式的计算结果是真,那么所有的都很好,什么也没事。然而,如果表达式的计算结果为假,那么你在代码中创建的假设就是无效,程序就会因相应的错误而中断(McConnell, 2004)。
断言是为程序构建额外的健全测试的一种基本方式,可以直接放置在程序代码中。因此,这些对测试和调式来说是很有帮助的。
虽然你可以*编写自己的断言例程,但是C标准库中已经包含了在assert.h头文件中的assert()宏(在C++中也是可用的,是cassert头文件)。下面的例子说明了使用这个宏来展示如何记录和强制你即将解除引用的指针为非NULL。
[代码 P313 第一段]
通常情况下在发布代码时会关闭所有的assert()调用,以便最终用户程序运行时不会突然地中断。比起调式版本,当在发布版本编译时通常让断言调用什么也不做。(因为这个原因,你绝不应该把总是要运行的代码放到一个断言中去。)下面有个简单的断言例子,只会在调式时激活。
[代码 P314 第一段]
断言应该用来做为你(做为开发人员)认为应该永远不会发生的文档条件。它们并不适用于运行时的可能合乎逻辑地发生的错误条件。如果你可以从一个错误很好地恢复,那么你应该总是偏好导致这种行为的过程,而不是会导致用户程序崩溃的过程。例如,你如果你有个从用户处接受一个指针的API调用,你不应该认为它是非NULL的。你应该检测指针是否是NULL,如果是的话得处理好返回结果,潜在地给出一个合适的错误信息。对于这种情况,你不应该使用断言。然而,如果你的API强加这样的条件:私有成员变量总是非NULL的,那么它曾经是NULL的话,它就会成为一个编程错误。这种情况下,断言就是合适的。总之,使用断言来检测程序错误;使用普通的错误检测来测试用户错误,并在那种情况下良好地恢复。
提示
使用断言做为文档和验证绝不应该发生的编程错误。
断言在商用产品中是普遍时候用的,用来诊断错误。例如:据报道,几年前的微软Office套装有超过250,000个断言(Hoare, 2003)。这些常常用来连接其它的自动化测试技术,在断言打开的情况下,在debug代码上运行大型的单元和集成测试用例。如果有任何的测试代码遇到断言失败,运行的测试就会失败。在导致用户代码崩溃前,就允许开发人员跟踪并查明失败的原因。
展望未来,C++0x草案标准建议了一个新的编译时断言叫:static_assert()。这不是C++98标准的一部分,但是很可能包含到C++的下一个版本规范中。不过,一些C++编译器中已经提早支持这个新的特性,如:Visual Studio 2010和GNU C++ 4.3。使用static_assert(),如果常量表达式在编译时解析为false,那么编译器就会显示提供的错误信息并无法通过编译;否则,语句就不会起作用。例如:
[代码 P314 第二段]
10.4.5 契约式编程
Bertrand Meyer发明和注册了术语“根据契约设计”,规定了接口和它的用户之间的义务(Meyer, 1987)。对于函数调用,这意味着在调用函数前,用户必须满足指定的前置条件;在函数退出时要满足后置条件。对于类,这意味着定义它在公共方法调用前后所维持的不变式。(Hoare, 1969; Pugh, 2006)
上一章讨论了通过API文档来向你的用户展示如何沟通这些条件和约束。这里我将说明如何使用断言风格的检查来在代码中实现它们。例如,继续之前介绍过的SquareRoot()函数,下面的代码显示了如何为它的前置和后置条件实现这些测试。
[代码 P315 第一段]
本例中的require()和ensure()都是用上一个章节中相同风格的assert()宏断言描述的。也就是说,如果条件的技术结果为true,它们就什么也不做,否则它们就退出或抛出一个异常。正如断言的使用,在发布版本中通常都会禁用这些调用,这样就可以避免在产品环境中的开销且避免用户程序退出。换句话说,你可以按照下面的方法简单地定义这些函数。
[代码 P315 第二段]
此外,你可以为你的类实现一个私有的方法来测试它的不变式,它是在有效的状态。接着,在函数开始和结束时,你可以从函数中调用这个方法来确保对象是处于有效的状态。如果你为这个方法使用一个一致的名称(你可以通过一个抽象基类来实现),那么你可以像下面这样给require()和ensure()宏增加一个check_invariants()宏。
[代码 P315 第三段]
把上面讲过的全部整合起来,这里有一个关于字符串添加的契约式编程的例子。
[代码 P316 第一段]
有个有趣的注意点是:Meyer起初对契约式编程的构想是在他的Eiffel语言中为这项技术添加显式的支持。他也使用了断言模型来实现这个支持,正如我这里做的一样。然而,在Eiffel中,这些断言可以自动抽取成类的文档。因为C++并没有这种功能,所以你必须手动为接口确保实现中的断言和文档相匹配。
采用这种契约式编程的一个好处是错误发生时所标记的位置比出问题的实际出处要接近很多。这在试图调试一个复杂的程序时就会感受到有很大的差异,因为常常会发生导致问题的点和错误源相离甚远。这就是使用断言的基本好处。
提示
通过系统使用断言来强制接口的契约,如require()、ensure()和check_invariants()。
有个特别重要的建议就是要记住:当采用这种编程风格是测试接口的,而不是其实现。也就是说,前置和后置条件的检测应该是针对API的抽象层的。它们不该取决于你的特定实现细节。否则你会发现,一旦你修改了实现,你就得修改契约。
提示
对接口执行契约检测,而不是实现。
10.4.6 记录和回放功能
测试(和许多其它任务)有个非常有用的特性就是能够记录API调用的序列,接着可以根据需要回放(即再次调用记录过的序列)。记录和回放工具在舞台程序或GUI测试中相当普遍,如用户交互的按钮点击和按键捕获,接着回放重复用户的动作。然而,同样的原理可以应用到API测试中。这包括检测API中每个函数的调用,把它的名字、参数和返回值记录到日志里。接着,可以编写一个回放模块来接收这个日志、文件和调用每个函数序列,检查实际的返回值是否匹配之前记录的响应。
通常在默认情况下这项功能是关闭的,以便创建日志文件的开销不会影响API的性能。然而,可以在产品环境中打开这项功能,用来捕捉最终用户的活动。这些日志文件可以添加到你的测试套装中,做为数据驱动集成测试或在调式中用于回放,以帮助隔离问题。你甚至可以根据现实中的使用情况来完善API的行为,如侦测常见的无效输入和添加更好的错误处理。你的用户也可以在他们的程序中提供这个功能,允许他们的最终用户记录他们的行为并自行回放,也就是程序中自动反复执行的任务。这在最终用户程序中通常也叫做宏功能。
使用这种风格有几种不同的方法来检测API。有种最明了的方式是为主API引入一个代理API,但是也负责管理所有的函数调用日志。通过这种方式,你不会因为这些细节而影响实际的API调用,而且你总是可以装载一个普通的不带任何日志功能的API。下面的这个例子将演示这些。
[代码 P317 第一段]
当然,如果你已经有一个包装API,如脚本绑定或便捷API,那么你可以简单地重用那个接口层。这也是一个执行API契约式测试的好地方,正如在上一个章节讲过的一样。
Gerard Meszaros注意到:记录和回放技术可能对于敏捷方法学是适得其反的,如测试优先开发。不过,他指出:当日志是存储在人们可读的文件格式中时,如XML,还是有可能联合使用记录和回放与测试优先开发的(Meszaros, 2003)。当在这种情况下,记录和回放的基础结构可以早早构建,接着测试可以写成数据文件,而不是代码。这在有更多初级QA工程师时,会带来额外的好处,他们也可以为测试套件进行数据驱动的集成测试。
为API添加健壮的记录和回放功能将是一个重要的工作,不过这种花费通常是值得的,你可以考虑一下更快的测试自动化所带来的好处,还有就是可以让你的用户能够容易地捕捉到错误报告中再现的样例。
10.4.7 支持国际化
国际化(i18n,译者注:internationalization 的缩写形式,意即在 i 和 n 之间有 18 个字母,是指软件的"国际化")也就是让软件产品支持不同语言和地区差异的功能。相关的术语本地化(l10n)是指使用底层的国际化支持,提供特定语言的程序文本翻译和为特定的区域定义本地设置,如数据格式或货币符号。
国际化测试可以用来确保产品完全支持给定的区域设置或语言。这种测试属于最终用户程序的测试,也就是使用用户偏好的语言来测试程序中显示的菜单和信息。然而,API开发过程中的设计决策会影响到你的用户是如何在他们的程序中支持本地化的。
例如,你在某个语言中喜欢返回整数错误代码,而不是错误信息。如果你返回的是错误信息,那么这可以用来在合适的头文件中定义所有潜在的错误信息,以便你的用户能够访问并进行适当地本地化。还有,你应该避免返回数据或格式化数字的字符串,因为这些在不同的跨区域中有不同的解释。例如,“100,000.00”在美国和英国是有效的数字,但是在法国同样的数字会被格式化为“100 000,00”或“100.000,00”。
有几个库提供了国际化和本地化功能。你可以使用这些库中的一个来为用户返回本地化字符串,让他们为API返回的字符串指定首选区域。这些库常常都是非常容易使用的。例如,GNU的gettext(获取文本)库提供的gettext()函数用来查询一个字符串的翻译并返回当前区域的语言版本(假设已经提供了翻译版本)。这个gettext()函数常常有个别名“_”,方便你编写简短的代码,如下所示:
[代码 P318 第一段]
相似地,Qt库也提供了非常好的国际化和本地化特性。所有的QObject子类都使用Q_OBJECT宏有一个tr()成员函数,它和GNU的gettext()有相似的功能,例如:
[代码 P318 第二段]
10.5 自动化测试工具
这个章节让我们看一下用来支持自动化测试的工具。我将把这些内容分成四类:
(1).测试框架:为自动测试提供易于维护、运行和报告结果的软件库和程序。
(2).代码覆盖:这些工具可以用来指示你的代码跟踪测试执行的语句或分支。
(3).错误跟踪:这个数据库驱动的程序允许为你的软件提供缺陷报告和特性请求的提交、优先级区分、分配和解析。
(4).连续构建系统:用来重新构建软件的系统,一旦有新的变动添加时会返回自动化测试。
10.5.1 测试框架
C和C++有很多可用的单元测试框架。这些的绝大多数都和经典的JUnit框架有相似的设计,所提供的特性支持有:基于断言的测试、夹具安装(fixture setup)、多个测试的夹具分组、模拟对象等。除了能够定义单个测试,一个好的测试框架应该也提供一种一次运行整个测试套装的方法,并报告失败的总数。
我不会在这里描述所有可用的测试框架,如果你感兴趣的话可以搜索一下“C++ 测试 框架”(C++ test frameworks)就会得到很多这方面的工具。不过,这里我将给出很多比较流行或有趣的框架。
qCppUnit (http://cppunit.sourceforge.net/):最初是由Michael Feathers为C++设计的JUnit的一个端口。这个框架支持各种辅助宏,用来简化测试声明、异常捕获和大量的输出格式,包括XML格式和一个用来和IDE集成的类似编译器(compiler-like)的输出。CppUnit还提供很多不同的测试过程管理工具,包括Qt和基于MFC的GUI测试过程管理工具。CppUnit的1.0版本已经比较稳定,将来开发的版本将直接是2.0版。Michael Feathers还开发了一个非常轻量的CppUnit的可选版本,叫做CppUnitLite。这里有一个使用CppUnit编写的测试用例的例子,这个例子是基于CppUnit的使用手册。
[代码 P319 第一段]
qBoost测试(http://www.boost.org/):Boost包含一个可以用来编写测试程序的测试库,能够把测试组装成简单的测试用例和测试套装,并且可以对它们在运行时的执行进行控制。这个库的核心价值是可移植性。因此,它采用的是一个保守的C++特性子集,最大限度地减少对其它API的依赖。这就允许该库用做其它Boost库的移植和测试。Boost测试提供了一个可以捕捉测试代码中的异常的执行监视器,还提供了一个程序执行监视器,可以从最终用户程序处检测到异常和非0返回码。下面的例子,来自Boost测试手册,演示了如何通过这个库来编写一个简单的单元测试。
[代码 P320 第二段]
qGoogle Test(谷歌测试,http://code.google.com/p/googletest/):Google C++测试框架为C++提供了一个JUnit风格的单元测试框架。它是一个支持自动化测试发掘(例如:你不需要手动枚举测试套装中的所有测试)和丰富的断言,包括致命断言(fatal assertion,如ASSERT_*宏)、非致命断言(EXPECT_*),以及所谓的死亡测试(death test,检测程序按照期望终止)跨平台系统。Google Test还为运行测试提供了各种选项并提供了文本和XML格式的报告。正如前面提到过的,Google也提供了一个模拟对象测试框架,Google Mock,很好地集成在Google Test中。下面的代码演示了如何使用Google Test创建一个单元测试套装。
[代码 P321 第一段]
qTUT (http://tut-framework.sourceforge.net/):模板单元测试(Template Unit Test ,TUT)框架是一个小型可移植的C++单元测试框架。因为它只由头文件组成,并未链接或部署任何库。测试都组织到命名的测试组中,该框架也支持你定义的所有测试的自动发掘。还提供了很多测试报告类型,包括基本的控制台输出和CppUnit风格的报告模式。你也可以利用TUT提供的扩展报告接口来编写自己的报告格式。这里有一个利用TUT框架编写的简单的单元测试。
[代码 P322 第一段]
10.5.2 代码覆盖
代码覆盖工具可以让你精确地了解测试中所覆盖到的代码语句。也就是说,通过这些工具,你可以关注那些未在测试中涵盖的代码部分。
代码覆盖有不同的衡量尺度。我将通过下面的代码样例加以说明。
[代码 P322 第二段]
q函数覆盖:这种代码覆盖是比较粗糙的级别,只有函数调用会被追踪到。在本例代码中,函数覆盖只会记录TestFunction()是否至少被调用一次。函数中的控制流不会影响函数代码覆盖的结果。
q行覆盖:这种形式的代码覆盖测试是关于测试是否涵盖了可执行语句的每行代码。这种度量的一个限制可以从我们的代码样例的Line 1(行1)中看出来。即使a++语句没有执行,也认为行覆盖100%执行了Line 1;它只关心是否执行了控制流。显然,你可以通过把if条件和a++语句放到单独的行来解决这个限制。
q语句覆盖:这种测量度量的是控制流是否至少一次到达过每个可执行的语句。这种形式的覆盖的主要形式就是它不会考虑因控制结构(如if、for、while或switch语句)造成的代码路径。例如,在我们的代码样例中,语句覆盖会告诉我们Line 3的条件的计算结果为true,这会导致Line 4执行。然而,如果那个条件计算为假时它就不会告诉我们,因为那个结果不会有相关联的可执行代码。
q基本块覆盖:基本块是指不能进入或离开分支的一系列语句。也就是说,如果第一条语句执行了,那么块中剩余的所有语句都会被执行。本质上说,一个基本块都会终结于一个分支、函数调用、抛出或返回。这可以被认为是一种语句覆盖的特殊形式,拥有相同的优点和局限性。
q判定覆盖:这种代码覆盖测量使得程序中每个判断的取真分支和取假分支至少经历一次。这解决了语句覆盖的主要缺陷,因为你会知道Line 3的条件是否为false。这也叫做分支覆盖。
q条件覆盖:条件覆盖决定每个控制结构中的布尔子表达式是否都是计算为true或false。在本例中,这意味着Line 3必须执行a>10、a<=10、b!=0和b==0。要注意的是这在判定覆盖中是不需要的,因这些事件的每一个可以按照一个顺序依次发生,而if语句的总体结果总是计算为false。
度量C++代码覆盖率的程序有很多。这些工具都支持上述我列出的几种度量的组合,通常是检测编译器生成的代码。这些工具的绝大多数是商业版的,不过还是有些是免费和开源的可供选择。
有个特别有用的特性是从分析中排除某些指定的行,这通常是通过在代码行周围添加注释实现的。这可以用来关闭对这些行的覆盖,这样就不会被执行了,就像基类中总是被重载的方法。不过在这种情况下要注意到如果这些被排除的代码在将来会被执行的话会导致覆盖工具产生一个错误,这可能会发出一个行为上不可预期的更改通知。
另一个要记住的问题就是执行的代码覆盖分析应该是基于未经优化的编译,因为编译器会在优化时重排或消除个别代码行。
下面列表是关于一些优秀的代码覆盖分析工具的简介。
qBullseye Coverage(http://www.bullseye.com/):这个覆盖工具,来源于Bullseye测试技术,提供函数、条件/判定覆盖,为你提供了一系列覆盖精度。它提供的特性包括覆盖系统级别和核心模式代码,从分布式测试融合结果并和微软的Visual Studio集成。它也可以从分析中排除指定的代码部分。Bullseye是一个可以支持大量平台和编译器的成熟产品。
qRational PureCoverage (http://www.rational.com/):这个代码覆盖分析工具是做为IBM的PurifyPlus套装软件的一部分销售的。它报告的覆盖范围包括:可执行、库、文件、函数、块和代码级别。PureCoverage能够积累多次运行之上的覆盖并合并来自不同程序(这些程序共享相同的源代码)的数据。它还提供图形化和文本输出,可供你查看其结果。
qIntel Code-Coverage Tool (http://www.intel.com/):本工具包含在英特尔的编译器内,运行于那些编译器生成的指令文件之上。它提供函数和基本块级别的覆盖并能够只分析那些感兴趣的模块。它也支持差别覆盖,也就是可以比较两次运行的输出结果。英特尔的代码覆盖工具可以运行在Windows或Linux的英特尔处理器上。
qGcov (http://gcc.gnu.org/onlinedocs/gcc/Gcov.html):这个测试覆盖程序是开源的GNU GCC编译器集的一部分。它通过g++使用-fprofile-arcs和-ftest-coverage命令操作其生成的代码。Gcov提供函数、行和分支代码覆盖。它采用文本格式输出报告。不过,附带的lcov脚本能够用来输出HTML格式的报告(见图10.3)。
[图 P324 第一张]
图10.3
本例是由gcov代码覆盖工具通过Icov生成的HTML报告。
一旦你构建好代码覆盖,你就可以为API查询覆盖报告和为你的代码开始实现代码覆盖目标。例如,您可以指定所有代码都必须达到一个特定的阈值,如75%、90%或100%覆盖。这种你选定的目标阀值主要取决于你想要采用的覆盖模型:实现100%的函数覆盖相对来说比较容易,而100%的条件/判定覆盖就困难很多。从经验上看,一个比较合理的代码覆盖尺度是:函数是100%、行是90%或者条件覆盖是75%。
还有一个值得解决的是代码覆盖和老旧代码的问题。有时你的API必须依赖于大量的未经任何测试的旧代码。回想一下Michael Feathers定义的老旧代码就是未经测试的代码(Feathers,
2004)。在这种情况下,如果对老旧代码要像新代码那样强制一样的代码覆盖目标就不那么切合实际了。不过,你至少可以做些适当的基本测试并且操作时间不低于当前的覆盖级别[l8]。这表明对老旧代码的任何新的改动都应该进行测试。有时这种每次强制操作时间是比较困难的(因为你要根据修改来构建软件,并且在运行所有测试后才知道是否满足能够接受),另一种合理的方式是记录上个版本库的老旧代码覆盖情况,确保在发布时新版的覆盖等于或超过它。这种处理老旧代码的方法是比较实用的,这样就可以随着时间的推移把代码覆盖提高到可以接受的程度。
本质上,API中不同模块或库会有不同的代码覆盖目标。在过去,我会通过更新代码覆盖报告明确的显示每个模块的目标,还通过配色方案来指出目标是否已经满足每个样例。这样你在浏览报告时就可以一目了然地知道测试级别是否达到目标。
10.5.3 错误追踪
错误追踪系统(Bug tracking system)程序是为你的软件项目把错误(常常还有建议)记录到数据库中。一个高效的错误追踪系统是一个非常宝贵的工具,可以很好地映射到开发和质量控制过程中。相反地,一个差的错误追踪系统将很难用,无法完全显示出软件状态,这会成为项目中主要瓶颈。
绝大多数追踪系统都支持对新的错误进行分类。也就是设置一个错误的优先级别(也叫做严重级别)并把它分配给某个特定的开发人员。还有一个标准功能是为系统中的错误定义过滤器,以便创建有针对性的错误列表,例如打开崩溃错误列表或分配给特定开发人员的错误列表。与此相关的还有一些错误追踪系统还提供报告生成功能,这常常是显示成图表和饼状图。这对于生成软件质量度量是必不可少的,加上代码覆盖结果可以用来更为高效地进行更多的测试。
还需值得注意的是错误追踪系统不是什么。举个例子,它不是一个故障或问题追踪系统。这是一个用户支持系统,用来从用户处接收反馈,可能有很多是和软件问题无关的。由用户发现的有效软件问题都会被输入到错误追踪系统中并分配给工程师解决。错误追踪系统的不是一个任务或项目管理工具,也就是说:让你记录任务和计划工作的系统。不过,一些错误追踪系统厂商还提供了全面的产品,利用底层系统提供一个项目管理工具。
市面上有很多错误追踪系统,至于哪个最适合你的项目取决于如下几个因素:
q你可能更喜欢开源的解决方案,以便你在需要时可以进行自定义。例如,很多大型的开源项目在使用Bugzilla,包括Mozilla、Gnome、Apache和 Linux 核心。请参见:http://www.bugzilla.org/。
q也有很多商业软件套装提供良好和灵活的错误追踪功能,还支持可选地安全托管。Atlassian的JIRA是一个比较流行的解决方案,提供了可定制的和健壮的错误追踪系统。Atlassian还为敏捷开发项目提供了相关的GreenHopper项目管理系统,让你可以管理待办的来自用户的功能点描述、任务故障和冲刺与迭代计划(sprint/iteration planning,具体内容可参考敏捷开发)等。请参见:http://www.atlassian.com/。
q或者,你决定采用一般的拥有修订控制特性的项目托管解决方案、磁盘存储配额、论坛和一个集成的错误追踪系统。Google Code Hosting是这种类别的一种流行选择。请参见:http://code.google.com/hosting/。
10.5.4 连续构建系统
一个连续构建系统是一个自动化处理系统,一旦在修订控制系统检测到新的改动就会立即重新构建你的软件。在一个拥有许多工程师的大型项目中,连续构建系统应该是一项首要的技术,独立与任何需要的测试。它让你知道当前构建的状态和在一有问题发生时就立即识别到。它在跨平台开发中也是非常有价值的,因为在一个平台中,即使是经过非常好的测试的修改也可能在不同的平台上发生问题。市面上有几种连续构建系统可供选择,如下所示:
qTinderbox (https://developer.mozilla.org/en/Tinderbox):一个来自Mozilla团队的开源解决方案,可运行构建和测试套装。请参见图10.4,这是一个Tinderbox界面的例子。
qParabuild (http://www.viewtier.com/):来自Viewtier系统的企业级软件,是一个构建和发布管理系统。
qTeamCity (http://www.jetbrains.com/teamcity/):一个来自JetBrains的分布式构建管理和连续集成服务系统。
qElectric Cloud (http://www.electric-cloud.com/):支持并行构建的工具套装,可以用来自动化构建/测试/部署。
然而,我们这里关注的是自动化测试。不过连续构建系统对于测试确实带来实际的好处,它提供的机制也会按照一定的规则自动运行测试,这样就可以快速发现测试中问题。也就是说,自动化构建有四种不同状态的结果:进行中、通过、构建失败或测试失败。
随着测试规模的增加,最后就要开始运行所有的测试。如果你的测试需要几个小时才能完成,那么你应该对测试进行一些优化,这样就可以节省构建时间。实现这个的一种方式是把测试分配到不同的分类中,在连续构建中只运行最快的测试分类。另一种方式是使用多个自动构建,一个只构建软件,接着运行所有的测试。这可以让工程师快速接收到构建中带来的问题反馈,而且还能确保完整的测试可以尽可能地经常运行。
[图 P327 第一张]
图10.4
Tinderbox连续构建系统界面显示了八种不同的构建(多个列)。垂直的轴代表时间,顶部表示最新的构建。这显示过去bill的签入和为Mac Opt构建分隔开的测试,接着是fred为SunOS修复构建的签入。