带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

点击查看第一章
点击查看第三章

第2章

构建安全体系
测试是一项技能,虽然这可能会让一些人感到惊讶,但这是一个事实。
—Mark Fewster and Dorothy Graham,《自动化软件测试》,1999
我将测试作为本书的开篇可能会让一些读者感到意外,但请相信我,这样做有几个好处。在过去的几年中,测试已经成为衡量软件质量好坏的一个重要指标。一个好的测试策略所带来的好处是巨大的。任何测试(前提是认真设计过的)对代码质量的提高都是有好处的。在保证软件质量的所有措施中,测试是最不可或缺的一环,在本章中我将为你解释这是为何。
请注意,本章所讲的内容通常称为POUT(Plain Old Unit Testing,普通的单元测试),而不是TDD(Test-Driven Development,测试驱动开发,一种软件开发模式),后者将在本书的后面章节另外讨论。

2.1 测试的必要性

1962:NASA的水手一号
水手一号太空飞船于1962年7月22日发射升空,计划飞向金星执行星际探索任务。由于它的定向系统出了一个问题,导致它的Atlas-Agena二级发射火箭工作异常,并在发射后不久便与地面控制中心失去联系。
幸运的是,在火箭设计与建造阶段就已经考虑到了这种情况。于是发射火箭的导航系统接过了控制权并开启自动驾驶模式。然而,由于导航系统软件设计问题,它下达了一个错误的控制指令,导致火箭偏离航线并且不能调整方向,而火箭的前进方向变成了地球上的人口密集区域!
在火箭发射293秒后,现场的地区安全官员下达了销毁火箭的命令。在NASA的一份检测报告中显示,这次事故是由于控制系统源代码中的一个拼写错误导致的,代码中缺少了一个“-”号。而这一失误造成的损失高达1850万美元,在当时这可是一笔不小的损失。
如果问一些软件开发人员为什么说软件测试是有好处的而且是有必要的,我想最普遍的回答就是能够减少故障(bug)、错误(error)以及缺陷(flaw)。毫无疑问,这个回答基本正确:软件测试是QA的一个组成部分。
软件的bug通常是令人不愉快的。程序的错误行为通常让用户大为恼火,比如无效的输出或者用户最讨厌的不定时崩溃的问题,甚至诸如在文本框中的文字被截断这样的小问题,也会让用户在日常工作中痛苦不堪。最终导致的结果就是用户满意度下降,甚至用户转而使用其他产品。除了经济上的损失外,软件开发商的专业印象也会因此大打折扣,最糟糕的情况是,公司运营困难,以致大量裁员。
1986:THERAC-25医用加速器灾难
这一事件可以说是软件开发历史上最轰动的一次失败。THERAC-25是一款放射治疗设备,它由加拿大国有企业,加拿大原子能有限公司,Atomic Energy of Canada Limited (AECL)于1982年至1985年研发并生产,共生产了11台设备以供美国和加拿大的诊所使用。
由于质量保证体系不完善,以及开发过程中存在的其他问题,使得它的控制系统中存在严重的bug,直接导致三名病人死于过量的辐射,还有三名患者由于辐射遭受健康永久的、严重的损坏。
此次事件的调查表明,这款设备的控制系统由同一个人开发并测试,这是导致这一悲剧的诸多因素之一。
一提起电子设备,人们首先想到的就是台式电脑、笔记本电脑、平板或者智能手机,而说到软件产品,人们联想到的就是线上购物、办公软件以及信息商务系统。
但是这些只占我们在日常生活中接触到的软件和电子产品的一小部分,目前使用的绝大部分软件都是通过控制实体设备与外界相连。我们的生活由软件掌控。可以这么说,目前软件影响着我们所有人。软件无处不在并逐渐成为我们生活中必不可少的一部分。
当我们走进电梯,我们的生命就由软件掌控。飞机也由软件控制着,全世界的空中交通管制系统更是离不开软件的管理。目前,汽车上也存在着大量与互联网相连的控制软件,为我们的安全保驾护航。空调、感应门、医疗设备、火车、工厂中的生产线……无论我们想干什么,都会不由自主地与软件产生联系。随着数字革命的进步和物联网(IoT,Internet of Things)的快速发展,我们与软件的联系将会更加密切,无人驾驶汽车就是一个很好的例子。
毫无疑问的是,在这种软件密集型系统中,一旦出现bug将导致灾难性的后果。在这些系统中,任何一个错误都可能对我们的身体和生命构成威胁。试想一下,一旦飞机的控制系统出现异常,很可能导致成百上千的人死于空难,而引发事故的原因可能只是飞机自动巡航系统的if语句条件判断错误。在这种复杂的控制系统中,软件的质量是没有任何商量余地的,完全没有商量余地!
即便是在对人身安全要求没有那么严格的系统中,bug也会造成难以估量的损失,尤其是需要日积月累才会表现出来的bug。不难想象,金融软件中的漏洞将会成为且正在成为当今世界银行危机的导火索。假设一个大银行的金融软件由于bug导致每次提交请求时会重复两次,而这种行为在几天后才被发现,这将会导致什么后果呢?
AT&T电话网络的崩溃事故
1990年1月15日,美国电话电报公司(AT&T)的长途电话网络崩溃,导致9小时内高达7500万次的通话请求得不到响应。而导致这一恶果的原因,仅仅是AT&T在1989年12月,将全部114个计算机控制的交换设备升级到第四代电子交换系统(4ESS)时,部署在代码中的一条break语句。这一问题于1月15日在AT&T公司的曼哈顿控制中心首先爆发出来,随后引起连锁反应,并导致整个通信网络中近半数的设备宕机。
在此事故中,估计损失6000万美元,而在通信网络瘫痪的9个小时内产生的经济损失远高于这一数字。

