第1 章测试驱动开发

8
首先调试代码比写代码难一倍因此从这个意义上讲调试你穷思竭虑写出的代码时你将才尽思穷——Brian Kernighan 1 测试驱动开发 我们都做过这样的事——写一大堆代码然后艰难地使它工作起来也就是先建造再修正测试是在代码写完之后的事情测试总是一件后面加上来的事情这也是我们过去唯一所知的 方法这种很难预料的过程被亲切地称为调试”(debugging),我们可能会在其中花掉半个小时调试的过程在我们的进度中被测试集成粉饰起来它总是风险和不确定的来源改一个 bug 可能导致产生另一个有时是一系列其他的 bug我们往往会统计这些数据来预测把 bug 全部修改掉还需要花多少时间然后我们会去关注曲线的拐点拐点上的趋势表明我们修改 bug 的速度终于超过了新引入并报出 bug 的速度拐点说明我们几乎就要完成了——但我们永远 不能确切地知道在代码的某个黑暗的角落里是否还躲着一个杀手级 bug开发人员在瀑布模型开发的最后阶段往往忙作一团与其等到那时才发现问题质检部门不 如在开始时就写一些回归测试以尽快发现那些问题可我们还是经常会遇到意外 一个小小的错 误可能要花上几天几个星期甚至几个月才能发现有些错误从未找到一些有远见的人看到了这里的潜在问题他们发现短的开发周期产生的问题会少一些他们 发现积极的测试自动化可以节省时间和精力这样就不再需要继续重复冗长乏味且错误百出的工 作了测试不一定非要花费出动一小队手工测试人员这样昂贵的代价很快人们就发现了其中的 边际效应side effect):可以避免调试了解决了项目进度变化的一个根本原因那么就会出现 更靠谱的项目计划软件开发的脉络 从今天就开始集成这是开发嵌入式系统唯一合理的方式未知是项目进度 的最大杀手只有把代码和硬件运行起来测试才能暴露出其中的未知测试和集 成将不再是独立的里程碑它们是软件开发的脉络。” 来自The Art Designing Embedded Systems[Ganoo]作者 Jack Ganssle 著名的嵌入式大师 Jack Ganssle 提出集成和测试是软件开发的脉络不过还不能这么说起码在目前广泛流行的开发方式中还不是但它们以后必须是测试驱动开发Test-Driven DevelopmentTDD就是其中一种方式一种有效的方式它可以把测试贯穿到软件开发的 脉络中去它是你代码的Kevlar防弹衣 Kevlar防弹衣是美国杜邦公司的著名产品广泛用于各种军警领域

Transcript of 第1 章测试驱动开发

首先,调试代码比写代码难一倍。

因此,从这个意义上讲,调试你穷思竭虑写出的代码时你将才尽思穷。

——Brian Kernighan  

第 1 章 测试驱动开发

我们都做过这样的事——写一大堆代码然后艰难地使它工作起来。也就是先建造再修正。

测试是在代码写完之后的事情。测试总是一件后面加上来的事情,这也是我们过去唯一所知的

方法。

这种很难预料的过程被亲切地称为“调试”(debugging),我们可能会在其中花掉半个小时。

调试的过程在我们的进度中被“测试”和“集成”粉饰起来。它总是风险和不确定的来源。修

改一个 bug 可能导致产生另一个,有时是一系列其他的 bug。我们往往会统计这些数据来预测把

bug 全部修改掉还需要花多少时间。然后我们会去关注曲线的拐点,拐点上的趋势表明我们修改

bug 的速度终于超过了新引入并报出 bug 的速度。拐点说明我们几乎就要完成了——但我们永远

不能确切地知道在代码的某个黑暗的角落里是否还躲着一个杀手级 bug。开发人员在瀑布模型开发的最后阶段往往忙作一团,与其等到那时才发现问题,质检部门不

如在开始时就写一些回归测试以尽快发现那些问题。可我们还是经常会遇到意外 :一个小小的错

误可能要花上几天、几个星期甚至几个月才能发现。有些错误从未找到。

一些有远见的人看到了这里的潜在问题,他们发现短的开发周期产生的问题会少一些。他们

发现积极的测试自动化可以节省时间和精力。这样就不再需要继续重复冗长乏味且错误百出的工

作了。测试不一定非要花费出动一小队手工测试人员这样昂贵的代价。很快人们就发现了其中的

