现在的位置: 首页 > 综合 > 正文

修改代码的艺术—– 2.1 什么是单元测试

2013年01月13日 ⁄ 综合 ⁄ 共 5207字 ⁄ 字号 评论关闭

对系统进行改动有两种主要方式。我喜欢将它们分别称
为编辑并祈祷(edit and pray)和覆盖并修改(cover and
modify)。遗憾的是,前一种方式几乎可算是业界的标准做法。使用这种方式来进行改动时,先是仔细地计划你所要进行的改动,并确保自己理解了将要修改
的代码,然后再开始改动。结束之后,运行修改后的系统,看看所做的改动是否已经生效,再然后就是对系统整体的修复,以确保改动没有破坏什么东西。这最后一
个步骤是必不可少的,且非常重要。在进行改动时,你希望并祈祷能把事情做对,在完成改动后,你要用额外的时间来验证是否真的把事情做对了。

从表面上来看,“编辑并祈祷”这种方式似乎意味着
“小心下手”,这是一件需要专业水平的工作。在事情的一开始就需要小心,而当改动变得非常具有侵入性的时候,你还需分外小心,因为这时出错的可能性就更大
了。然而可惜的是安全性并不取决于你的细心程度。我想谁都不会仅仅因为一位外科医生会非常细心地进行手术就允许他拿着切黄油的刀来切你吧。精湛的软件改动
就像精湛的外科手术一样,除了细心之外还要有深厚的技术。如果没有辅以正确的工具和技术,即便“小心下手”也起不到多大作用。

而“覆盖并修改”则是另一种方式。它背后的理念在
于,在我们改动软件的时候张开一张安全网。这里所谓的“安全网”并不是指放在桌子底下,当我们从椅子上跌下去时能够托住我们的那种,而是像张斗篷一样
“盖”在我们进行修改的代码上面,以确保糟糕的改动不会泄漏出去并感染到软件的其他部分。覆盖软件即意味着用测试来覆盖它。当对一段代码有一组良好的测试
时,我们就可以放心对它进行修改,并快速检验出修改是好是坏。我们仍然会辅以同样的细心,但有了测试的反馈结果,我们就得以进行更为细致的修改。

如果对这种使用测试的方式不熟悉的话,上面这些话听
起来或许就有点儿令人不解了。传统上,测试总是在开发之后编写并执行的。一组程序员编写代码,另一组测试员在代码写好之后对其进行测试,看看它们是否满足
特定的要求。一些非常传统的开发团队正是以这种方式来开发软件的。这类团队也能得到反馈,但整个反馈周期非常长,往往是在几个星期乃至几个月后,另一个小
组的人才会告诉你是否已经把事情做对了。

以这种方式进行的测试实际上可以表述为“通过测试来检验正确性”。虽说这是一个很好的目标,但测试还可以用来做其他事情,我们可以“通过测试来检测变化”。

用传统的术语来说,这叫做回归测试(regression testing)。我们周期性地运行测试来检验已知的良好行为,以便确诊软件是否还像它以前那样工作。

当要动手进行改动的区域由测试包围着时,这些测试的作用就好比一把“软件夹钳(vise)”。你可以用这把“软件夹钳”来固定住目标软件的大部分行为,只改动那些你真正想要改动的地方。

软件夹钳

夹钳:名词,由金属或木头做成的钳夹工具,通常由两个靠螺旋或杠杆进行开合的部件组成,用于木工业或五金业中使物件定位。[《美国英语传统词典》,第4版]

能够起到检测改动的作用的测试就好比是为我们的代码上了一把夹钳,使代码的行为被固定起来。于是当进行改动时,我们得以知道在一个特定的时间只改动某一处的行为。简而言之,我们得以掌控我们的工作。

回归测试是一个好主意。那么为什么人们不更频繁地进
行回归测试呢?这是因为回归测试存在着一个小问题:通常进行回归测试时,都是在应用程序接口(application
interface)层面进行测试。不管目标应用是Web应用、命令行程序,还是GUI程序,回归测试在传统上都是看作一种应用层面的测试。然而这只是一
个不幸的传统观念。事实上,我们从回归测试中得到的反馈信息是非常有用的。所以若能把回归测试运用到一个更细粒度的层面则将是大有裨益的。