2.2 测试入门

在软件开发项目中有不同级别的质量保证措施,这些不同级别的质量保证措施通常用金字塔的形式形象地表述,也就是所谓的测试金字塔。这一基本概念是由Scrum Alliance创始人之一、美国软件开发工程师Mike Cohn提出的,他曾在其著作《Succeeding with Agile》[Cohn09]中描述了测试金字塔,Cohn用测试金字塔描述了高效的软件测试所需的自动化程度。在随后的几年里,测试金字塔得到了进一步发展,如图2-1所示。
当然,金字塔形状并非偶然,它背后的信息是,你要比其他类型的测试进行更多次的低层次单元测试(几乎100%代码覆盖率),但是为什么会这样?
实践表明,关于测试实施和维护的总成本是朝着金字塔顶端增长的。大型系统的测试和手动的用户验收测试通常是很复杂的,并且通常需要大规模的组织又不易实施自动化。例如,一个自动化的UI测试是很难编写的,通常是比较脆弱的,而且相对较慢。因此,这些测试通常是手动进行的,它适合于客户审核(验收测试)和QA定期的探索性测试,但是在日常开发过程中使用它们太耗费时间且代价昂贵。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

此外,大型系统测试或UI驱动测试完全不适合检查整个系统中所有可能的执行情况。软件系统中有太多处理各种可能情况的代码、异常和错误处理,交叉相关问题(安全性、事务处理、日志记录……)以及其他所需的辅助功能,但这些通常是无法通过普通用户接口去触发的。
非常重要的一点是,如果系统级别的测试失败了,则可能难以找到错误的确切原因。系统测试通常基于系统的测试用例,执行用例期间涉及许多组件,这意味着要执行数百甚至数千行代码,这其中的哪一行代码导致了测试失败?这个问题通常无法轻易回答,它需要花费时间和代价去分析。
不幸的是,在一些软件开发项目中,你会发现退化的测试金字塔,如图2-2所示。 在这样的项目中,人们把更多的精力投入到了较高层次的测试中,而忽略了基本的单元测试(Ice Cream Cone Anti-Pattern)。在极端情况下,他们完全不做单元测试(Cup Cake Anti-Pattern)。
因此,由一系列可选而有用的测试组件作为支撑,且基于广泛而廉价、精心制作、快速、定期维护、能完全自动化的单元测试的测试平台,可以成为确保软件系统高质量的坚实基础。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

2.3 单元测试

没有测试的“重构”不能称之为重构,它仅仅是到处移动垃圾代码。

           —Corey Haines (@coreyhaines), December 20, 2013, on Twitter

