第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10...

15
单元测试基础 本章内容 定义一个单元测试 对比单元测试与集成测试 探索一个简单的单元测试示例 理解测试驱动的开发 凡事总有第一次:第一次编写程序,第一次项目失败,第一次通过努力获得成功。你不会忘 记自己的第一次,我希望你也不忘记第一个测试。也许你已经写过一些测试,在你的记忆里那些 测试也许是差劲的、笨拙的、缓慢的,或者无法维护的。(大部分人都是如此。)乐观一点的话, 也许你有过很棒的单元测试经历,想看看这本书里有什么你可能错过的知识。 本章先分析“传统的”单元测试定义,并和集成测试的概念进行比较。很多人对这二者的区 别并不清楚。接着我们会列举单元测试和集成测试相比的优缺点,给“优秀的”单元测试下个更 好的定义。最后我们会了解一下测试驱动开发的概念,因为测试驱动开发经常会和单元测试联系 在一起。在这一章中,我还会提到一些概念,这些概念会在本书其他的章节做更详尽的解释。 让我们以单元测试的定义作为开始吧。 1.1 逐步定义单元测试 在软件开发领域,单元测试并不是一个新概念。从早期使用Smalltalk编程语言的20世纪70代开始,单元测试就已经出现,并一次又一次被证明是开发人员提高代码质量,加深理解类或方 法功能需求的最佳手段之一。 Kent BeckSmalltalk中引入了单元测试的概念,这个概念又被带入许多其他编程语言,使单 元测试成为软件编程中一项极为有用的实践。在深入讲解前,我需要更好地定义单元测试的概念。 下面将给出维基百科中单元测试的传统定义。在本章中这个定义会慢慢演化,最后在1.4节给出 最终的定义。 第1章

Upload: others

Post on 10-Aug-2020

16 views

Category:

Documents


0 download

TRANSCRIPT

Page 1: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

2 第 1 章 单元测试基础

单元测试基础

本章内容 定义一个单元测试

对比单元测试与集成测试

探索一个简单的单元测试示例

理解测试驱动的开发

凡事总有第一次:第一次编写程序,第一次项目失败,第一次通过努力获得成功。你不会忘

记自己的第一次,我希望你也不忘记第一个测试。也许你已经写过一些测试,在你的记忆里那些

测试也许是差劲的、笨拙的、缓慢的,或者无法维护的。(大部分人都是如此。)乐观一点的话,

也许你有过很棒的单元测试经历,想看看这本书里有什么你可能错过的知识。

本章先分析“传统的”单元测试定义,并和集成测试的概念进行比较。很多人对这二者的区

别并不清楚。接着我们会列举单元测试和集成测试相比的优缺点,给“优秀的”单元测试下个更

好的定义。最后我们会了解一下测试驱动开发的概念,因为测试驱动开发经常会和单元测试联系

在一起。在这一章中,我还会提到一些概念,这些概念会在本书其他的章节做更详尽的解释。

让我们以单元测试的定义作为开始吧。

1.1 逐步定义单元测试

在软件开发领域,单元测试并不是一个新概念。从早期使用Smalltalk编程语言的20世纪70年

代开始,单元测试就已经出现,并一次又一次被证明是开发人员提高代码质量,加深理解类或方

法功能需求的最佳手段之一。

Kent Beck在Smalltalk中引入了单元测试的概念,这个概念又被带入许多其他编程语言,使单

元测试成为软件编程中一项极为有用的实践。在深入讲解前,我需要更好地定义单元测试的概念。

下面将给出维基百科中单元测试的传统定义。在本章中这个定义会慢慢演化,最后在1.4节给出

最终的定义。

第 1 章

Page 2: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

1.1 逐步定义单元测试 3

1

2

3

4

5

8

9

6

7

11

10

定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

码,然后检验某些假设的正确性。如果这些假设是错误的,单元测试就失败了。一个单

元可以是一个方法或函数。

你写代码测试的对象称为“被测试系统”(System Under Test,SUT)。

定义 SUT代表System Under Test,有的人喜欢用CUT(Class Under Test或Code

Under Test)。在测试中,被测试的东西称为SUT。