边际效应(side effect):可以避免调试了。解决了项目进度变化的一个根本原因,那么就会出现

更靠谱的项目计划。

软件开发的脉络

“从今天就开始集成,这是开发嵌入式系统唯一合理的方式。未知是项目进度

的最大杀手。只有把代码和硬件运行起来,测试才能暴露出其中的未知。测试和集

成将不再是独立的里程碑,它们是软件开发的脉络。” 来自《The Art Designing Embedded Systems》[Ganoo],作者 Jack Ganssle  

著名的嵌入式大师 Jack Ganssle 提出集成和测试是软件开发的脉络。不过还不能这么说,

起码在目前广泛流行的开发方式中还不是,但它们以后必须是。测试驱动开发(Test-Driven Development,TDD)就是其中一种方式—一种有效的方式,它可以把测试贯穿到软件开发的

脉络中去,它是你代码的“Kevlar”防弹衣 。

  “Kevlar”防弹衣是美国杜邦公司的著名产品,广泛用于各种军警领域。

 2  第 1 章 测试驱动开发

把 TDD 应用于嵌入式 C 语言开发有很多值得关注的地方,这就是本书的目的。在这一章

里,你将从“万米高空鸟瞰”TDD。然后我们将把 TDD 应用到一个简单的 C 模块中。当然,这

会引发很多疑问。这些疑问将在第 2 章回答。在开始之前,我们先来看一个著名的 bug。如果用

了 TDD,这个 bug 可能就可以避免了。

1.1 为什么我们需要 TDD

Zune 是微软用来与 iPod 竞争的产品。如果使用测试驱动开发就可能阻止一个在 Zune 中令

人尴尬的 bug。2008 年 12 月 31 日这一天,Zune 变成了一块砖头。这个日子有什么特别的吗?那

天是新年前夜,闰年的最后一天,也是 30GB Zune 经历过的第一个闰年。

很多人分析了 Zune 的这个 bug,并且最终定位问题到时钟驱动程序中的一个函数。尽管下

面的例子不是实际的那个驱动程序代码,但这段代码有着相同的 bug。

Download src/zune/RtcTime.c

static void SetYearAndDayOfYear(RtcTime * time)

{

int days = time->daysSince1980;

int year = STARTING_YEAR;

while (days > 365)

{

if (IsLeapYear(year))

{

if (days > 366)

{

days -= 366;

year += 1;

}

}

else

{

days -= 365;

year += 1;

}

}

time->dayOfYear = days;

time->year = year;

}

很多代码阅读方面的专家在看过这段代码后得出了和我一样的错误结论。我们关注到了布

尔表达式(days>366)。闰年的最后一天的确是第 366 天,但是在这里却没有得到正确处理。在

闰年的最后一天,这段代码会进入一个死循环!我决定要为 SetYearAndDayOfYear()写一些测

试来看看把布尔表达式改成(days>=366)是否可以改正这个错误,就像 90% 的为 Zune 的这个

bug 写博客的人所预测的那样。

 出于版权的考虑,这里不能使用真实的 Zune 代码,Zune 是微软的注册商标。

  第 1 章 测试驱动开发  3 

在把这段代码放入到自动化测试框架中以后,我写了如下的测试用例,它本可以挽救很多新

年前夜的派对:

Download tests/zune/RtcTimeTest.cpp

TEST(RtcTime, 2008_12_31_last_day_of_leap_year)

{

int yearStart = daysSince1980ForYear(2008);

rtcTime = RtcTime_Create(yearStart+366);

assertDate(2008, 12, 31, Wednesday);

}

就像 Zune 一样,这个测试进入了死循环。在终止测试进程后,我把这个基于几千程序员评审得

来的流行改法应用到代码上。令我吃惊的是,测试失败了,这是因为 SetYearAndDayOfYear()认为

这一天是 2009 年 1 月 0 日。新年前夜派对可以放音乐了,但还存在 bug。现在 bug 更容易看出而

且更容易改了。

有了这一个测试,Zune 的 bug 本可以避免。一大群人评审代码后的结论很相似,但大多数

人还是没能得到正确的解法。我不是在打击你对代码评审的信心,评审代码还是有必要的。但只

有代码运行起来才是唯一能确认它正确的方式。

你会想,我们怎么知道要写这个测试?我们可以在任何有 bug 的地方写测试。问题是我们不