单元测试是一小段代码,在特定上下文环境中,单元测试能够执行产品的一部分代码。单元测试能够在很短的时间内,展示出你的代码是否达到了预期的运行结果。如果单元测试覆盖率非常高,那么,你就可以在很短的时间内,检查正在开发的系统的所有组件是否运行正常。单元测试有许多优点:
□大量的调查和研究已经证明,在软件部署运行以后修复bug的代价,比在单元测试阶段修复bug的代价要高得多。
□单元测试能够给出关于整个代码库(已经写了单元测试的那一部分代码)的即时反馈,如果单元测试覆盖率足够高(大约100%),开发人员在几秒钟内就能知道代码库中的代码是否能够正常运行。
□单元测试让开发人员有足够的信心重构代码,而不必担心因重构而带来的错误。事实上,没有单元测试的重构是非常危险的,严格来讲,不能称之为重构。
□高覆盖率的单元测试,可以有效防止陷入耗时和让人手足无措的代码调试中,可以大大降低长时间使用调试器调试的问题。当然,没有人能够完全避免使用调试器调试。调试器可以用来分析细微的问题,或者找出执行失败的单元测试的原因。但是,调试器不应该是确保代码质量的关键工具。
□单元测试是一种可以被执行的产品文档,因为单元测试精确地展现了代码是如何被设计和使用的。可以说,单元测试是一组非常有用的示例代码。
□单元测试可以很容易地检测回归测试的代码,也就是说,单元测试能够很快地检查更改代码后引发的异常。
□单元测试可以促进实现整洁且良好的接口,可以帮助开发人员避免文件间不必要的依赖关系。可测试性的设计也是良好的可用性的设计,也就是说,如果一段代码可以很容易地与测试夹具集成,那么,这段代码通常也可以很容易地集成到产品的代码。
□单元测试能够促进开发。
上述提到的最后一个优点看似是矛盾的,这里做一下解释,单元测试能够促进开发—这似乎是不可能的事情,也不合乎正常的逻辑。
毫无疑问,编写单元测试意味着成本的投入。首先,最重要的是,管理者只看到了这种成本的投入,却并不明白为什么开发人员应该为测试投入时间。特别是在项目的开始阶段,单元测试对开发速度的促进几乎是看不见的。在项目的早期阶段,当系统的复杂度较低并且大部分组件都工作得很好的时候,编写单元测试看起来只是无意义的付出。但是,时代正在改变……
当系统变得越来越庞大(超过100 000行代码量)且系统复杂度增加时,理解和验证系统变得越来越困难(还记得我在第1章描述的软件熵吗?)。通常,当不同开发团队中的许多开发人员协同开发一个庞大的系统时,他们每天都要面对其他开发者编写的代码,如果没有单元测试,这将成为一项令人沮丧的工作。我确信,团队中的每个人都知道那些愚蠢的、无休止的调试,在单步模式中一遍又一遍地调试代码,同时一次又一次地分析变量的值……这非常浪费时间!并且,这也将大大降低开发速度。
特别是在软件开发的中后期,以及在产品交付后的维护阶段,良好的单元测试会展现出它们积极的一面。在编写单元测试后的几个月或几年里,当一个组件或产品的API需要更改或扩展的时候,单元测试能够最大程度地节省时间。
如果单元测试覆盖率很好,那么开发人员编辑一段自己写的代码或别人写的代码,影响不会太大。良好的单元测试有助于开发人员快速理解另一个人编写的代码,即使这段代码是在三年前编写的。如果单元测试失败,通过失败的信息,能够准确知道失败的地方。开发人员可以相信,如果所有的单元测试都通过了,那么所有的函数都可以正常运行,烦人的调试就会变得不常见。调试主要用于分析那些错误现象不直观的失败的单元测试,这将是一件很有趣的事情。单元测试具有正向的促进作用,能给我们带来更快更好的结果,开发人员也将对基础代码有更大的信心,并对此感到满意。如果更改需求或加入新的特性呢?也没有问题,因为单元测试能够快速、频繁地完成产品的单元测试,并且能够保证产品的质量。
单元测试框架
C++的单元测试框架有很多种,例如:CppUnit、Boost.Test、CUTE、Goole Test等。
一般而言,几个单元测试框架的集合称为xUnit,所有遵循所谓的xUnit的基本设计的单元测试的框架,其结构和功能都是从Smalltalk的SUnit继承而来的。抛开实际情况不谈,本章内容没有涉及某个单元测试框架,因为本章内容适用于一般的单元测试,单元测试框架完整而详细的对比内容将超出本书的范围。进一步讲,选择一个合适的单元测试框架取决于很多因素。例如,如果以最小的工作量和最快的速度添加新的单元测试,对你来说是非常重要的,那么,最小的工作量和最快的速度将成为你选择单元测试框架的主要因素。