我以前觉得(是的,觉得。本书中没有科学,只有艺术和感觉),单元测试的这个传统定义

在技术上是正确的,但是在过去的几年中,我对于单元这个概念的想法改变了。我认为一个单元

代表系统中的“功能单元”或者一个“用例”。

定 义

从调用系统的一个公共方法到产生一个测试可见的最终结果,其间这个系统发生的行为总

称为一个工作单元。我们通过系统的公共API和行为就可以观察到一个可见的最终结果,无需

查看系统的内部状态。 一个最终结果可以是以下任何一种形式。

被调用的公共方法返回一个值(一个返回值不为空的函数)。

在方法调用的前后,系统的状态或行为有可见的变化,这种变化无需查询私有状态即可

判断。(例如:一个以前不存在的用户可以登入系统,或者一个状态机系统的属性发生

变化。)

调用了一个不受测试控制的第三方系统,这个第三方系统不返回任何值,或者返回值都

被忽略。(例如:调用一个第三方日志系统,这个系统不是你编写的,而且你也没有源

代码。)

对于我来说,工作单元这个概念意味着一个单元既可以小到只包含一个方法,也可以大到包

括实现某个功能的多个类和函数。

你也许觉得被测试的工作单元应该尽可能的小。我以前也这么认为,现在却不这么看。如果

你创建的工作单元更大,而且它的最终结果对这个接口的用户可见度更高,那么我相信你的测试

会更容易维护。如果你试图把工作单元缩到最小,最后会不得不伪造一堆东西,这些东西并不是

使用公共API的真实最终结果,而是生成结果过程中的一些中间状态。我会在之后关于过度指定

的部分(大部分内容在第8章)对此做更多的解释。

定义更新1.1 一个单元测试是一段代码,这段代码调用一个工作单元,并检验该

工作单元的一个具体的最终结果。如果关于这个最终结果的假设是错误的,单元测试就

失败了。一个单元测试的范围可以小到一个方法,大到多个类。

不管你使用哪种编程语言,要定义单元测试,最困难的就是定义“优秀的”单元测试。

Page 3: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

4 第 1 章 单元测试基础

1.1.1 编写优秀单元测试的重要性

要理解单元测试,能理解什么是工作单元还不够。

大部分尝试对自己的代码进行单元测试的人要么在某个阶段放弃了,要么并没有真正执行单

元测试。他们或者依靠在产品生命周期后期执行的系统测试和集成测试来发现问题,或者改用手

工方式,使用定制的测试应用程序或者通过最终产品调用代码来进行测试。

编写差劲的单元测试是没有意义的,除非你正在学习如何编写单元测试,这些差劲的测试是

你初次练习的产物。如果你不加鉴别地胡乱编写单元测试,那还不如干脆不写,至少还能免除日

后维护和时间安排的麻烦。通过定义什么是优秀的单元测试,你可以确保自己在开始编写单元测

试时有正确的目标。

要理解什么是优秀的单元测试,你需要了解开发人员在测试的时候都做些什么。

那么,如何确保代码今天还能工作呢?

1.1.2 我们都写过(某种)单元测试

不要惊讶,你已经自己进行过某种程度的单元测试。你见过提交代码前不做测试的开发人员

吗?是啊,我也没见过。

也许你是用一个控制台程序来调用一个类或组件的各种方法,也许是特意创建WinForm或者

Web Form的用户界面来检查这个类或组件的功能,也许干脆是在实际应用程序的用户界面上手

工执行各种操作。但最终的结果都是让你能在一定程度上确信代码工作正常,可以移交给其他人。

图1-1展示了大多数开发人员如何测试他们的代码。具体的用户界面可能会不同,但是模式通

常是一样的:使用一个手工的外部工具反复进行检验,或者运行整个应用程序,手工验证它的行为。

图1-1 在传统测试中,开发人员使用一个图形用户界面(GUI)触发

要测试的类的某个行为,然后检验结果

这些测试也许很有用,而且可能也很接近传统定义的单元测试,但是它们和我在本书中要定

Page 4: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