知道 bug 在哪里——它们可以在任何地方。因此,这意味着我们不得不为所有的东西写测试,至

少是为任何可能出错的东西。想象一下所有需要的测试,这有点耸人听闻。但不要着急。你不必

为每一年里的每一天都写一个测试,你只需要为每一个关键的日子写测试就可以了。本书就是关

于如何写这些测试的。本书将帮你学到该写哪些测试,如何写这些测试,如何去避免在你的产品

中出现如 Zune 一样的 bug。最后,让我们回过头来回答“为什么我们需要 TDD”这个问题。我们需要 TDD,这是因

为我们是人类,人类会犯错误。计算机编程是一项非常复杂的活动。除了其他原因,TDD 还是

自动化测试用例,通过它,我们系统地得到按我们的意图工作的代码,并且可以同时保持这些

代码可工作。

1.2 什么是测试驱动开发

测试驱动开发(TDD)是一种增量式软件开发技术。简单地说,就是在没有失败的单元测试

的前提下不可以写产品代码。这些测试要很小,而且要自动化。用测试来驱动其实很合理。相对

于直接工作在产品代码上,TDD 的实践者们会先用测试来表达他们希望产品代码会有什么样的行

为。然后这个测试显然会失败。只有在这时,他们才开始写产品代码,以便让测试通过。

测试自动化是 TDD 的关键。在 TDD 的进程中,首先会生成新的自动化测试,紧跟着是满

足这些测试的产品代码。一套单元测试伴随着产品代码同时不断增长,它也是像产品代码一样具

有价值的资产。每次微小改动代码时,这套测试都会运行一次。这样不仅保证了新代码是可工作

的,同时也保证了已存在的代码与新的改动兼容。

软件是很脆弱的,任何改动都可能产生意料之外的结果。如果只有手工测试,我们很难负担

这么多测试来预防所有意料之外的情况发生。重新测试的代价太高了,所以我们只去运行那些我

 4  第 1 章 测试驱动开发

们认为必需的手工测试。有时我们没那么幸运,引入了 bug 却没发现。如果使用 TDD,测试会帮

我们检查这些意料之外的情况,我们不用放弃检查之前的那些代码的行为。

尽管你的确要写很多有价值的自动化测试,但是 TDD 并不是一种测试技术。它是解决编程

问题的一种方法。它能帮助软件开发人员做出更好的设计决策。对于方向错误,或者疏漏了实际

约束的解决方案,测试将会给出明确的警告。测试抓住了产品代码预期的行为。

TDD 还很有趣!这就像一个游戏,你在技术迷宫中将软件引向高可靠的方向,同时还避免

了无聊的调试过程。伴随着每一个测试的通过都会有新的成就感,并能感受到向目标更近了一

步。自动化测试会记录那些假设,抓住决策,并且让我们能专心面对下一个挑战。

1.3 TDD 的机理

为了看看 TDD 能带来什么不同,我们把它和传统的编程方式进行比较。我把传统的方式称

为“后期调试式编程”(Debug-Later Programming,DLP)。在后期调试式编程(DLP)中,代码

先设计并写出,即代码“写完”之后才进行测试。有趣的是,这个对于“写完”的定义忽略了一

半左右的开发工作量。

人都会犯错误,在设计和编码中犯错也很正常。这时我们就发现了后期调试式编程的问题 :

它可能要在几天、几周或者几个月后才能反馈给开发者。反馈如此之晚,你已不可能从错误中吸

取经验了。下次你还是会犯同样的错误。

在慢速反馈的情况下,出错代码之上可能还堆积了不少其他改动,因此问题的根源往往很难

确定。有些代码可能已经依赖于错误的行为了。没有明确的原因和结果,唯一可以做的就是确定

bug。这种天生很难预知的事情可以毁掉任何精心设计的计划。当然,你也可以为改错计划一些

时间。但你计划的那点时间够吗?我们没办法进行可靠的估计。

从图 1-1 中可以看出,当发现一个 bug 的时间(Td)增加时,找到这个 bug 根本原因的时间

(Tfind)也会增加,而且往往增加很多。修正有些 bug 的时间(Tfix)通常和 Td 无关。但如果这是

由建立在错误假设之上的代码造成的组合错误,Tfix 也可能大幅度增长。有些 bug 在数年之后才

被发现。

图 1-1 后期调试式编程的机理

现在来看一下图 1-2。当发现 bug 的时间(Td)趋近于零,定位问题的时间(Tfind)也会趋近