2.4 关于QA

开发人员可能会认为:“为什么我要测试我的软件?我们有测试人员和质量保证(QA,Quality Assurance)部门,这是他们的工作。”
关键问题在于:软件质量只是QA部门关注的问题吗?
简单明了的答案是:不是!
我以前说过这个问题,现在我再说一遍,尽管你的公司可能有一个单独的QA小组来测试软件,但开发组的目标应该是QA没有发现任何缺陷。
—Robert C. Martin,《The Clean Coder》[Martin11]
将一个已知的有缺陷的软件移交给QA是非常不专业的行为,专业的开发人员永远不会把保证系统质量的责任推给其他部门。相反,专业的软件开发人员与QA的人建立了富有成效的合作伙伴关系,他们紧密合作,相互补充。
当然,交付100%无缺陷的软件是一个很难达到的目标,QA有时会发现一些问题,这也很好。QA是我们安全体系的第二道防线,他们会检查以前的质量保证措施是否充分有效。
我们可以从错误中学习并变得更好,专业开发人员通过修复QA发现的缺陷来立即补救这些质量问题,并通过编写自动化单元测试在未来捕获这些异常。然后,他们应该仔细考虑这个问题:“以上帝的名义,我们忽略的这个问题是如何出现的?”本次学习总结的成果应该用于以后改善开发的质量。

2.5 良好的单元测试原则

我看到过很多没有任何意义的单元测试代码。单元测试应该为项目带来价值,为了实现这一目标,单元测试应该遵循一些基本原则,下面我将描述这些基本原则。

2.5.1 单元测试的代码的质量

高质量地要求产品代码,同样高质量地要求单元测试的代码。更进一步地讲,理论上,产品代码和测试代码之间不应该有任何区别—它们生而平等。我们不能说这是产品代码,那是测试代码,不能把原本属于一体的代码分开,千万不要那样做!将测试代码和产品代码分成两类的思想是以后项目中忽略单元测试的根本所在。

2.5.2 单元测试的命名

如果单元测试失败,开发人员希望立即知道以下信息:
□测试单元的名称是什么?谁的单元测试失败了?
□单元测试测试了什么?单元测试的环境是怎么样的(测试场景)?
□预期的单元测试结果是什么?单元测试失败的实际测试结果又是什么?
因此,单元测试的命名需要具备直观性和描述性,这是非常重要的,我建议建立所有单元测试的命名标准。
首先,以这样的方式命名单元测试模块(依赖于单元测试框架,称为测试用具或测试夹具)是很好的做法,这样单元测试代码很容易衍生于单元测试框架。单元测试应该有一个像Test的名字,很显然,必须用测试对象的名称来替换占位符。例如,如果被测试的系统(SUT)是Money单位,与该测试单元对应的单元测试夹具,以及所有的单元测试用例都应该命名为MoneyTest(见图2-3)。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

除此之外,单元测试必须有直观的且易理解的名称,如果单元测试的名称或多或少没有意义,比如testConstructor()、test4391()或sumTest(),那么单元测试的名称不会有太大的帮助。通过下面的建议,可以为单元测试取一个好名字。
一般来说,可以在不同场景下使用多种用途的类,一个直观的且易理解的名称应该包含以下三点:
□单元测试的前置条件,也就是执行单元测试之前的SUT的状态。
□被单元测试测试的部分,通常是被测试的过程、函数或方法(API)的名称。
□单元测试预期的测试结果。
遵循以上三点建议,测试过程或方法的单元测试命名的模板,如下所示:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系


下面是几小段示例代码:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

另一个构建直观的且易理解的单元测试名称的方法,就是在单元测试名称中显示特定的需求。这样的单元测试的名称通常能够反应应用程序域的需求,例如,单元测试名称来自于利益相关者的需求。
下面是一些具有特定域需求的单元测试名称的示例:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

当你阅读上面这些单元测试的名称时,即使在没有单元测试代码的情况下,也是非常直观的且易理解的。从这些单元测试的名称中可以很容易地得到许多有用的信息。如果单元测试失败,这样的命名将会是一个很大的优势。几乎所有的单元测试框架都会把失败的单元测试的名称输出到标准输出(stdout),所以,这种直观的且易理解的单元测试命名,极大地促进了错误的定位。

2.5.3 单元测试的独立性