1.3 集成测试 5

1

2

3

4

5

8

9

6

7

11

10

义的优秀的单元测试还有很大的区别。说到这里,我们又回到了一个开发人员在定义优秀的单元

测试的特质时要面对的第一个问题,也是最重要的问题:什么是单元测试,什么不是单元测试?

1.2 优秀单元测试的特性

一个单元测试应当具有如下特征:

它应该是自动化的,可重复执行;

它应该很容易实现;

它应该第二天还有意义;

任何人都应该能一键运行它;

它应该运行速度很快;

它的结果应该是稳定的(如果运行之间没有进行修改的话,多次运行一个测试应该总是

返回同样的结果);

它应该能完全控制被测试的单元;

它应该是完全隔离的(独立于其他测试的运行);

如果它失败了,我们应该很容易发现什么是期待的结果,进而定位问题所在。

很多人把进行软件测试的行为和单元测试的概念混为一谈。要澄清这个误解,你首先应该回

顾自己以前写过的测试,问自己如下问题。

我两周前写的一个单元测试,今天还能运行并得到结果吗?几个月前写的呢?几年前写

的呢?

我两个月前写的单元测试,我团队里任何一个人都能运行它们并得到结果吗?

我能在几分钟内跑完我写过的所有单元测试吗?

我能一键运行我写过的所有单元测试吗?

我能在几分钟内写出一个基本的测试吗?

如果你对以上问题中的任何一个回答“不能”,那很有可能实施的并不是单元测试。如果你

实施的是单元测试,那对所有那些问题的回答都应该是“能”。当然你做的肯定是某种测试,而

且这种测试也和单元测试一样重要,但是和单元测试相比,这种测试具有一些缺点。

你可能会问:“那我以前做的都是什么测试呢?”你做的实际上是集成测试。

1.3 集成测试

任何测试,如果它运行速度不快,结果不稳定,或者要用到被测试单元的一个或多个真实依

赖物,我就认为它是集成测试。例如,如果一个测试要使用真实的系统时间,真实的文件系统,

或者一个真实的数据库,那这个测试就进入了集成测试的领域。

例如,如果一个测试不能控制系统时间,在代码中使用了当前时间DateTime.Now,那么每

次测试执行使用的都是不同的时间,因此每次测试本质上都是不同的,那么这个测试就不稳定了。

这种测试本身并不是一件坏事。我认为集成测试和单元测试具有同等重要的地位,但是这两

Page 5: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

6 第 1 章 单元测试基础

种测试应该彼此分开,以营造一种“绿色安全区”的感觉。这一点我会在本书后面的章节讨论。

如果一个测试使用真实的数据库,那么和那些只使用内存中的伪数据的测试相比,这种测试

的行为痕迹更难以消除,从这个意义上说它就不是只在内存中运行了。这种测试的运行时间也会

更长,这个问题也是无法控制的。单元测试运行应该很快。集成测试通常会慢很多。如果你有成

百上千个测试的话,半秒钟都是至关重要的。

集成测试还可能带来另外一个问题:一次测试的东西太多。

如果汽车坏了该怎么办?先不说怎么修好它,你怎么找到问题出在哪呢?一个汽车的发动机

由许多子系统组成,这些子系统协同工作,每个子系统都依靠其他的子系统的帮助产生最终的结

果:一辆飞驰的汽车。如果汽车不动了,问题可能出在这些子系统中的任何一个,或者多个。这

些子系统(或层次)的集成使得汽车能够工作。当汽车上路时,你可以把它能否开动看成是对这

些部件的一个终极集成测试。如果这个测试失败了,那所有的部件就都失败了;如果这个测试成

功了,那所有的部件也就都成功了。

软件也是同样的道理。大部分开发人员是通过用户界面的最终功能来测试软件的功能。单击

一个按钮会触发一系列的事件,类和组件协同工作产生最终的结果。如果测试失败了,所有这些

软件组件作为一个整体就失败了,而且很难找到到底是什么导致了整体操作的失败(参见图1-2)。

图1-2 集成测试中可能有很多失败点。所有的单元要一起工作,而每个单元都有可能