于零。刚刚引入到代码中的问题往往是很明显的。就算不那么明显,开发者也可以简单地通过回

退来恢复到以前工作的状态。时间只会模糊程序员的记忆并让更多的代码建立在早期错误之上,

而 TDD 则会让 Tfind+Tfix 最小化。

  第 1 章 测试驱动开发  5 

图 1-2 测试驱动开发的机理

相比之下,TDD 则会给出立即的反馈!对失误的立刻通知将帮助我们避免 bug。如果一个

bug 只能存在几分钟,那它还是 bug 吗?不,它只是个被避免了的 bug。TDD 预防 bug。后期调

试式编程(DLP)则是把浪费制度化。

1.4 TDD 的微循环

我将从什么不是 TDD 说起。它不是花一个小时、一天或者一个星期来写一大堆的测试代码,

然后再来实现产品代码的开发方式。

TDD 是写一个小小的测试,然后写仅够让这一个测试通过的产品代码,同时不能破坏已有

的测试。

TDD 要求你在构建一个东西之前先决定你到底要什么。它将反馈给你所有的东西是否都和

你当前的预期一样。

TDD 的核心是由小步骤来不断重复的循环组成,称为 TDD 微循环。新的和老的代码是否还

保持我们期望的行为?每次循环都会给出这个问题的答案。能得到这种反馈的感觉很棒。进度也

很实在,而且这种进度也是更容易度量的。错误变得很明显。

以下的 TDD 循环步骤基于 Kent Beck 在其所著的《Test-Driven Development》[Bec02] 一书

中的描述:

1)增加一个小的测试。

2)运行所有的测试并期待新测试失败,也可能根本连编译都通不过。

3)为了让测试通过来做一些小改动。

4)运行所有的测试并期待新的测试通过。

5)重构,以移除重复并改进代码的表达方式。

TDD 循环中的每一步都只需要几秒到几分钟的时间。新的代码和测试以增量的方式加入进

来,并且立刻得到关于我们新写的代码是否和我们预期一致的反馈。你把脑海中预见到的愿景

“生长”成代码,从简单到复杂。

在这个过程中,你不仅了解到了解决方案,同时也对所解决的问题有了进一步了解。测试变

成了对需求细节的翔实描述。当工作不断地累加后,测试同产品代码一起绑定了对问题的定义和

解决方案。这些知识以稳定的形式被捕捉下来。

不论进行何种改动,都要运行一下测试。如果测试通过,那么它会告诉你一切正常 ;如果

 6  第 1 章 测试驱动开发

产生了意料之外的结果,它会给你警告。形象一点来讲,这意味着如果你把代码搞坏,那么它

会尖叫!

如果测试已经通过,这感觉当然不错。因为这是一个坚实的进步。但是软件工作该做的事情

还不止于此。

保持代码整洁和富有表现力

通过测试表明了正确的行为。代码还必须可以运行。但是软件还有更多的错误行为。代码

应该保持整洁和良好的结构,以体现我们专业的素养并为将来能轻松地改动投资。整理代码有个

专用的名字,它就是这个不断重复循环中每次的最后一步——重构。Martin Fowler 在他所著的

《Refactoring:Improving the Design of Existing Code》[FBB+99] 一书中是这样描述的:重构就是在

不改变当前外部行为的条件下对现有代码结构进行修改的过程。其目的是通过写易于理解、易于

演化并且易于我们自己和他人维护的代码来让工作变简单。

小的乱代码很容易写出来。遗憾的是,它们也容易被忽略。这些乱代码越到开发的后期越难

清理—是你创造了它。在这些乱代码还新鲜的时候就要清理。“测试全过”给了我们做重构的

一个好机会。贯穿本书都会讨论和演示重构,尤其是在第 12 章中。

TDD 首先会帮助代码工作起来,但更大的好处还在后面,在后来的开发者理解和维护这些

代码的时候。那时就可以(几乎)毫无畏惧地修改这些代码了。

测试代码和 TDD 首先支持代码的作者,以便让代码正常工作。当我们面向未来时,它更加

是和代码的读者相关的,因为测试把我们正在创造的是什么东西描述给读者。

红灯-绿灯-重构,以及程序员的条件反射

红灯 绿灯 重构