每个单元测试和其他的单元测试都必须是独立的。如果单元测试之间是以特定的顺序执行的,那么这将是致命的,因为一个单元测试的执行依赖于前一个单元测试的影响,例如,改变了类的状态,改变了上下文环境等。永远不要编写“一个单元测试的输出是另一个单元测试的输入”的单元测试。当离开一个单元测试的时候,不应该改变测试单元的状态,这是后续单元测试执行的先决条件。
主要的问题可能是由全局状态引起的,例如,在单元测试中使用单例或使用了静态的成员。单例不仅增加了单元测试之间的耦合度,还经常会保持一个全局的状态,单元测试之间因为全局状态而变得相互依赖。例如,如果一个全局状态是某个单元测试成功执行的先决条件,当前面的单元测试成功执行并修改了这个全局状态时,那么接下来的单元测试就会执行失败。
尤其是在遗留系统中,经常杂乱无章地使用单例模式,这就引出了一个问题:如何才能摆脱这些杂乱无章的对单例的依赖关系,让我们的代码更易于测试呢?这是在第6章的依赖注入部分讨论的一个重要问题。
处理遗留系统
如果你在所谓的遗留系统中添加单元测试时遇到许多困难,我强烈推荐Michael C写的《Working Effectively with Legacy Code》[Feathers07]。这本书包含了许多策略,用于处理大型的、未经测试的遗留代码,这本书还包括了24种依赖中断技术。当然,这些策略和技术超出了本书的范围。

2.5.4 一个测试一个断言

我知道这是一个有争议的话题,但我会试着解释为什么我认为这很重要,我的建议是限制一个单元测试只使用一个断言。如下所示:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

有人可能争辩说我们还可以检查其他比较运算符(例如,Money :: operator==())在该单元测试中是否正常工作,只需添加更多断言就可以轻松实现这一点,如下所示:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系
带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

我认为这种测试方法的问题是显而易见的:
□如果由于某些原因而导致测试失败了,开发人员可能很难快速找到错误原因。最重要的是,前面一个断言的错误掩盖了其他的错误,也就是说,它隐藏了后续的断言,因为测试的执行被中断了。
□正如单元测试的命名一节(2.5.2节)中所述,我们应该以精确且富有表现力的方式命名测试。通过多个断言,单元测试确实可以测试很多东西(顺便说一下,这违反了单一职责原则,参见第6章),并且很难为它找到一个好的名字,上面的...testAllComparisonOperators()仍然不够精确。

2.5.5 单元测试环境的独立初始化

该规则有点类似于单元测试的独立性,在一个干净整洁的单元测试运行完成后,与该单元测试相关的所有状态都必须消失。更具体地说,在运行所有单元测试时,每个单元测试都必须是应用程序的一个独立的可运行的实例,每个单元测试都必须完全自行设置和初始化其所需的环境,这同样适用于执行单元测试后的清理工作。

2.5.6 不对getters和setters做单元测试

不要为类的简单的getters(访问器)和setters(设置器)编写单元测试,如下所示:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

你真的认为这样简单而直接的方法会出问题吗?这些成员函数通常非常简单,因此为它们编写单元测试是愚蠢的。此外,这些简单的getters和setters已经隐式地通过其他且更重要的单元测试进行了测试。
注意,我刚才说到,测试常见且简单的getters和setters是没有必要的,但有时getters和setters并不是那么简单。根据我们稍后将讨论的信息隐藏原则(参见3.5节“信息隐藏原则”),如果getter是简单的,或者它必须通过复杂的逻辑来确定它的返回值,那么它就应该被隐藏起来。因此,有时显式地为一个getters或setters写出单元测试是很有用的。

2.5.7 不对第三方代码做单元测试

不要为第三方代码编写单元测试代码!我们不必验证库或框架是否按预期的那样工作。例如,我们可以问心无愧地大胆假设,调用C++标准库中的成员函数std::vector::push_back()无数次都不会出错。相反,我们可以预测第三方代码都有自己的单元测试。在你的项目中,不使用那些没有自己的单元测试和质量可疑的库或框架,这是一种明智的架构选择。

2.5.8 不对外部系统做单元测试

对于外部系统,道理也和第三方代码一样,不要为你要开发的系统环境中的第三方系统编写测试代码,这不是你的责任。 例如,如果你的财务软件使用一个通过Internet连接的现有的外部货币转换系统,那么你不应对这个外部系统进行测试,这样的系统不能提供明确的结果(货币之间的转换因子每分钟都在变化),并且由于网络问题可能根本无法对其进行测试,我们不对外部系统负责。
我的建议是无视这些东西(见本章后面的测试使用的虚假对象章节),测试你自己的代码,而不是他们的代码。