出错,因此找到缺陷的根源就更加困难

Page 6: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

1.3 集成测试 7

1

2

3

4

5

8

9

6

7

11

10

正如Bill Hetzel在The Complete Guide to Software Testing(Wiley,1993)一书中定义的,集成

测试是“一个循序渐进的测试,软硬件相结合并进行测试直到整个系统集成在一起”。但是很多

人每天做的不是系统集成测试,而是开发和单元测试, 这个定义并不太适用。

这里有一个更好的集成测试定义。

定义 集成测试是对一个工作单元进行的测试,这个测试对被测试的工作单元没

有完全的控制,并使用该单元的一个或多个真实依赖物,例如时间、网络、数据库、线

程或随机数产生器等。

总的来说,集成测试会使用真实依赖物,而单元测试则把被测试单元和其依赖物隔离开,以

保证单元测试结果高度稳定,还可以轻易控制和模拟被测试单元行为的任何方面。

1.2节中列出的问题可以帮助你认识到集成测试的一些缺点。我们接下来要定义期望在优秀

的单元测试中看到的特质。

与自动化单元测试相比,非自动化集成测试的缺点

让我们把1.2节中的问题同样应用在集成测试上,同时思考在真实世界中使用单元测试的

目的。

我两周前写的一个单元测试,今天还能运行并得到结果吗?几个月前写的呢?几年前写

的呢?

如果回答是“不能”,那你怎么知道自己是不是已经破坏了以前实现的某个功能?在应用

程序的生命周期中,代码经常会变化。如果在修改代码之后,你不能(或者不愿意)对

之前所有的功能进行测试,就有可能破坏了某个功能而毫不知情。我把这种情况称为“偶

然引入缺陷”。在软件项目快结束时,开发人员承受着压力修复已有的缺陷,“偶然引入

缺陷”的情况会经常发生。有时开发人员会在修复旧缺陷的时候无意间引入新缺陷。如

果在破坏功能后三分钟内就能发现问题,那该多好啊!在本书后面的章节中,你会了解

如何做到这一点。

定义 回归是以前运行良好但是现在不工作的一个或多个工作单元。

我两个月前写的单元测试,团队里任何一个人都能运行它们并得到结果吗?

这个问题和上一个属于一个范畴,但比上一个问题要求更高。当你做改动时,需要保证

自己不会破坏别人的代码。许多开发人员都害怕修改以前系统中的遗留代码,就是担心

不知道有什么别的代码依赖他们要改动的部分。简言之,他们不知道系统修改后的状态

是否稳定。

应用程序是不是还正常工作是很令人担心的,尤其代码还不是你自己写的。如果知道自己

不会破坏任何功能,你就比较有信心接手不太熟悉的代码了,因为有单元测试这个安全网。

优秀的单元测试可被任何人访问和运行。

Page 7: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

8 第 1 章 单元测试基础

定义 遗留代码在维基百科中定义为“与一个不再受支持或继续生产的操作系统,

或其他计算机技术相关的源代码”,但是很多公司把任何比当前维护的应用更老旧的版

本都称为遗留代码。这个词经常用来指代那些难以使用,难以测试,通常也更难以阅读

的代码。

有一个客户曾经以一种很实际的方式定义遗留代码:“能运行的代码”。很多人喜欢把遗留代

码定义为“没有测试的代码”。Michael Feathers在《修改代码的艺术》(Prentice Hall,2004)中把

“没有测试的代码”作为遗留代码的正式定义,在阅读本书时我们也需要对此定义加以考虑。

我能在几分钟内跑完我写过的所有单元测试吗?

如果你不能很快运行完测试(几秒钟完成要好于几分钟完成),就不会经常运行它们(每

天或者每周,有时甚至每月运行)。问题是,如果修改代码,你需要尽早得到反馈,好知

道自己是不是破坏了什么功能。运行测试的时间间隔越长,你对系统做未测试的修改越

多,出现问题的时候需要找寻缺陷的地方就越多。

优秀的测试需要能够快速运行。

我能一键运行我写过的所有单元测试吗?