“红灯-绿灯-重构”称为 TDD 的节奏。“红灯-绿灯-重构”得名于 Java界。在 Java 中使用 TDD 方法会用到像 JUnit 这样的单元测试框架,测试结果会

以图形化的方式体现在一个进度条上。如果有单元测试失败,进度条会变成红色。

如果它是绿色的就说明所有测试都通过。开始,新的测试会失败并亮出红条,这

给我们一种一切尽在掌握的感觉。然后在不破坏已有测试的前提下让新的测试也

通过,我们就会看到一条绿色的进度条。如果这和你料想的一样,那么你当然感

觉不错。如果你期望看到的是红色但它却显示绿色,那要么是你的测试用例有问

题,要么是你的期望不正确。

当所有的测试都通过时,就可以安全地重构了。如果在重构时我们意外地看到

了红条,那表示我们破坏了代码原有的行为,或者是测试发现了一处错误,也可能

是我们刚刚避免了一个 bug。让它变回绿色只要简单回退,然后再来重新尝试重构。

  第 1 章 测试驱动开发  7 

你可能听做过 TDD 的人称这种节奏为“红灯-绿灯-重构”微循环。

1.5 TDD 的好处

正如很多其他技巧,如打桌球或者高山滑雪 一样,TDD 也要花相当长的时间来练习。许多

开发者已经接受了这种技术,而且再也不想回到从前“后期调试式编程”的方式去了。这里罗列

出一些 TDD 实践者所描述的好处:

产生的 bug 更少

  无论小的还是大到可以在现场产生灾难性结果的逻辑错误,TDD 都能在开发时快速发

现。缺陷就这样避免了。

调试时间更短

  更少的 bug 自然意味着更短的调试时间,就是这么简单。

边际效应所带来的 bug 更少

  测试会绑定假定、约束并给出使用范例。当新代码违反了这些假定或约束,测试给出

警告。

单元测试是“不会说谎的文档”

  精心构建的测试可以成为可执行且无歧义的文档。千言万语也没有一个可工作的示例能

说明问题。

内心的平静

  彻底、全面的回归测试给予我们信心。TDD 开发者据称晚上睡得更香,且周末更少被

打扰。

改善设计

  好的设计一定是可测试的设计。长函数、紧耦合以及复杂的条件判断,这些都会导致复

杂且难测试的代码。如果开发者发现对计划中的代码改动很难编写测试,那就说明设计有问

题,TDD 会在早期给出提示。TDD 是监视代码变坏的雷达。

对进度的监控

  测试跟踪记录了到底现在哪些部分已经可以工作了,以及已经完成了多少工作。它对于

“完成”给出了新的估计方法和定义。

有趣且回报丰厚

  TDD 不断地给开发者以成就感。你每写一点代码都会完成一些工作,同时你能很清楚

它们是正确的。

1.6 对于嵌入式开发的益处

嵌入式软件开发面临所有“通常意义上”的软件开发的挑战。例如很难把进度计划做得好且

可靠。但嵌入式软件开发也有其自身特有的更多挑战。这并不意味着嵌入式开发不能采用 TDD。

 作者 James Grenning 是一位桌球和滑雪爱好者。—译者注

 8  第 1 章 测试驱动开发

嵌入式开发者最常引用的借口是嵌入式代码依赖于硬件。依赖关系对于非嵌入式代码也是个

大问题。幸运的是,我们有办法来解决这些依赖问题。原则上讲,对硬件设备的依赖和对数据库

的依赖没什么区别。

嵌入式开发者面临很多挑战,我们将展开讨论如何从 TDD 借力。嵌入式开发者不仅能收到

前面讲过的那些非嵌入式开发者能享受到的好处,同时还将得到一些嵌入式特别的好处:

• 在硬件可用之前或者硬件很贵或者很稀少时,通过独立于硬件的方式校验产品代码,从而

降低风险。

• 在开发环境中执行并移除 bug,从而减少长时间的编译、链接和上传这样的循环的次数。

• 对于在目标硬件中既难发现又难修改的问题,可以减少调试的时间。

• 通过在测试中模型化硬件交互,使硬件/软件的交互彼此独立。

• 通过解开模块之间以及模块与硬件之间的耦合来改进软件设计。可测试的代码首先必须是

模块化的。

本书的第一部分将重点帮助你开始进行 TDD。在接下来两章的一个 TDD 编程示例之后,我

们将在第 5 章中更多地讨论为嵌入式软件做 TDD 的技术。