2.5.9 如何处理数据库的访问

目前,许多软件系统都包含(依赖)数据库系统,将大量的对象和数据长期存储到数据库中,从而可以方便地从数据库查询这些对象和数据,当系统被关闭以后,这些对象和数据也不会丢失。
一个很重要的问题是:在单元测试期间,我们应该如何处理数据库的访问?
我对这个问题的第一个也是最重要的建议是:能不使用数据库进行单元测试,就不使用数据库进行单元测试。
—Gerard Meszaros, xUnit Patterns
在单元测试过程中,数据库可能会引起各种各样的问题。例如,如果许多单元测试使用同一个数据库,那么,这个数据库就会趋向于一个大的集中式的存储系统,这些单元测试必须为不同的目的而共享这个数据库。而这种共享,可能会对本章前面讨论过的单元测试的独立性产生不利的影响,可能很难保证每个单元测试所需的前提条件。一个单元测试的执行,可以通过共享的数据库对其他的单元测试产生不好的影响。
另一个问题是,数据库的存储速度是缓慢的。访问数据库的速度比访问计算机内存的速度要慢得多。与数据库交互的单元测试往往比完全不依赖于数据库的单元测试慢得多。假设你有几百个单元测试,每个单元测试需要额外的平均500毫秒的时间,这很有可能是由于查询数据库导致的。总之,访问数据库的单元测试比没有访问数据库的单元测试要多花费几分钟的时间。
我的建议是模拟数据库(参见本章后面5.2.12节“测试替身”),只在内存中执行所有的单元测试。不要担心,如果系统中存在数据库的使用,那么,在系统集成和系统测试级别会测试数据库

2.5.10 不要混淆测试代码和产品代码

有时开发人员产生了一个想法,用测试代码来装备他们的生产代码。例如,在测试期间,一个类可能以如下方式包含了处理协作类的依赖关系的代码:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

DataAccessObject是特定DAO(数据访问对象)的抽象基类,在本例中为CustomerDAO和FakeDAOForTest,后者就是所谓的测试替身(fake object),这是一个用于测试的虚拟对象(参见本章后面的2.5.12节),目的是替换真正的DAO,因为我们不想测试它,并且我们不想在测试期间保存Customer的数据(谨记我关于数据库的建议)。使用两个DAO中的哪一个由布尔数据成员inTestMode控制。
这段代码虽然可行,但这一解决方案有几个缺点。
首先,我们的生产代码会混杂测试代码,虽然初看并不显眼,但它会增加产品复杂度并降低代码的可读性。我们需要一个额外的成员来区分系统的测试模式和生产使用,这个布尔成员与客户无关,更不用说系统的域了。而且不难想象系统中的许多类都需要这种类型的成员。
此外,Customer类依赖于CustomerDAO和FakeDAOForTest,你可以在源代码头部的包含文件列表中看到它,这意味着在生产环境中测试虚拟类FakeDAOForTest也是系统的一部分,我们寄希望于测试替身的代码永远不会在生产中被调用,但是它确实被编译、链接并部署在了生产中。
当然,也有一些更优雅的方法来处理这些依赖关系,并保证生产代码不受测试代码的影响。例如,我们可以在Customer::save()中注入特定的DAO作为一个参考参数。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

或者,也可以在构造Customer类型的实例期间完成。在这种情况下,我们必须将DAO的一个引用作为类的成员属性。此外,我们必须通过编译器禁止自动生成默认构造函数,因为我们不希望Customer的任何用户可以创建一个未正确初始化的实例。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系
带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

deleted函数[C++11]
在C++中,如果有些类型成员没有被定义,编译器会自动为这些类型生成所谓的特殊成员函数(默认构造函数、拷贝构造函数、拷贝赋值运算符和析构函数)。从C++11开始,这个特殊成员函数列表多了移动构造函数和移动赋值运算符。C++11(及更高版本)提供了一种简单且声明性的方法来阻止自动创建任何特殊成员函数、普通成员函数和非成员函数,你可以删除它们。例如,你可以通过以下方式阻止创建默认构造函数:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

另一个例子:你可以删除new运算符以防止在堆上动态分配一个类:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