如果不能,那可能意味着你需要对运行测试的机器进行配置,让测试能够正确运行(比

如设置数据库的连接字符串),或者单元测试不是完全自动化的。如果不能完全自动化单

元测试,你很可能会避免重复运行这些测试,团队里的其他人也一样。

没有人喜欢费工夫配置然后运行测试,而结果只是为了保证系统还能运行。开发人员有

着更重要的任务,比如在系统中增加更多功能。

优秀的测试应该无需修改就能运行,不需要手工配置。

我能在几分钟内写出一个基本的测试吗?

辨别集成测试的一个最简单方法就是:集成测试需要花时间进行正确的准备和实施,不

能直接执行。编写集成测试需要花费时间,因为它涉及内部,有时还有外部的依赖。(数

据库可以看做外部依赖。)如果不对测试进行自动化,依赖就不是一个太大的问题,但是

你也就失去了享受自动化测试所有好处的机会。编写测试的难度越高,你编写更多测试

的可能性就越小,对所担心的“大问题”之外的东西也会关注越少。单元测试的一个特

点就是会测试可能出问题的每一处细节,而不只是关注大问题。人们常常会惊讶:有很

多缺陷正是在他们认为简单正确的代码里找到的。

当你只关注大的测试时,测试的逻辑覆盖率就会比较低,代码中核心逻辑的很多部分不

会测到(虽然你可能覆盖到了较多的组件),这样就可能会出现没有考虑到的缺陷。

一旦找到了想用来测试具体对象模型的模式,你就应该能很快地轻松编写出优秀的测试。

一个小小的警告:对一个以前没有做过单元测试的对象模型,即便是有经验的单元测试

人员,也需要花30分钟或者更长时间才能写出第一个单元测试。这种摸索工作是难免的,

也是预料之中的。对这个对象模型的第二个以及之后的测试应该很容易就能完成了。

到现在,我已经解释了什么测试不是单元测试,以及有用的测试应具有什么特征。基于这些,

Page 8: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

1.5 一个简单的单元测试范例 9

1

2

3

4

5

8

9

6

7

11

10

现在我可以开始回答这一章提出的主要问题了:什么是优秀的单元测试?

1.4 什么是优秀的单元测试

在讨论了单元测试应该具有的重要属性之后,我来给出单元测试的最终定义。

定义最终更新1.2 一个单元测试是一段自动化的代码,这段代码调用被测试的工

作单元,之后对这个单元的单个最终结果的某些假设进行检验。单元测试几乎都是用单

元测试框架编写的。单元测试容易编写,能快速运行。单元测试可靠、可读,并且可维

护。只要产品代码不发生变化,单元测试的结果是稳定的。

这个定义看起来的确很难实现,尤其是考虑到有很多开发人员的单元测试都做得很糟糕。我

们需要认真审视一下,到现在为止,作为开发人员是如何实施测试的,并和我们希望的方式做个

比较。(第8章将讨论可靠、可读以及可维护的测试。)

在本书的前一版本中,我对单元测试的定义略有不同。我在前一版本中把单元测试定义为“只

对控制流代码运行”,但现在认为这不正确。没有逻辑的代码通常用作工作单元的一部分。即便

是没有逻辑的属性也会在工作单元中使用,因此测试不需要特别针对它们。

定义 控制流代码是包含某种逻辑的代码段,无论其规模大小。控制流代码包含

下面列举的一种或多种语句:if语句;loop、switch或case语句;计算;或其他任何

类型的决策代码。

说到通常不包含任何逻辑,因此不需要特别进行测试的代码,属性(Java中的获取方法/设置

方法)是个很好的例子。这种代码通常会用在你要测试的工作单元中,但是没必要直接测试它。

但是要小心:一旦你在一个属性中加入了任何检验逻辑,就需要确保这个逻辑在测试中覆盖。

在下一节中,我们要介绍一个完全用代码实现的简单的单元测试,这个测试不使用任何单元

测试框架。(我们会在第2章介绍单元测试框架。)

1.5 一个简单的单元测试范例

不用测试框架我们也可以编写一个自动化的单元测试。实际上,因为开发人员更习惯于将测