为证实这一点,让我们来做一个思想实验:假设我们正
单步跟踪一个大函数,该函数包含大量复杂的逻辑。我们分析、思考,并与更熟悉这块代码的人交流,然后我们着手进行修改。我们想要确保所做的改动没有破坏任
何东西,然而如何才能确保这一点呢?幸运的是我们有一个质量小组,他们有一组回归测试,可以在夜里运行这组测试。于是我们打电话给他们,让他们安排一次测
试,他们答应了,可以替我们安排一次夜里的回归测试。幸运的是,这个电话打得早,因为其他小组通常会在星期三左右要求他们安排回归测试,要是再晚一些的
话,他们可能就腾不出可用的时间段和机器了。听了这话我们就放心了,回头继续工作。我们还有五处像刚才那样的改动要做,而且都是位于像刚才那样复杂的地
方。同时我们心里也清楚,还有其他几个人也在像我们一样忙活着修改代码并等着安排测试呢。

第二天早晨我们接到一个电话。测试部门的Daiva
告诉我们,第AE1021和AE1029号测试昨晚失败了。她不能肯定是不是由于我们所做的改动导致的,但她给我们打了电话,因为她知道我们会帮她搞定这
件事情。我们会进行调试,查出失败是否由于所做的某处改动还是因为其他原因。

以上场景听起来真实吗?不幸的是,它恰恰是非常真实的。

让我们来看另外一个场景。

我们需要对一个相当长且复杂的函数进行改动。幸运的是,我们发现有一组针对它的单元测试。最后一次接触这段代码的人编写了一组大约20个单元测试,彻底地检验了这段代码。我们运行这组测试,发现全部通过。接下来我们就浏览这些测试,以便对这段代码的实际行为有一些认识。

我们准备好做改动了,然而却发现很难想出具体进行改
动的方法。代码模糊不清,我们很希望能够在着手之前先更好地认识代码。现有的测试并不能覆盖所有的情况,因此我们想让代码变得更清晰一些,这样我们才能够
对进行的改动更有信心。除此之外,我们不想自己或任何其他人再次经历这样一个痛苦的过程,那实在太浪费时间了!

我们开始对代码做一点点重构。我们将一些方法抽取出
来,并移动一些条件逻辑。在进行的每一步细小的改动后,我们都会运行上面提到的那套单元测试。几乎我们每次运行它的时候都是通过的。就在几分钟前我们犯了
一个错误,反转了一个条件逻辑,然而单元测试迅速给出了失败的结果,于是我们得以在大约一分钟内纠正了所犯的错误。当我们完成重构之后,代码变得清晰多
了。我们完成了要做的改动,而且确信我们的改动是正确的。接下来,我们添加了几个测试来验证新加上去的特性。于是,面对这些代码的下一个程序员做起来就会
轻松得多,而且他会有覆盖其所有功能的全套测试。

你是希望在一分钟内就获得反馈呢?还是希望等一整个晚上?以上哪个场景更有效率?

单元测试是用于对付遗留代码的极其重要的组件之一。系统层面的回归测试的确很棒,然而相比之下,小巧而局部性的测试才是无价之宝,它们能够在进行改动的过程中不断给你反馈,使重构工作的安全性大大增强。

2.1  什么是单元测试

术语单元测试(unit
testing)在软件开发领域有着悠久的历史。在大多数有关单元测试的观念中都有这么一个共同的理念,即它们由一组独立的测试构成,其中每个测试针对一
个单独的软件组件。那么组件又是什么呢?实际上组件有多种定义,不过在单元测试这一领域,我们通常关心的是一个系统的最为“原子”的行为单元。譬如在过程
式程序设计的代码中,“单元”一般来说指的就是函数,而在面向对象的代码中则指的是类。

测试用具


书中我使用了术语测试用具(test
harness)。该术语是泛称,代表我们为了检验软件的某部分而编写的测试代码以及用来运行这些测试代码的代码。我们可以针对代码使用多种不同的测试用
具。第5章中讨论了xUnit测试框架以及FIT框架。这两者都可以用来进行本书中描述的测试工作。

那么,我们究竟能否做到只测试系统中的某一个函数或
类呢?在过程式系统中,通常是难以孤立地测试函数的,因为这种系统中的情况往往是顶层的函数调用其他函数,后者再调用另一些函数,最后直到机器层。而在面
向对象的系统中,单独测试类则要简单一点,然而实际上类却往往并不是“离群索居”的生物。想想看,在你写过的所有类中有多少是没有使用到别的类的?非常少
这些极个别分子往往是那些小型的数据类或数据结构类,如栈和队列(甚至就算是这样的类也可能会用到其他类)。

测试的隔离性是单元测试的一个重要方面,然而为什么说它是重要的呢?毕竟,当整合软件的各个部分时还可能出现许多错误。难道不应该是那些能够覆盖代码中的广泛功能区域的大型测试更为重要吗?诚然,它们是重要的,我并不否认这一点,然而大型测试存在着一些问题:

q
错误定位:测试离被测试者越远,就越难确定测试失败究竟意味着什么。要想精确定位测试失败的根源往往需要耗费大量的工作。你得检查测试输入、还要检查失败
本身,然后还得确定这次失败发生在从输入到输出的执行路径上的哪一点。虽说对于单元测试来说这样的工作也是免不了的,然而通常其工作量微乎其微。

q 执行时间:大型测试往往需要更长时间来运行。而这种长时耗性往往让人无法忍受。需要太长时间运行的测试,结果往往是无法运行。

q 覆盖:在大型测试中,往往难以看出某段代码与用来测试它的值之间的联系。我们通常可以通过覆盖工具来查出某段代码是否被一个测试覆盖到了,但当添加新的代码时,可能就需要花费可观的工作量来创建检验这段新代码的高层测试了。


型测试最令人不能接受的事情就是可以通过频繁地运行测试来实现错误定位,然而这偏偏又是难以实现的。假设我们运行测试并且测试通过,接着我们做了一点点改
动于是测试失败了,这时我们就能够精确地知道问题是在哪儿被触发的,就是我们最后做的那点改动中的某处地方。于是我们可以将改动回滚,并重新尝试改动。这
一场景看上去也许没什么问题,然而如果我们的测试相当大,其执行时间可能长得令人无法忍受,可想而知我们更可能做的事情便是尽量避免去运行它,于是就无法
达到错误定位的目的了。

而单元测试则做到了大型测试所不能做到的那些事情。
利用单元测试可以独立地对某一段代码进行测试。我们可以将测试分组以便在某些特定条件下运行某些特定的测试,并在其他条件下运行另一些测试。我们还可以迅
速定位错误。如果认为在某段代码中存在着一个错误而且又可以在测试用具中使用这段代码的话,我们通常能够迅速地编写出一段测试,看看我们所推测的错误是不
是真的在那里。

下面是好的单元测试所应具备的品质:

(1) 运行快;

(2) 能帮助我们定位问题所在。

在业界,人们在判断某个特定的测试是否是单元测试这
个问题上常常摇摆不定。如果一个测试中涉及了另外一个产品类,那它还能算是单元测试吗?为了回答这个问题,我们回到刚才提到的两点品质上来,即该测试运行
起来快不快?它能帮我们快速定位错误吗?比如有些测试较大,其中用到了好多类。那么实际上这种测试或许看上去像是小型的集成测试。就它们自身而言,可能运
行起来比较快,然而要是你将它们一起运行呢?一个测试如果不仅测试了某个类还测试了与该类一起工作的几个类,那么它往往会“越长越大”。如果你当时不花时
间来使得一个类能够在测试用具中单独实例化的话,难道你还能指望当更多的代码被添加进系统之后这件事会变得更容易吗?永远也不会。人们会不断推诿,并且随
着时间的推移,原本短小的测试可能会变得需要十分之一秒才能执行完。

一个需要耗时十分之一秒才能执行完的单元测试就已算是一个慢的单元测试了。

我说这话是认真的。在我写作本书时,十分之一秒对于
单元测试来说简直就像一个世纪一样。不信的话让我们来做一点简单的算术吧:假设你有一个项目,其中包含3
000个类,每个类平均大约有10个测试,一共算起来就是30
000个测试。倘若这些测试个个都耗时十分之一秒才能运行完的话,那整个项目测试一遍需要多少时间呢?将近一个小时!对于反馈来说这段等待时间可不短。什
么?你的项目没有3
000个类?那一半总有吧,这样算下来也仍然要等半个小时呢。另一方面,假如我们的测试只需耗时百分之一秒呢?很显然,我们一下子从需要等一个小时变成了
只需等5到10分钟!这样的话,虽说我还是比较谨慎的只取出其中的部分单元测试来用,但哪怕每隔几个小时就将它们全部运行一遍我也不再害怕。

此外,根据摩尔定律,在我有生之年我有望看到即便是在最大型的系统上测试反馈也能在近乎一瞬之间完成。我猜到那时候在这类系统中工作就会像是在一堆会“反咬一口”的代码中工作一样。一旦你改错了一步,系统就会立即“反咬一口”让你知道。

单元测试运行得快。运行得不快的不是单元测试。

有些测试容易跟单元测试混淆起来。譬如下面这些测试就不是单元测试:

(1) 跟数据库有交互;

(2) 进行了网络间通信;

(3) 调用了文件系统;

(4) 需要你对环境作特定的准备(如编辑配置文件)才能运行的。

当然,这并不是说这些测试就是坏的。编写它们常常也是有价值的,而且你通常也会在单元测试用具内来编写它们。然而,将它们跟真正的单元测试区分开来还是很有必要的,因为这样你就能够知道哪些测试是你可以(在你进行代码修改的时候)快速运行的。

抱歉!评论已关闭.