第三种替代方案是特定的DAO可以由Customer类已知的一个工厂(请参阅第9章中有关设计模式的Factory模式部分)来创建。如果系统在测试环境中运行,我们可以从外部配置Factory以创建所需的DAO。无论你选择哪种可能的解决方案,Customer类都能与测试代码脱离,Customer与特定的DAO没有依赖关系。

2.5.11 测试必须快速执行

在大型项目中,单元测试的量级早晚会达到上千条。这在软件质量保证方面是有促进作用的。但比较尴尬的是,测试人员也许直到提交代码的时候才会执行它们,因为这项工作耗费的时间过于漫长。
很显然,测试花费的时间和团队的生产力有很大的关系。如果运行单元测试需要花费15分钟、30分钟甚至更多,那么开发人员的工作进度就会由于长时间等待测试结果而受到影响。即使执行每个单元测试平均“只”需要几秒钟,那么执行完1000个测试用例也需要超过8分钟。这就意味着如果这些测试案例每天需要执行10次的话,那么将有1.5小时的时间花在等待上。结果就是,开发人员会减少单元测试的次数。
我的建议是:测试必须快速执行!单元测试必须为开发者建立一套快速反馈机制。一个大型项目的所有单元测试的执行时间最多花费3分钟,当然,越少越好。在开发过程中,为了更快地执行本地的测试用例,测试框架要提供一种简便的方法来暂时关闭不相关的测试组。
毫无疑问,在最终产品发布前,测试平台上的所有测试用例都应该执行到。一旦测试用例执行失败,应当立即通知开发团队。可以通过电子邮件提醒,或在显眼的地方标记出来(比如,在墙上的显示屏上展示出来,或者通过测试平台控制指示灯提醒开发人员)。即使只有一个测试用例不通过,也不能发布产品!

2.5.12 测试替身

单元测试应该只被称为“单元测试”,被测试单元在单元测试执行期间,与依赖系统完全无关,也就是说,被测试单元不依赖其他单元或外部系统。例如,虽然在系统集成测试的时候,数据库的测试不是必要的,但是这是集成测试的目的,所以禁止在实际单元测试期间访问数据库(如查询,参见2.5.9节“如何处理数据库的访问”)。因此,要测试的单元与其他模块或外部系统的依赖性应该被所谓的测试替身(Test Doubles)替换,测试替身也被称为伪对象(Fake Objects)或假模型(Mock-Ups)。
为了以一种优雅的方式使用测试替身,尽量达到被测试单元之间的松耦合(参见3.7节“松耦合原则”)。例如,抽象(如纯抽象类形式的接口)可以在访问单元测试不关心的合作者的时候被引入,如图2-4所示。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

假设你想开发一个应用程序,该应用程序使用外部的Web服务进行货币转换。在单元测试期间,不能正常使用外部服务,因为货币转换因子每秒都在发生变化。此外,通过互联网查询服务是比较慢的,很有可能失败,而且也不能模拟边界值的情况。因此,在单元测试期间,必须用测试替身替换实际货币转换服务。
首先,我们必须在代码中引入一个可变点,可以用一个测试替身替换与货币转换服务的通信模块,通常可以使用一个接口达到这个目的,该接口在C++中是一个仅包含纯虚成员函数的抽象类。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

通过Internet访问被封装在实现CurryNyCurror接口的类中的货币转换服务。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

第二个实现测试目的的方法是:测试替身CurrencyConversionServiceMock。这个类的对象将返回一个预定义的转换因子,在单元测试的时候需要用到这个预定义的转换因子。此外,这个类的对象还提供了从外部设置转换因子的能力,例如,用于模拟边界情况。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

产品代码中,在使用货币转换服务的地方,现在用接口来访问货币转换服务。得益于这种抽象,客户端代码在运行时是完全透明的—无论是访问实际的货币转换服务还是货币转换服务的替身。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

在UserOfConversionService类的单元测试中,测试用例能够通过初始化构造函数把伪对象(mock object)传递给这个类的对象。另一方面,在软件正常运行的情况下,也可以通过构造函数把真实的服务传递给这个类的对象。这种技术称为依赖注入模式,在后面第9章“设计模式和习惯用法”会进行详细讨论。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之二:构建安全体系

上一篇:带你读《More Effective C#:改善C#代码的50个有效方法》之一:处理各种类型的数据


下一篇:带你读《企业安全建设指南:金融行业安全架构与技术实践》之一:企业信息安全建设简介