试自动化,他们中很多人在知道有测试框架之前,就已经在做自动化的单元测试了。在本节中,

我会展示没有框架是怎么编写自动化单元测试的,你可以把这个例子和第2章中使用框架的例子

进行对比。

假设有一个类SimpleParser(如代码清单1-1所示)需要测试。这个类有一个方法

ParseAndSum。ParseAndSum的输入是由零个或多个逗号分隔的数值组成的字符串。如果输入

字符串不包含数值,方法返回0。如果输入包含单个数值,方法返回这个数值的int值。如果输

入包含多个数值,方法把所有数值相加,返回总数(但是现在这段代码只能处理零个或一个数值)。

Page 9: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

10 第 1 章 单元测试基础

是的,我知道代码里的else语句不是必需的,但是我们也不用这么死板吧。我倒觉得这个else

语句增加了代码的可读性。

代码清单1-1 要测试的简单解析类

你可以创建一个简单的控制台应用项目,引用包含这个类的程序集,然后写一个如下面代码

清单所示的方法SimpleParserTests。这个测试方法调用这个产品类(即被测试的类),然后

检验调用的返回值。如果返回值和预期值不同,测试方法就会把结果输出到控制台。这个测试方

法也会捕捉任何异常,并且把异常信息输出到控制台。

代码清单1-2 测试SimpleParser类的一段简单代码

Page 10: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

1.5 一个简单的单元测试范例 11

1

2

3

4

5

8

9

6

7

11

10

接下来,你可以在这个项目的控制台程序里,使用方法Main调用你写的测试,具体请参见

下面的代码清单。方法Main在这里用作一个简单的测试运行器,逐个调用测试,让测试结果输

出到控制台。因为这是一个可执行程序,它可以无需人工干预自己运行(假设这些测试不会弹出

任何交互式的用户对话框)。

代码清单1-3 通过一个简单的控制台程序运行测试代码

捕获发生的任何异常并输出到控制台是测试方法的职责,这样异常就不会干扰其后方法的运

行。之后随着在项目中加入更多的测试,你可以在方法Main中增加更多的方法调用。每个测试

各自负责把问题输出(如果有问题的话)到控制台屏幕。

显然,这是一种随意的测试编写方法。如果要编写多个这样的测试,你可能希望有一个通用

的ShowProblem方法,所有的测试都可以用这个方法一致地格式化错误信息。你也可以添加专

门的辅助方法,辅助检查空对象、空字符串等情况,避免在多个测试中重复书写同样的冗长代码。

下面的代码清单展示了使用一个较为通用的ShowProblem方法的测试代码。

代码清单1-4 使用一个较为通用的ShowProblem方法

使用.NET的反射API得到当前方法名

Page 11: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

12 第 1 章 单元测试基础

单元测试框架可以像上面的示例一样创建更为通用的辅助方法,使测试更容易编写。我会在

第2章讨论测试框架的这个功能。在那之前,我们来看“如何编写单元测试”之外的一个重要问

题,那就是“在开发过程中应该何时编写单元测试”。这就是测试驱动开发要解决的问题。

1.6 测试驱动开发

一旦了解了如何使用一个单元测试框架编写结构化、可维护和可靠的测试,下一个问题就是

什么时候编写测试。很多人觉得为软件编写单元测试的最佳时机是软件编码完成以后,但是越来

越多的人选择在产品代码编写之前写单元测试。这种方法称为测试优先或是测试驱动开发

(Test-Driven Development,TDD)。

注意 对于测试驱动开发的具体含义,人们有很多不同的观点。有人说测试驱动开发是测试优先

开发,有人说测试驱动就是进行大量的测试。有人说测试驱动开发是一种代码设计方法,

其他人则觉得只有使用某些设计时,测试驱动开发才成为一种驱动代码行为的方法。要更

全面地了解人们对TDD的观点,请参考我的博客文章“TDD的各种含义”(The various

meanings of TDD,参见http://osherove.com/blog/2007/10/8/the-various-meanings-of-tdd.html)。

在本书中,TDD意指测试优先开发,设计仅作为这项技术中的次要方面(本书中不讨论

设计)。

图1-3和图1-4展示了传统编码方式和TDD之间的区别。

硬编码方法名也是可以的 但是这个得到方法名的技巧很有用

调用辅助方法

Page 12: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

1.6 测试驱动开发 13

1

2

3

4

5

8

9

6

7

11

10

图1-3 编写单元测试的传统方法。虚线表示人们认为是可选的行为

图1-4 测试驱动开发概要图。注意,这是一个螺旋式的过程:编写测试,编码,

重构,编写下一个测试。这张图展示了TDD的增量特性:小步骤的积累得

到高质量的最终结果

如图1-4所示,TDD和传统开发方式不同。你首先编写一个会失败的测试,然后创建产品代

码,并确保这个测试通过,接下来是重构代码或者创建另一个会失败的测试。

本书关注的是编写优秀单元测试的技术,而非测试驱动开发方法,但是我非常推崇TDD。我

曾经用TDD编写过几个重要的应用程序和框架,也管理过使用TDD的团队,还讲授过100多堂关

Page 13: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

14 第 1 章 单元测试基础

于TDD和单元测试技术的课程和讲习班。在我的整个职业生涯中,我发现TDD能帮助创建高质量

代码和高质量的测试,还能更好地设计代码。我相信TDD对你也会有帮助,但是你需要付出很多

(学习的时间、实施的时间,以及更多),当然这些付出绝对是值得的。

我们必须认识到,TDD并不能保证项目一定成功,或者测试都健壮、可维护。人们很容易陷

入TDD技术的细节,而忘记关注编写单元测试的方式:测试的命名,测试的可维护性和可读性,

以及是否测试了正确的内容,或者测试本身是否有缺陷。这也是我要写这本书的原因。

TDD的技术相当简单。

(1) 编写一个会失败的测试,以证明产品中代码或者功能的缺失。编写测试的时候,要假设

产品代码已经能工作 了,这样测试的失败就说明产品代码中有缺陷。如果要在一个计算器类中

添加一个新功能,记住LastSum的值,我就会写一个测试来验证LastSum确实是正确的值。这个

测试最初会编译失败,只有在添加了需要的代码后,编译才能通过(但是还没有实现记住数值的

真正功能),然后测试可以运行,但是会失败,因为我还没有实现所需的功能。

(2) 编写符合测试预期的产品代码,使测试通过。产品代码应该尽量简单。

(3) 重构代码。如果测试通过了,你就可以编写下一个单元测试,或者进行重构,使代码可读

性更强,或者去除重复代码等。

重构可以在编写多个测试之后进行,也可以在每个测试后都进行。重构是一项重要的实践,

它确保代码更易读,更好维护,同时还依然能通过之前编写的所有测试。

定义 重构意味着在不改变一段代码功能的前提下修改代码。如果重命名了一个

方法,你就进行了重构。如果把一个较大的方法分成了多个较小的方法调用,你也对代码

进行了重构。代码还是具有同样的功能,但重构后变得更容易维护、理解、调试和修改。

这些步骤听起来很技术性,但是其间蕴藏着很多的智慧。如果实施得当,TDD可以极大地提

高代码质量,减少缺陷数量,提升你对代码的信心,缩短发现缺陷的时间,优化代码设计,让你

的经理更满意。但如果实施不当,TDD可能导致项目延期,浪费时间,打击士气,降低代码质量。

TDD是一把双刃剑,很多人在失败之后才体会到这一点。

确切地说,没有人会告诉你TDD有一个最大的优点:如果一个测试失败了,没有经过修改再

次运行又成功了,你其实是在测试这个测试本身。如果你预期一个测试会失败,但它却成功了,

那测试本身可能有缺陷,或者测试的对象不对。如果一个测试之前失败了,你现在预期它成功,

它却依然失败,那测试可能有缺陷,或者测试预期的结果不正确。

本书讨论的是可读、可维护并且可靠的测试,但是只有当你看到测试该成功的时候成功,该

失败的时候失败,才能获得对它们最大的确定。在这方面,TDD对你会有很大帮助,这也是为什

么和编码之后进行单元测试相比,开发人员在使用TDD开发时进行代码调试要少得多。如果开发

者信任测试,他们不会为了只是以防万一而调试代码。这种信任只能通过看到测试的两方面获得:

该失败的时候失败,该成功的时候成功。

Page 14: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

1.8 小结 15

1

2

3

4

5

8

9

6

7

11

10

1.7 成功进行 TDD 的三种核心技能

成功进行测试驱动开发,你需要三种技能集:知道如何编写优秀的测试、在编码前编写测试,

以及良好的测试设计。

仅仅做到先编写测试,并不能保证测试是可维护、可读以及可靠的。你正在读的这本书

讲的全都是进行优秀单元测试的技巧。

仅仅做到编写的测试可读、可维护,并不能保证你获得先编写测试的各种好处。市面上

大部分讲TDD的书介绍的都是测试优先的技能,而不讲授优秀测试技能。我特别推荐Kent

Beck的Test-Driven Development: by Example(Addison-Wesly Professional,2002)。

仅仅做到先编写测试,并且测试可读、可维护,并不能保证你得到一个设计完善的系统。

设计能力才是使代码优美、可维护的关键。关于这方面的好书,我推荐Steve Freeman和

Nat Pryce的Growing Object-Oriented Software, Guided by Tests(Addison-Wesly Professional,

2009)以及Robert C. Martin的《程序员的职业素养》。

学习TDD的一个实用方法是分别学习以上三个方面的技能。也就是说,一次只关注一种技能,

忽略其他的技能。我经常看到人们试图同时学习这三种技能,学习过程非常艰难,最后因为难度

太大而放弃。所以我推荐一次只关注一种技能的学习方法。

通过采用这种比较循序渐进的方法来学习这个领域的知识,你一段时间只关注一个方面,不

会总是担心在别的领域犯错。

至于按什么顺序学习,我没有什么具体的设想。我很希望了解你学习这些技能的经验和学习

时的建议。你可以在http://osherove.com获取我的联系方式。

1.8 小结

在本章中,我定义了一个优秀单元测试应具有的特质,如下所示:

一段自动化的代码,它调用另一个方法,然后检验关于此方法或类的逻辑行为的某些假设;

用一个自动化测试框架编写。

容易编写。

运行快速。

能由开发团队里的任何人重复执行。

要理解什么是一个单元,你需要弄清楚自己以前所做的是什么类型的测试。你发现以前所做

的是集成测试,因为它测试的是一组互相依赖的单元。

掌握区分单元测试和集成测试的知识很重要。作为一个开发人员,你每天都会用到这一知识,

以决定在何处测试、何时编写何种类型的测试,以及对一个具体的问题用哪种测试更好。这一知

识也能帮助修复那些已经令你头疼不已的测试。

我们还了解了不使用框架进行集成测试的缺点:这种测试很难编写及自动化,运行缓慢,需

要进行配置。虽然在项目中的确需要集成测试,但单元测试能够在开发过程的早期提供更多的价

Page 15: 第1章 单元测试基础images.china-pub.com/ebook3770001-3775000/3770394/ch01.pdf · 10 定义1.0 一个单元测试是一段代码(通常是一个方法),这段代码调用另一段代

16 第 1 章 单元测试基础

值,在这个阶段缺陷较小,更容易发现,需要检查的代码也比较少。

最后,我们探讨了测试驱动开发,包括它与传统编码方式有什么不同之处,以及它有哪些基

本好处。TDD能帮助你确保测试代码的代码覆盖率(你的测试调用了多少产品代码)非常高(逻

辑代码覆盖率接近100%),确认测试可信。它让你看到测试在预期的时候成功或者失败,以此来

“测试你的测试”。TDD还有其他的许多好处,例如辅助代码设计,降低代码复杂度,帮助你逐步

解决难缠的问题。但是如果你不知道怎么编写优秀的测试,就不可能持续地成功实践TDD。

在下一章里,你将学习使用.NET开发者最常用的单元测试框架NUnit,开始编写你的第一个

单元测试。