版权信息

书名：测试驱动开发的艺术

作者：Lasse Koskela

译者：李贝

ISBN：978-7-115-23836-8

本书由北京图灵文化发展有限公司发行数字版。版权所有，侵权必究。

您购买的图灵电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

目录

版权声明

前言

关于本书

第一部分　TDD入门

第1章　综述

1.1　挑战：用正确的方法解决正确的问题

1.2　解决方案：测试驱动

1.3　正确地做事：TDD

1.4　做正确的事：ATDD

1.5　TDD工具

1.6　小结

第2章　TDD入门

2.1　从需求到测试

2.2　选择第一个测试

2.3　广度优先，深度优先

2.4　别忘了重构

2.5　添加错误处理

2.6　无穷尽的测试

2.7　小结

第3章　小步重构

3.1　探寻解决方案

3.2　以受控的方式修改设计

3.3　进一步延伸新设计

3.4　小结

第4章　TDD的概念与模式

4.1　如何编写及通过测试

4.2　重要的测试概念

4.3　近处观察测试替身

4.4　提高设计的可测试性的准则

4.5　单元测试模式

4.6　在遗留代码基础上工作

4.7　小结

第二部分　针对特定技术应用TDD

第5章　测试驱动Web组件

5.1　在60秒内介绍Web应用中的MVC

5.2　控制器

5.3　用测试先行的方法构建视图

5.4　在基于控件的Web框架基础上TDD

5.5　小结

第6章　测试驱动数据访问

6.1　探索问题领域

6.2　用单元测试驱动数据访问

6.3　编码前写集成测试

6.4　集成测试实战

6.5　为集成测试填充数据

6.6　使用单元测试还是集成测试

6.7　文件系统访问

6.8　小结

第7章　测试驱动不可预测功能

7.1　测试驱动时间相关功能

7.2　测试驱动多线程代码

7.3　标准同步对象

7.4　小结

第8章　测试驱动Swing代码

8.1　Swing UI中该测试什么

8.2　可测试UI代码的模式

8.3　测试视图控件的工具

8.4　测试驱动视图组件

8.5　小结

第三部分　基于ATDD构建产品

第9章　解析验收测试驱动开发

9.1　用户故事介绍

9.2　验收测试

9.3　理解过程

9.4　作为团队活动的ATDD

9.5　ATDD的好处

9.6　我们究竟要测试什么

9.7　工具概览

9.8　小结

第10章　用Fit创建验收测试

10.1　Fit是什么

10.2　三个内建夹具

10.3　FitLibrary对内建夹具的扩展

10.4　执行Fit测试

10.5　小结

第11章　执行验收测试的策略

11.1　验收测试该检测什么

11.2　实现方式

11.3　技术相关考虑

11.4　常见问题的处理技巧

11.5　小结

第12章　TDD应用

12.1　成功采用TDD的必要条件

12.2　让其他人参与进来

12.3　如何应对阻力

12.4　如何推进变革

12.5　小结

附录A　JUnit 4简明教程

附录B　JUnit 3.8简明教程

附录C　EasyMock简明教程

附录D　通过Ant运行测试

相关资源

版权声明

Original English language edition, entitled Test Driven: Practical TDD and Acceptance TDD for Java Developers by Lasse Koskela, published by Manning Publications Co., 209 Bruce Park Avenue, Greenwich, CT 06830. Copyright © 2008 by Manning Publications Co.

Simplified Chinese-language edition copyright © 2010 by Posts & Telecom Press. All rights reserved.

本书中文简体字版由Manning Publications Co.授权人民邮电出版社独家出版。未经出版者书面许可，不得以任何方式复制或抄袭本书内容。

版权所有，侵权必究。

前言

七年前，正值全球IT产业繁荣时期，大大小小的软件公司都发了疯似地想赶上下一波IPO，招聘市场火爆异常。我也在此时投身到繁荣的新媒体产业，开始了我的编程生涯。从此我没日没夜地鼓捣各种代码段，配置服务器，往生产系统里上传PHP脚本，似乎一切尽在掌握。

一个九月的雨夜，又是加班到很晚，突然间我的心脏似乎停止了跳动：哎呀！我刚才做了什么？我是否删掉了生产数据库里的所有数据？好像是的！看来我只有卷铺盖走人了。我怎么才能把数据找回来呢？刚才还以为只是测试用的数据库呢！这种悲剧怎么能发生在我头上？然而，确实发生了。

第二天我没有被炒鱿鱼，主要原因是，看来客户对我删掉的数据并不太在意。而且，看来别的人也都干过类似的蠢事——他们安慰我说：大家都可能犯错。我得到一个教训，那个该死的夜晚也标志着我开始追求一种负责任的、可靠的软件开发态度。

几年以后，我换了家国际性咨询公司工作，为其他大公司开发应用和后台系统。在短短几年的职业生涯里我学到了不少东西，这得归功于我以前趴在电脑前熬夜的努力，而新工作无疑是我磨炼实战技艺的好机会。我又一次认为我已经对于软件开发行当熟门熟路了。可是我又错了，显然我比自己想象的要知道得少。我几乎每天都能学到重要的新知识。

我最重大的发现改变了我对软件开发的认识，极限编程（XP）给了我全新的视角，让我知道什么才是正确的软件开发方法。在我看来，XP把我过去行之有效的披荆斩棘式的编程方式与一种系统的、训练有素的工作方法结合在一起。XP项目除了能让开发团队更接近客户之外，最打动我的就是测试驱动开发（TDD）了。我以前认为编程和单元测试是两个分离的活动，现在“编码之前先写测试”这样一个简单的理念完全颠覆了我的旧思想。

TDD绝非闲庭信步那样轻松。我时刻提醒自己要先写测试，一开始能做到，可是只过了半个小时，我就忘了遵守，还没有测试就在修改代码。随着时光流逝，我越来越能够坚持测试先行的编程方法，甚至一整天都不会落入往日的陋习中。接着我会被一段代码难住，凭我的能力我无法征服它。再往后，我能理解应该怎么做，但我的手法还不够用。再后来，我不知道如何能四两拔千斤地巧妙解题，却又往往不愿意愚公移山般地用笨办法尝试。年复一年，我学会了越来越多的技巧，掌握了越来越多的工具，终于获得了现在的功力。

我写此书的目的是让诸君不必像我以前那样笨拙地克服种种困难，你们有此书在手可以轻松地前行。对我而言，学会了测试先行，深刻地影响了我工作的方法和对编程的认识，正如敏捷方法改变了我对软件开发的认识。我希望你们也能学会测试先行。

关于本书

为了更好更快地开发软件，软件开发人员经过实践和思考，提出了测试驱动开发的方法。本书作者作为软件开发人员，希望降低其他人学习TDD的难度。鉴于刚接触TDD的开发人员最头疼的是一些技术性问题，本书主要采用了“手把手”的教学方式。不仅会通过动手做例子来解释TDD，而且还将用几章的篇幅专门讲解如何为通常认为难于测试的技术来编写单元测试。只有动手实践才能收到最佳的学习效果，而本书可以在你的探索之旅中充当一部导航仪。

本书读者对象

本书面向各层次的Java程序员，特别是希望提高效率和编码质量的人。测试驱动开发会为你提供一个框架，让你能增量地构建软件，借以释放你的潜能。不管你是在开发导弹控制系统，还是在创造下一个YouTube，采用TDD都不会让你失望。

本书的第二类读者不一定对TDD感兴趣，但作为Java程序员，他们想在测试方面寻求一些帮助。测试驱动开发主要是一种设计和开发技术，但编写单元测试却是TDD中的一项基本工作。本书同样能为这一类读者提供纯粹编写测试的帮助——我们介绍了为很多（所谓的）难于测试的技术，如访问数据的代码、并行程序，以及用户界面代码编写测试的方法。

不管你是想做好工作，还是想自己提升，本书都能助你一臂之力。

路线图

本书涵盖了很多背景和基础知识。全书分为三大部分，关注不同的主题。

第一部分从最基础的内容讲起，讨论测试驱动开发和验收测试驱动开发。

第1章先提出问题（即要面对的挑战），然后解释了TDD和ATDD给出的有效解决方案，包括测试先行编程、渐进设计、测试自动化和果断重构。

第2章开始动手实践，为深入理解TDD展示了一个例子：以测试驱动方法编写的模板引擎。这一章还将讨论如何管理测试和选择下一个测试。

第3章以第2章为起点，为模板引擎引入了一些设计变更，从一个Spike开始，然后再以可控、严格的方式来修改模板引擎。

第4章回头再讲述策略，从选择测试到通过测试。这一章会讨论一些基本的测试概念，如夹具、测试替身，还会讨论基于状态和基于交互的测试有什么区别。在说明了如何创建可测试设计的几条准则之后，这一章还介绍了几个重要的测试模式以及如何以测试先行的方式测试遗留代码。

第二部分同样侧重于动手实践，逐一讲解了如何对那些以往认为不好测试的技术应用TDD。学习完第二部分，你就明白过去的认识有多么可笑。

第5章先从Web开发讲起。学习如何对使用Servlet和Spring Controller生成的请求/响应式的Web层进行测试驱动开发，以及测试驱动由JSP和Apache Velocity模板构建的表现层。这一章最后还介绍了借助基于组件的框架对Web应用程序进行测试驱动的开发。

第6章探讨如何测试驱动Web组件背后的数据访问层。将分别介绍基于原始的JDBC、Spring Framework的Jdbc Template API和事实上的ORM工具Hibernate来测试驱动数据访问对象。另外，还将讨论在单元测试中处理数据库问题的方法和集成测试。最后，展示了一些有关文件系统的技巧。

第7章探索了新的未知领域：不确定行为。在简单介绍了与时间相关的几种选择后，首先转向多线程，探讨了几个能够测试的方面，还讨论了线程安全、阻塞操作、启动和停止线程及异步执行。这一章最后讲解Java 5中新增的java、util、concurrent中的同步对象。

第8章讨论界面，即Java Swing应用程序的界面。首先，也是从测试驱动的UI代码该测试哪些方面入手。然后，介绍三个有用的设计模式和两个开源工作（Jemmy和Abbot），它们可以简化对Swing组件进行单元测试的工作。这一章（也是第二部分）最后提供了一个例子——测试驱动定制Swing组件的界面和行为。

第三部分调整步调，从讨论具体的测试驱动对象和类，上升到基于验收测试驱动的测试先行的方式，构建完整的系统。

第9章讨论用于需求管理的用户故事和验收测试的基本概念，之后，解释ATDD的过程和对团队的需求，以及基于ATDD开发软件的好处。这一章最后讨论验收测试时应说明系统的哪些方面和一些工具。

第10章介绍流行的验收测试工具Fit。首先讨论如何使用Fit与用户协作，即先以表列格式列出验收测试，再将其转换成Fit格式。然后讨论将表列测试与系统关联起来的技术，涉及三个Fit内置的标准夹具和FitLibrary提供的几个实用工具。最后，讨论如何在命令行中和以Ant构建方式来运行Fit测试。

第11章探讨如何实现与工具无关的验收测试。在介绍了几种将测试与系统连接起来的方法后，讨论了这些技术的优缺点。最后还与读者分享了提高验收测试效率和应对复杂性的技巧。

第12章讨论了如何成功地推行TDD。开头探讨了实现长久改变的先决条件，事关我们自己和同事。接着关注反对意见：怎样识别和处理反对意见。最后给出了很多确保推行成功的建议。

考虑到单元测试在测试驱动开发中的重要性，本书附录还为读者准备了相关的三个工具的简明教程。附录A和附录B分别展示了JUnit单元测试框架4.3和3.8版本的语法。附录C也差不多，它展示了EasyMock（用于生成测试替身的动态模拟对象框架）的语法。

能在自己喜爱的IDE中编写测试驱动代码的确很棒，但我们还希望把测试的部分也融入自动构建的过程中。这正是添加附录D的原因。附录D提供了Java开发人员使用的标准构建工具——Apdche Ant中运行单元测试的简明教程。

代码约定

本书代码主要有Java代码、标记代码和输出结果。对于大段代码，我们将配以标题。而小段代码则直接与正文混排在一起。无论是在哪里，代码都以等宽字体印刷，以区别于正文。在第二部分，经常会引用代码清单中的标识符和元素，这些也都使用等宽字体。另外，特别长的代码清单中还会有一些编号的注解，以便于正文中引用说明。

下载代码

可以从Manning公司网站下载本书全部示例代码，地址为：http://www.manning.com/koskela。其中包括本书中出现和省略的代码。

下载的文件中包含Maven 2 POM文件及使用Maven（http://maven.apache.org）编译和运行示例的说明。请注意，下载的文件中不包含依赖文件。在第一次运行Maven时，它会在能上网的情况下帮你下载必需的依赖。此后，即使断开网络连接也可以在本地运行示例了。

我们强烈建议读者安装一个IDE。本书中的示例代码是Eclipse项目的格式，因此需下载并安装Eclipse（http://www.eclipse.org）的最新或最高版本。当然，IntelliJ IDEA（http://www.jetbrains.com/idea）和NetBeans（http://www.netbeans.org）也不错，只不过你得自己配置一下项目。

在线资料

第12章本来还有一个主题，讨论测试驱动企业级JavaBean，但未包含在本书中。我们把这个有40多页篇幅的内容放在了网上，其中对开发人员给出了相关的详细建议。

网上这一章的内容涵盖用来封装业务逻辑的会话bean、面向持久性的实体bean、异步消息驱动bean和Timer API。

虽然我们致力于介绍最新的EJB 3.0规范，但我们给出的建议实际上同时适用于3.0及2.x AIP。这样做的原因是很多遗留系统仍在使用EJB 2.x规范。

可以在如下地址下载这一章：http://www.manning.com/kosketa。

深入学习

通过本书可以掌握与测试驱动开发有关的很多知识，但这些恐怕还不能满足你的需求。好在，Manning提供了网上论坛，让读者可以与其出版图书的作者在线沟通。当然也包括本书作者在内。访问http://www.manning-sandbox.com/forum.jspa?forumID=306，即可与本书作者Lasse交流。

关于测试驱动开发这种技术或方法，很难用一本书涵盖其方方面面。因为TDD还在发展，而且针对不同领域编写测试往往有很大差别。书中总会有一些应该收录但没有收录的内容。为此，读者还应该知道如何寻求帮助。我们推荐专门讨论TDD及相关问题的Yahoo!论坛testdrivendevelopment。如果有着急的问题不知问谁才好，就在这个邮件列表中提问吧！

如果Yahoo！论坛还不能满足你的需求，你还可以订阅http://www.testdriven.com，这是TDD的门户网站。这个网站会推荐一些相关的文章、博客和工具。当然，行业内有关敏捷方法的一些会议也经常会以TDD为主题，遇到这样的活动，不妨参加一下。

我们约好了，在TDD社区再见！

作者在线

买了这本书之后，读者就可访问Manning公司的论坛，发表对本书的评论，提出技术问题并获得作者和其他用户的帮助。要访问和订阅论坛，请访问http://www.manning.com/koskela。相应页面会给出注册之后如何使用论坛，如何寻求帮助，以及论坛版块划分的信息。

Manning提供这个论坛的初衷就是让分散的读者能有机会聚到一起，与作者进行互动交流。但这并不表示作者有义务在论坛中发言，作者对相应图书论坛的贡献都是自愿的（而且没有物质回报）。为此，我们建议读者在论坛中多提一些有挑战性的问题，免得他们失去动力。

在拿到本书时，上面提到的作者在线论坛就已经开通并可以访问了。

关于封面插图

本书封面上的插图名为Franc Comtois，他是一位法国东北部小镇勃艮第省Free郡的居民。勃艮第以前一直是一个独立的地区，直到17世纪才并入法国版图。该地区有自己独特的传统和语言，这种名为“弗朗什—孔泰”的语言如今还在使用。

本书的插图取自一本1796年出版的法国游记——Encyclopedie des Voyages，作者为J.G.St.Saveur。在当时，旅游业才刚刚盛行，像这本书这样的旅游向导非常流行，无论是观光客还是足不出户的“神游旅行者”，这本书都能为他们了解法国以及世界各地人们的生活提供帮助。

Encyclopedie des Voyages一书中的插图生动展示了200年以前世界各地的城镇和地区之间文化的差异。相隔仅几十公里远的人们的服饰风格也不一样，通过服饰风格就能辨认出对方来自哪个地区。有了这本书，我们就可以体会到那个时代的人们之间的巨大文化差异，体会到那么多富有浓郁地方特色的服饰文化。如今，这一切都逐渐消失了。现在已经很难通过服饰来区分不同地区的居民了。也许我们用文化多样性换来了更加多样化的个人生活，也是更快节奏的科技生活。

当所有的计算机书籍都变得千篇一律的时候，Manning出版社则通过体现两个世纪以前丰富多彩的各地生活的图片作为封面插图，以这样的方式赞美计算机行业中的独特性和主动性。

第一部分　TDD入门

这一部分是TDD（Test-Driven Development）入门，引领读者体验测试驱动的艺术。第1章来认识TDD和它的延伸概念——验收测试驱动开发（Acceptance TDD，ATDD），我们从最基本的知识入手，对这两种技术先有一个大概的了解。第2章进入实际动手环节，将通过修改和运行实际代码深入体验测试先行的好处。第3章将更进一步，向读者展示大规模重构的例子，借以看看设计将会有多么显著的变化。

几年来，通过向一批批的程序员讲授TDD，我体会到实践是最好的老师。在学习了第2章和第3章并亲手实现功能完善的模板引擎之后，就可以接触一些重量级的技术了。第4章介绍了运用TDD思想的一些技巧和方法，包括如何选择下一个测试并使其通过。此外，必要时还将讨论相关的设计原则和测试工具。

本部分内容

- 第1章　综述

- 第2章　TDD入门

- 第3章　小步重构

- 第4章　TDD的概念与模式

第1章　综述

我能忍受暴力，但施暴的理由却让我无法容忍。

——奥斯卡·王尔德

测试驱动开发（Test-Driven Development，TDD^1），用一句话讲，就是“写代码只为修复失败了的测试”。我们先写一个测试，然后再写代码让测试通过。当我们在当前结构中找出最佳设计时，由于有足够的测试做保障，我们可以放心地改动现有设计而不必担心破坏已完成的功能。使用这种开发方法，我们可以让设计更加优良，能编写出可测试的代码，同时还能避免在不切实际的假设基础上过度地设计系统。要得到这些好处，只需不断添加可执行测试，一步步地驱动设计，从而最终实现整个系统。

1 缩写TDD有时也代表测试驱动设计（Test-Driven Design）。有人把TDD称为用测试先行编程（Test-First Programming），只不过叫法有区别而已。

这本书就是讲如何实行这每个小步骤的。在后面的章节中，我们将会学到TDD的本质以及玄妙之处，学到如何利用TDD开发普通Java应用程序及企业级Java应用程序，以及如何用ATDD（Acceptance TDD，验收测试驱动开发，测试驱动开发核心理念的一个扩展）来驱动整个开发过程。在用测试驱动开发实现某个具体功能之前，我们将会首先编写功能测试或验收测试，从系统功能角度驱动开发过程。

TDD其实并不是一个新概念。很久以前，不少开发人员就认为编写测试不仅仅是为了验证系统正确性了。至今你还可以从他们那里听到这个故事：从那时候起，我们写代码之前都会先写测试……。而现在，这种开发方法有了自己的名字——TDD。本书的大部分内容都是关于“如何测试驱动”以及“测试驱动什么”的。这些知识可以被用到各种日常软件开发任务中。

不过，相对于主流开发方法，TDD仍然很新。就像今天的日常用品曾经都是十足的奢侈品一样，新的开发或设计方法，通常都是只有资深的开发人员才能拥有的高级货，而在很多年以后，等这些先驱证明了新方法确实有效，这种方法才会被广泛接受，成为开发必备技能。

我相信TDD会逐渐地变成主流开发方法，而且我认为，TDD已经开始变为主流开发方法了。希望本书能够推动这个过程。

在本书的开始部分，我们会先讨论现有的开发方法在开发软件过程中遇到的挑战。一旦对现存问题有了基本共识，我们将着手讨论如何用TDD或ATDD解决这些问题，同时也会学着使用一些支持此开发方法的工具。

1.1　挑战：用正确的方法解决正确的问题

开发软件的目的是为协助组织的经营运作。作为一个专业的软件开发人员，我们的主要任务是向客户交付一个能够真正帮助他们提高工作效率并减少运作成本的系统。

回顾我多年的专业软件开发经验，参阅几十年来软件开发的文献记载，再看看整个世界范围内的技术人员对于开发方法的争论，我们不难看出很多开发组织本应向他们的客户交付更好的软件。换言之，我们现在交付的软件可不怎么好用。就算这些软件能够正常的工作，它们也没有真正解决客户的问题。从本质上讲，我们写出的代码并没有满足客户的真正需求。

接下来，我们来看看为什么“糟糕的代码质量”和“不能满足客户日益变化的需求”会妨碍我们给客户交付足够好用的系统。

1.1.1　糟糕的代码质量

虽然软件开发行业已经发展了很多年，但是开发出软件的质量依旧存在问题。近些年市场越来越注重软件的及时上市，对软件产品的需求量越来越大，同时很多的新技术也不断涌现。在这种情形下，软件行业将不可避免地继续面临质量问题。

这些质量问题主要表现在两个方面：高缺陷率和低可维护性。

1. 在缺陷的泥潭中挣扎

缺陷会带来很多额外的开销，因为它会导致软件不稳定，行为不可预测，或者完全不能使用。缺陷减少了软件本身的价值，有时甚至使软件造成的破坏远大于创造出的价值。

测试可以解决这些问题——我们仔细地检查软件是否像期望的那样工作，同时也试着通过某种方式检测其是否稳定。测试在软件开发中的重要性是毋庸置疑的，但是传统的测试方法只会在整个软件开发完毕并且代码“冻结”后才进行，而且会耗费很长时间。这种测试方法有很大的改进空间。例如，在测试阶段修复一个缺陷的成本，通常是在编码阶段就修复这个缺陷成本的两倍或者更多。有缺陷的软件是不能交付的。我们在寻找并修复缺陷上用的时间越多，开销越大，我们开发软件的能力也越低。

软件缺陷通常是由低质量的代码引起的，要维护这些代码简直就是噩梦，而想对其进行进一步开发也会举步维艰，代价高昂。

2. 维护困难，开发缓慢

好的代码有很多优点：整体设计优秀，清楚地划分出了各部分之间的功能和责任，并且没有重复。而糟糕的代码可不是这样，成天和这种代码打交道简直就是噩梦。这些代码很难读懂，修改也很困难，因此工作效率会很低。改这里，还会莫名其妙地破坏掉那里的功能。重复的代码使修复缺陷也变得不容易，改完一处，还要找出其他所有重复的代码，挨个修正同样的缺陷才算完。糟糕的代码带来的问题还远不止这些。

“我不想碰它，那任务永远不可能完成，如果做了，天知道会破坏掉其他什么功能。”这是软件业需要解决的一个很现实的问题。想修改或者添加新功能时，应该是基于现有系统继续开发，而不是重写，这就是可维护性的重要之处。有良好的可维护性，我们才能及时应对迅速变化的业务需求。如果代码的维护性差，那么我们行动和响应的速度会变慢，按时交付的压力会变大，这会促使开发人员写出质量更差的代码。如果想持续地交付软件，我们必须打破这种恶性循环。

这些问题已经够糟糕了，但是还有其他问题：我们交付的软件通常都没有完全满足客户的需求。

1.1.2　不能满足客户需求

没人愿意花钱当冤大头，但软件行业的客户就经常被逼着这么做。在软件开发前期，客户和开发人员互相交换规格文档，然后开发工作就此开始。十二个月以后客户拿到系统，才发现这并不是自己当初想要的。更不用说在当前激烈的商战下，客户的业务需求和十二个月以前常常大相径庭。

由于交付的系统经常不能真正满足客户需求，软件开发行业一直在尝试用新的方法开发软件。我们试着在各种规格文档上多花些功夫，但效果却适得其反。我们试着延长交付系统的时间，却导致更多的人参与进开发过程。生产软件是为了支持人工作，但更多的人却为了生产软件而工作。另外，在开发初期确定过多的细节会导致文档不可靠。由于细节上的假设环环相扣，规格文件中的错误能轻易拖垮整个项目。

软件行业的业绩不佳，但也不必因此沮丧，因为这些问题有办法解决。敏捷软件开发^1——包括Extreme Programming（极限编程）和Scrum等方法——就是解决这些问题的良药。本书余下的内容会详细介绍敏捷开发方法的核心实践——测试驱动。

1 Craig Larman所著的Agile & Iterative Development: A Manager's Guide（Addison-Wesley, 2003）一书很好地介绍了敏捷开发方法。

1.2　解决方案：测试驱动

我们遇到的问题可以分为两个方面：代码质量不高和不能满足客户需求。解决方法也存在两个方面：学会正确地构建系统，以及学会构建正确的系统。本书所介绍的测试驱动方法能很相似地应用在这两方面，差别只在于如何利用测试来构建出维护性高并能真正满足客户需求的软件。

在细节层面上，我们用TDD方法以测试驱动的方式编写代码。在较高的，即软件的特性和功能层面上，我们使用类似的ATDD方法以测试驱动的方式构建系统。图1-1从提高内部质量和外部质量角度上描述了这种方法组合。

从图1-1中可以看出，在这两个不同的层面上结合使用测试驱动，能保证软件的内部质量，同时能保证可见的外部质量。在下面的小节里，我们将会讨论TDD和ATDD是如何带来这些改进的。在深入技术细节之前，先看看测试驱动如何帮我们应付软件开发中的挑战吧。

图1-1　TDD用来提高软件的内部质量，而验收测试驱动开发保证开发出的软件能满足正确的功能需求

1.2.1　高质量的TDD

TDD鼓励优良设计，这种纪律严明的方式还能帮我们避免在开发中引入错误。用TDD开发，我们会写很多小的自动化测试，这些测试最终会组成一个有效的预警系统以防止代码蜕化。没人能在现有代码中凭空加入“质量”，而提倡短开发周期的TDD从项目开始就能保证较高的代码质量，并一直延续下去。

这种短开发周期的开发方式与旧方式有很大不同。我们习惯于先设计，然后编码实现，最后做一些并不完备的测试。（我们都是优秀的程序员，很少犯错，所以稍加测试即可，不是吗？）TDD完全颠倒了整个过程。我们会先写测试描述出目标，然后写代码达到这个清晰的目标，最后再设计——在已有代码的基础上找出最简单的设计。

开发周期的最后一步叫作重构。重构是种严格的方法，用于改变代码结构，消除重复，并改良设计。我们能通过持续重构逐渐地提升代码质量。

如果你还不够了解TDD的完整周期，不用担心，我们将会在1.3节中详细介绍。

回顾一下TDD的特点：这种开发方法能帮我们写出完全可测试的代码，在每个阶段演化出当前最佳的设计，还能帮我们避免陷入代码越写越糟的恶性循环。

说到质量，下面我们来讨论“质量”这相对抽象概念的具体含义，以及它对我们而言意味着什么。

1. 不同形式的“质量”

正如业内质量保证部门所见证的，人们倾向于把“质量”与软件的缺陷数量联系在一起。“质量”也可能表示软件对客户需求的满足程度。还有人认为“质量”表示的不仅是可见的外部质量，还应包括内部质量（这又可以表现为外部质量，例如开发成本维护成本等）。TDD可以提高所有这些不同定义的“质量”，因为它本质上有助于改良设计及提高代码质量。

测试没能覆盖所有代码分支是缺陷流入产品的首要原因。（或者是因为我们有些懒惰，没有认真地执行所有测试——至少不太认真，所以让缺陷漏过去了。）

使用TDD，我们能保证所有的代码都是有用的^1，而且都会被测试覆盖。因为有高覆盖率的自动化测试，TDD能保证每个测试覆盖的功能都不会出现问题。因此，如何提高质量的问题就转化成了如何编写高质量测试的问题，解决了这个问题，质量问题也就迎刃而解了。

1 每行代码都是为了通过测试而写，所以不会存在无用的代码。——译者注

编写高质量的测试有一定的技巧，例如需要针对正常执行路径、边界值以及可能的错误操作分别做测试。在这方面，TDD可以促使我们从外在接口的角度考虑模块和类的设计。由于不用先考虑实现细节，我们可以站在更恰当的角度设想这个类的行为以及其他开发人员可能的使用方式，因而更容易发现代码中存在问题。

由于TDD关注高质量的代码及优良的设计，以往用于调试的大量时间，现在都被用在实现用户需要的功能及改良现有系统的设计上了。

2. 花更少的时间修复缺陷

TDD能帮我们缩短修复缺陷所用的时间。在引入缺陷的两个月后再去修复显然比起立即修复代价要大得多，因此有必要尽力减少引入缺陷的数量，尽快发现软件中已有的缺陷。

若能保证写代码时测试先行地小步前进，那么调试器也基本没用了，因为只有新添加的几行代码才可能破坏测试，所以找出问题会很容易，也不会像传说中的高手那样长时间地调试代码了。我们可以更快地修复缺陷，相应的开发成本也会降低。每个遗漏缺陷都可能造成几百甚至几万元的损失^2，可不是一笔小数目啊。不用连续几个小时调试程序，我们也有更多的时间做其他更有意义的事情了。

2 http://www.jrothman.com/Papers/Costtofixdefect.html

更快速地完成用户所需要的功能有很多好处：我们有更多的时间用于清理代码，学习最新的工具和开发技术，赶上其他优秀的同事，等等。这些会帮助我们提高代码质量，变得更加自信，做事也更快。这样一来，我们TDD又会变得更有效。这种良性循环带来的提升空间几乎是无限的。

我们一会儿将会讨论TDD给我们开发人员带来的更多实惠，不过在这之前，我们先介绍ATDD吧。

1.2.2　用ATDD满足客户需求

TDD能从技术角度帮我们提高代码质量，使代码执行结果正确，容易理解，也容易维护。不过用TDD写出的代码的验证逻辑针对的是独立的代码块，而不是系统的具体功能。而且用测试先行的方法写出的最棒的代码也有可能做错事情，也许做出的功能不是客户想要的。因此，使用ATDD很有必要。要给系统添加新的功能，传统的做法是先写出需求文档，然后开发人员按照文档开发，完成后进行内部测试，最后交给客户做验收测试。ATDD则有些不同：我们会先写测试然后再实现功能，如图1-2所示。换言之，我们把客户需求转化成一系列可执行的测试，开发工作会基于这些测试，而不是基于开发人员各自对需求文档的不同理解。

ATDD拉近了最终用户和开发人员的距离，这是生产优秀软件的必要条件。在ATDD中，客户与开发团队会紧密地合作，定义出清晰的毫无歧义的测试，而不是花大量的时间写含糊不清的需求文档。这些验收测试能准确地告诉我们客户要求的功能是否已经完成。由于用户的需求全都转换成了可执行的具体的功能测试，因此可以确保开发出的软件切实地满足了客户的需求。

图1-2　ATDD用自动化的可执行测试驱动新功能的开发

这个过程和代码层面上的TDD很相似。用ATDD开发软件，我们会更关注系统行为的测试，而不是对象行为的测试。因此我们也更需要使用客户和开发人员都理解的语言交流。

TDD和ATDD需要配合使用。在系统层面上使用ATDD驱动开发过程，而在每个功能点的实现层面上则使用TDD。这两种TDD方法之所以要结合使用，并不是因为存在紧耦合，而是因为这样可以使其互相弥补，互相支撑，而且能够更好地发挥各自优势。

我们现在应该已经了解了，TDD和ATDD如何能帮我们交付高质量且满足客户需求的软件。我们随即将会深入了解TDD是什么，为什么能够帮我们开发出高质量的软件，以及如何“用正确的方法做事”。在1.4节中，我们将会讨论如何用ATDD在更高的层面上来驱动开发过程以满足客户需求，即“做正确的事情”。在这以前，先来讨论我们这些开发人员从测试先行的开发方式中能获得什么好处。

1.2.3　这对我有什么好处

没人会无缘无故地买辆新车，也没有人会仅仅因为开发方法够“新”就去使用它。要学一种新的工作方法必须有充分的理由，例如可以提高工作效率。我们现在已经知道使用TDD和ATDD可以帮我们提高代码质量，满足客户业务需求了。那么这种开发方法会如何让我们更享受工作呢？

根据我个人的使用经验，TDD起码有三大好处：

1. 我很少会接到技术支持电话，就算有，也不会为了找出问题而花很长时间调试；

2. 我对代码的质量很有信心；

3. 我有更多的时间提高自己的专业素养。

下面来一一解释这些好处。

1. 不再长时间调试代码

直到现在，我还记得几年前接手的一项开发任务。公司内部有一个专有文件格式解释器，我需要修复文件解释器中的一个缺陷。我阅读了成百行的代码，在程序的各个部分间频繁跳转，最终才明白如何修复这个缺陷。

当时我修改了解释器的部分设计，以为既可以修复缺陷还能使代码更容易理解（那时候我还没有开始使用TDD）。实现新设计并且通过编译用去了几个小时。我怀着由那灵巧的设计带来的激动心情打开了终端窗口，在测试服务器上安装了解释器，然后发现它居然不能正常工作！我完全不知道原因。用调试器运行代码后还是不知道问题所在。我当时花费了数小时时间用调试器跟踪执行代码，最后才发现了问题——是个极小的错误。我筋疲力尽地离开办公室，心情很沮丧，责怪自己太粗心大意。

后来我意识到这不是因为粗心，而是完成任务的过程存在问题——步子跨得太大，导致注意力分散。如果能用TDD的方式写代码，把开发过程分解为小的、集中的测试，想必我能立即发现那个代码分支中的问题。

好像那恐怖的调试体验还不够糟糕似的，墨菲定律^3再次向我招了招手。不久我接到了客户怒气十足的电话，抱怨解释器在生产环境中崩溃了。我在修改解释器的设计时引入了至少一个严重缺陷。能找出更优的代码设计是一回事儿，而凌晨三点被愤怒的客户经理叫醒是另外一回事儿。（而这位经理被一个更恼火的客户叫醒。）

3 事情如果有变坏的可能，不管这种可能性有多小，它总会发生。比如你衣袋里有两把钥匙，一把是你房间的，一把是汽车的，如果你现在想拿出车钥匙，拿出的往往是房间钥匙。

如果当时使用了TDD，或至少写了必要的测试，那个晚上我应该可以多睡两个小时的。这件事强烈地激发起了我写测试的意愿，因为突然意识到我过高估计了自己在工作方面的能力。

2. 对自己完成的工作很有信心

我们当然都希望写出的代码没有错误。如果代码中存在太多的问题饭碗就会难保。而另一方面我们又希望尽早完成工作，若在写代码上用去了过多的时间，日子同样也不会好过。因此，我们时常需要判断当前工作是否已经完成以及何时开始下项工作。

此刻，让我们打开记忆的匣子。回忆过去某个写代码时的场景，用一分钟回想起那个时刻。

你是如何写那段代码的？事先在记事簿上做设计？一下写完全部实现期望一次通过，否则就全部推倒重来？在循环中找出过明显的问题吗？首次编译能通过吗？

你如何验证某段代码能正常工作？专门为测试写一个主函数？在用户界面上点来点去确认功能已经实现？在测试中发现过问题吗？用调试器一步步跟踪代码执行？费尽周折只为修复一个很小的缺陷？调试代码和写代码大体上哪个更费时间？

无论答案是什么，我希望你已经基本回忆起那些让你充满信心的代码是如何编写出来的了。好，我来问你个问题。

如果你写出的代码完美无瑕，会不会感觉十分自信？如果知道代码正如文档中描述的那样正常工作，会不会感觉心情舒畅？我会的。如果你能很快地完成工作，同时保证代码质量没问题，这种感觉又如何？若能一直这样工作，会不会很开心？

我不能保证你使用TDD后开发出的软件就能完全没缺陷，因为最终还是你写代码，如何避免缺陷取决于你。我能保证的是，在使用TDD后，你会对你的软件很有信心，因为你明确知道软件在各种情况下会如何工作。

这个方法对提高软件内部质量同样有效。这可以说是个良性循环。测试越好，代码质量越高，对代码的改动也越有信心。对代码改动有了信心，能够改动的地方就越多。改动越多，代码的内部质量就越高，也更容易编写测试。这显然是件好事！

3. 有更多时间做其他事情

TDD和ATDD并不会提高我们敲键盘的速度，但能有效缩短由于低效开发方式（例如调试代码，理解可读性不高的代码，或因为误解需求而返工）所浪费的时间。随着开发一步步进行测试会不断累积，我们对代码质量也会更有信心。我们不再会怀疑代码在不同情况下会有不同的执行结果，也不会担忧一些数据的组合会使程序停止工作，因此也不必一遍遍重复地做测试。

对代码越有信心，我们就能越快地开始下一项工作。当然，有时自信是盲目的，不过即便如此，也会比这点一下在那输入点数据来确认功能是否正常要强的多。

TDD和ATDD不是“银弹”4，但也可以算是分时系统以来最能提高软件开发效率的技术了。在下一节，我们会详细讲解TDD，然后讨论ATDD。

4 西方文化中用“银弹”来形容最强一招、最有效的手段、最大关注度、王牌等。——编者注

下面开始吧。

1.3　正确地做事：TDD

TDD是一种开发和设计技术，可以帮我们增量式地构建系统，也能保证代码在任何时刻都不会错得离谱。测试正是我们小步前进的方法。

在本节中，我们将会学到TDD的工作原理，也会了解这种技术所带来的好处。我们先从TDD周期开始介绍，这是TDD的核心内容。然后我们会解释让软件从一开始就能正常工作的意义。若要增量式地构建整个系统，我们必须能只为当前做设计，而非一开始就设计完所有东西。我们会讨论如何用TDD实现增量式地构建系统。最后我们会讨论如何运用这种方法来保证软件每天都很“健康”，每天都能正常工作。

开始吧。接下来要讲解的是TDD的测试—编码—重构周期。

1.3.1　测试—编码—重构

本章第一段曾经提到 TDD的原则十分简单：

写代码只为修复失败了的测试。

换言之，就是首先写测试，然后编码让测试通过。有些人会觉得这种方法和在学校学到的有些矛盾。学校教我们先做设计，接着实现，最后测试以找出代码中的问题。而TDD颠倒了整个过程，如图1-3所示。

测试先行，然后编码，最后做设计。这种“设计后行”的思维方法不奇怪吗？一点也不。这种设计有别于传统的“设计—编码—测试”过程中的设计。并且因为这两种设计区别很大，这种事后设计的方法甚至有了自己的名字——重构，表示把当前设计转换成一个更佳设计的过程。改名后的TDD周期如图1-4所示：测试—编码—重构。

图1-3　TDD颠倒了传统的设计—编码—测试顺序。我们会先测试，然后编码，最后设计

图1-4　让我们测试驱动开发人员着迷的测试—编码—重构过程。这幅图精确地描述了开发的过程，很容易理解，看起来也很酷

这小小的“测试—编码—重构”周期看似简单，其内部却蕴藏着巨大的威力。它足以改变每个人的软件开发过程的质量，从而提高整个团队、项目及组织的软件开发过程的质量。

红—绿—重构

TDD周期，即添加测试、编写代码通过测试及修改设计，也可以用“红—绿—重构”表示。这些颜色有什么含义？

当我们进行TDD周期的第一步，添加一个测试时，测试会失败。这是因为这时候系统有问题，它并不具有我们所期望的功能。在一些开发环境中失败的测试会显示为一个红条，因此第一步为“红”。

第二步是写代码通过测试。我们实现了系统应该有的功能，所有都测试通过了，包括为功能添加的新测试以及已经存在的测试。这时候红条变成了绿条，所以称为“绿”。

周期的最后一步是重构，即改善现有代码的设计。因为重构只改变代码内部结构而并不改变外在行为，所以所有测试仍旧通过，还是“绿”。

红、绿、绿，红、绿、重构，很上口，不是吗？

我们将会在第二章详细讨论TDD周期中的每一步，不过在这之前，我们会先大致了解如何做，以及为何做这三个步骤。然后我们会讨论其内在机理。

1. 先写测试

在TDD周期中的第一步中，我们会写测试，实际上这并不只是写测试而已，而是在做设计。我们是在设计API，即用来访问新功能的接口。编码之前写测试，我们会很自然地考虑新代码的调用方式。这过程就像拼图一样。我们必须根据拼图周围的部分来选择拼哪块，如图1-5所示。

图1-5　若不试着使用，我们怎么会知道接口应当是什么样子？测试先行的编码方式会促使我们站在代码用户（开发人员）的角度考虑，设计出更易用的API

这并不是件容易的事。也许你从用户界面专家那里了解到了设计用户界面有多么重要。软件内部的设计又何尝不是如此？我们这些开发人员不就是代码的用户吗？

若从这种角度观察代码，我们的思路会发生很大变化。我时常呆望着一些第三方类库的API，思考着该如何使用它们。这些类库的开发人员肯定是站在开发者的角度设计接口的，完全没从用户的角度考虑。接口是好是坏用过才会知道。这绝对是真理。若能测试先行地开发，我们肯定会对类库的好坏有所体验。

注意　写测试时应当注意粒度。应当尽量写“正好足以失败”的测试，而不是一下写出整个功能点的测试然后花一个小时写代码让测试通过。问题领域、工具或技术都会影响编写测试用的时间，不过通常编写测试的时间都在几秒钟到几分钟之间。编码的时间也应如此。如果用的技术较复杂，那么写测试及编码的时间可能会变长。不过在第二部分我们将会提到那些所谓复杂的技术（例如Java Servlet或数据访问的代码）实际并不那么复杂。

设计简单易用的API并不容易，因此我们要善于借助工具。用测试来驱动设计就是行之有效的办法，用这种方法能设计出模块化并且可测试的代码。因为要测试先行，所以我们必须要让代码可以测试，没有任何商量余地。在TDD中代码都应当是可测试的，否则根本不会存在！

设计软件并不是只强调结构，满足当前的需要更加重要。一个会烧水、煮饭、炸薯片，还会做腌鸡的软件对一个只想喝杯茶的用户一点用都没有。如果你的汽车引擎多出来两个阀门或许没什么，因为需要额外动力时这些阀门或许能帮上忙。不过若要更换引擎中的所有阀门那麻烦可就大了。这就是过度设计的代价。

花钱开发并不需要的东西，还要为额外的复杂性买单。如果这项功能目前尚不需要，为什么要开发呢？不如先将这项功能记在备忘录上好了。有些功能完全有可能永远不用实现。

使用TDD做开发，你会明确地知道软件现在需要有什么功能。不是明天也不是昨天，就是现在。小步前进，写代码只为通过测试，我们就可以牢牢地控制住软件及其设计。有了自动化测试做保护伞，我们不再会误入歧途，而且会很清楚前进的方向，同时也能够确信所实现的功能正是用户所需要的。

“强调现在”正是TDD的核心。这核心思想会在TDD周期的第二步再次得到体现，即写恰巧足够的代码。

2. 写恰好足够的代码

编写恰好能通过测试的代码是TDD周期的第二步。为什么恰巧够就可以？新添加的测试之所以会失败，是因为它指出了系统应该有但尚未实现的功能。我们应该只用花几分钟就能实现这项功能，测试失败的状态不应持续太长时间。

让测试指出下一步该做的事情是TDD的基本理念。我们不仅仅是在制造代码，而是在实现一项具体的功能，而测试能毫无歧义地描述出这项功能。每次测试通过，我们都清楚地知道工作有了进展。

注意，写恰巧足够的代码是为了让测试尽快通过。因此当前的实现方式或许不是最优的。不过没关系，等功能实现、测试通过后，我们会回来解决这个问题的。有了测试做保护伞，就可以进行TDD周期中的最后一步——重构了。

3. 重构

重构是TDD测试—编码—重构周期的最后一步。我们回过头审视现有的代码设计，想办法改进。重构使TDD步伐更加稳健。使用TDD而不重构能迅速产生大量的烂代码。无论有多么充足的测试，烂代码终归还是烂代码。优良的代码质量能保证今后的开发效率，所以重构必不可少。这一步至关重要，所以我们需要用一整节详细讨论。

在这之前，我们先对小步地增量开发软件的方式做个概览吧。

1.3.2　增量式开发

所有敏捷软件开发过程都有一个共同点，即无论当前的功能有多么少，都要保证软件可随时发布，并且每天都要能持续产生可部署的版本（有些项目甚至每天都会构建出几个可发布的版本，直到项目完成）。这样，当最后发布期限来临时至少有可用的产品能发布。虽然产品或许不包括用户想要的所有功能，团队也可能没有完成迭代计划，但是有产品可以发布总比没有强，更何况这产品运转良好。

在图1-6描绘的过程中，已完成且测试过的功能不断地累积。在每个时间点上，都只有少量的功能尚未完成或集成。

很多项目会不断地推迟交付时间，最后整个项目都会被取消，一行代码都未能交付。通过迭代式的小的增量式开发，你完全不用担心这个问题，因为从第一个迭代起软件就是可交付的。同样，很多的项目在交付时质量不过关，因为直到最后一刻开发人员还在赶工。

TDD可以解决这个问题。在TDD过程中，我们会小步地前进，每一小步都会产生一个工作良好的产品，每一步都会距离目标更近一些。这些步骤非常的小（以分钟计算，而非小时或天），我们完全不用担心刚写出来的一大堆代码拼在一起不能工作。我们从不让软件偏离可用状态太远，因此软件一直都能正常工作。同样，我们会着眼于现在而不是预测将来，这样就能保持软件精简实用了。

图1-6　使用增量式开发，即以小的增量方式构建整个系统，我们的代码永远不会距离已集成的、可工作的状态太远。这样可以降低风险，因为未完成的工作一直都很少。我们以后将会学到，使用增量式开发，用户总能够看到真实的、可工作的软件，客户也能不断提出反馈，因此团队的工作也会更加高效

传统过程强调“事先设计所有东西，考虑所有的风险，这样架构才可以经得住考验，将来才能支撑起所有系统特性”。若采用这种方式，那我们是无法做到增量式构建软件，特别是依照成本和业务价值来增量式地构建软件的。传统的方式只能用于极其简单，或者每个细节都已经被透彻理解了的项目。而其他类型的项目则需要用迭代的办法一步步增量式设计。

如图1-7所示，在迭代、增量式的开发过程中，我们需要不断地在“实现新功能”和为支持新功能而“调整设计乃至架构”两项任务间来回切换。

图1-7　增量式演化设计是指在系统不断添加更多的功能和行为的过程中，不断地微调代码结构。在代码生命周期的任何时刻，代码所展现的都是开发人员为完成现有功能所做的最好的设计。用这种方法，我们可以演化出能经受实践检验的架构

这就是增量式演化设计。我们只为完成当前功能而设计，而不会试图事先做完所有设计。按照开发过程中获得的信息调整当前设计，而不是在项目一开始的所谓设计阶段就企图预见到所有应用场景，然后基于这些或实或虚的假设做设计。

事先设计的量要依情况而定，需要考虑当前团队、个体，以及所采用的技术等因素。我们要时刻注意保持正确的方向，所做的设计大部分都不对？那就少做点事先设计。发现设计不容易扩展？那就多做些事先设计。

我们在文中常常提到“小步骤”这个词，下面来解释一下其优点吧。

1. 小到能够装进我们的脑袋

有两个原因促使我们把大的任务分解成许多小任务。首先，我们要解决的问题通常都比较大而且模糊，其复杂性也不易控制，因此我们需要把它分解成更容易解决的小问题。不知道你是否和我一样，至少我的脑袋不太擅长对付这种大的怪物。图1-8演示了如何把复杂的问题拆分成更简单的小问题，然后一个个解决。

图1-8　要解决复杂的问题，先集中解决其中一小部分效果会更好

大部分人的工作记忆都只能同时处理5到7个概念。如果一次处理的信息太多，大脑就必须在工作记忆和长期记忆之间来回切换，因此难免遗漏部分信息^1。若能把整个解决问题的过程分解成一个个小的阶段性目标，整体进度也就有了具体的衡量标准。在开发过程中，由于整个过程划分成了一系列的测试，利用这些测试，我们完全可以度量出当前的开发进度。

1 工作记忆就像内存，而长期记忆类似于外部存储，例如硬盘。内存速度比硬盘快几个数量级，因此若计算所用数据都在内存里，那么运算速度也会快很多。有兴趣的读者可以参阅科学出版社引进的《人脑功能》。——译者注

在TDD过程中我们会演进式地设计系统，所以正好是小步地前进。我们会持续地改良系统设计，在开发过程中逐渐地演化出最适合当前需求的架构。

现在我们来仔细研究演进式设计为何行之有效，为何能让我们的代码“活”起来，以及对我们的工作带来的影响。

2. 演进式设计

很多程序员可能都遇见过这种事：某块代码亟待修改，却没有人愿意接手。为什么会这样？这段代码正巧是两个组件间的接口，修改工作太过困难。而在演进式设计中，我们常常会做这种修改。代码应当是“活的”并且是“可生长”的，决不能无视强烈的变化需求而保持一成不变。正因为如此，演进式设计可以提高设计质量，进而提高整个系统的质量。

那么，究竟该如何做演进式设计呢？演进式设计也是小步前进的。每种敏捷软件过程中推荐的事先设计的量都不同，不过这些过程都有一个共同点，即要求架构恰好能满足需要即可。例如你在开发某个软件，到了中期时遇到一个需求，要用到邮件服务器，直到这时候你才会往架构中加入邮件服务的构件，才会安装邮件服务器，等等。通常这种类型的架构改动很容易，但是并不是所有修改都是这样。

软件系统会有些非功能性需求，例如性能等，修改现有架构支持这类需求通常很不容易。例如把一个单机版的桌面应用程序改成能通过网络与多用户服务器通信的桌面客户端，工作量可不会小。同样，让一个批处理应用程序支持实时自动更新也不是件轻松的任务。

不过这类需求不会让开发人员多惊讶。虽然我们通常难以预测需求变化，但是根据现有需求预见将来的需求变化并不是不可能的。我们将这类变化称为可预见的变化，如图1-9所示。不过，可预见的变化并不一定会发生，有时甚至永远不会发生，还有的会因为某种原因而转化为不可预见的变化。

图1-9　系统的演进式设计同时受“可预见”和“不可预见”两种变化的影响。不过值得注意的是，相当一部分“可预见”的变化都不会发生，或者以某种不曾预料到的方式出现，这时它们就变成了“不可预见”的变化。我们需要运用常识和敏锐的判断力，为可能发生的变化作准备。大部分这种变化或许永远不会发生

做演进设计时我们一样要使用常识，不过要明白事情是会发生变化的，同时要注意现有任务的优先级。

例如我们知道系统目前会通过网络从公司的CRM系统中更新信息，不过在几个月后更新过程会变为实时的，而且会用HTTP协议调用Web服务。在这种情况下，我们该如何应对实时数据集成带来的新需求呢？

我们应当把数据处理逻辑和数据接收逻辑分开吗？当然！那我们应当现在就开始做一个基于批处理的系统，等Web服务上线后直接就能投入使用吗？或许应该，或许不应该。

总之我们需要在“避免做无用功”和“先挑省事的方法做，等出了问题再说”之间做出权衡。实践一次又一次地证明，比起纸上谈兵的事先设计，演进式设计方法更加经济有效。

3. 遵守纪律

再强调一遍，每个项目要做的事先设计的量都不一样（原本就不该一刀切），但是在演进式设计中，我们总会为添加新需求而修改大量现有的代码。这种改动相当频繁，所以代码质量一定要高。为此我们需要不少严格的规章纪律，确保开发人员不会降低代码质量^2。幸好，演进式设计及其辅助实践可以帮我们解决这个问题。它能帮我们把缺陷数量降低到接近零的水平^3，而不会让整个系统构建在大堆隐藏的缺陷的基础上。

2 http://www.objectmentor.com/resources/articles/Principles_and_Patterns.pdf。

3 我并不打算证明零缺陷是可能的，你可以随意发表你的意见。

这些辅助性的实践究竟是什么？简言之，这些实践都是为了保证软件的质量在任何时候都足够好，而重构是其中很重要的一环。我们前面提到过重构是TDD周期，即测试—编码—重构的最后一步。下面我们会详细讲解重构的重要之处。

1.3.3　重构以保持代码的健康

在小步前进的过程中，我们会不断扩展当前设计以支持新功能，也会不断抛弃旧概念，引进新概念。在这种情况下，软件的设计必然会变得很不一致，几经失衡，不易理解，也难以扩展。这些肯定会给我们交付软件的能力带来负面影响。不过不用担心，通过做重构，我们既可以演进式地设计，又能保持系统设计不出问题。

来看看Martin Fowler在他的著作《重构：改善既有代码的设计》4中对重构的定义：“重构是一种训练有素、有条不紊的代码清理方式，它必须在不改变代码外在行为的情况下，改进程序的内部结构。”这句话虽然很短，但却传达了很多的信息，下面我们来详细解释这句话的含义。

4 本书已由人民邮电出版社出版。——编者注

1. 重构是种纪律严明的方法

当重构（动词）时，我们并不仅仅是修改代码而已，而是通过一种严格的方式改善现有设计。这种方式叫作重构（名词），它能小步地改变代码结构同时不改变代码的行为。换言之，重构是指用某种严格的方式重构（动词）代码。重构或许会显著地改变当前设计，不过这种改变总是小步进行的，而且每一步都会验证代码的行为没有改变。

我们可不仅仅是在修改代码。我们要首先确定设计中的问题，然后选择对应的重构方法小心彻底地重构这段代码。我们会静候问题浮出水面，然后再解决问题。我们并不会预测设计可能出现的问题，也不会为这种问题作准备——因为这不会使问题减少，反而会让事情变得更糟。

2. 重构是种转换过程

重构是两种状态间的转换。在初始状态中，代码的设计存在一定问题，而在目标状态中这些问题都已经被修复了。图1-10中的重构手法为“以委托取代继承”（请参考Martin Fowler的《重构：改善既有代码的设计》一书）。这种重构手法的目的在于把继承转化成委托。

若子类只想重用父类一小部分功能，但却继承了父类大部分我们并不需要的数据和功能，这时候就可以应用重构这种手法。

有些重构方法非常成熟，对很多现代开发工具都有很好的支持。把重构过程自动化使得用重构做演进式设计更容易了，无论项目的规模有多大或复杂性有多高。（很难想象仅仅为了给一个方法重命名而去搜索几十个源代码文件，用查找替换功能一处一处改名，然后再把改动提交到版本控制系统中去。）

图1-10　重构是指代码在功能完全相同的两个状态或结构之间的转换。在图中，我们用委托替代了继承。转换后代码功能不变，但却更符合我们当前的设计需要。这些转换不是绝对的改进，而仅仅是用户两种设计方案互相转换的“纪律严明”的方法而已。很多重构手法都有其反向重构手法，重构的方向正好相反

重构到模式

有时候重构的起始状态或结束状态可能会对应某个设计模式^5。所谓设计模式就是对特定问题的通用解决办法。虽然我们日常的重构动作都很小，并没有像使用设计模式那样会改动大量的代码，不过重构过程中也需要发掘代码中可能的设计模式，然后显式地重构出模式。要深入了解重构和模式之间的关系，请参阅Joshua Kerievsky所著的《重构与模式》6。

5 Partha Kuchana编著的Software Architecture Design Patterns in Java（Auerbach, 2004）是介绍Java语言实现的经典设计模式的好书。

6 人民邮电出版社出版。——编者注

3. 重构会改变内部结构

重构用于改变系统内部结构——代码，因此大部分重构手法都会涉及实现细节，例如应用最为频繁的重构手法“重命名”。给方法或者局部变量重命名对系统的设计貌似不会有太大影响，但是若把某个含义模糊的名字改得能清晰地表达出本意，那么对阅读代码的人就会轻松多了。

这些小的重构是更大的重构的基础。在“大重构”中，我们通常会改变代码的职责，引入或移除继承结构，或者做一些会涉及几个类的类似操作等。最终大重构都可以分解为一系列小步骤，例如给变量重命名，添加一个新的类，改变方法返回值，等等。

虽然从技术角度来看“重命名”等重构方法使用率最高，但重构最主要的却是为了消除重复。重复是指两个方法或类中有相似的代码。我们可以把重复的代码提取到公共方法中，然后再调用这个方法来消除重复。若代码中存在重复，表明代码职责划分得不够清楚。重复对系统当然是有害的，因为它使系统变动更麻烦。让逻辑和职责在不同地方重复一定会引入缺陷，因为我们很容易修改一处而忘记其他的。

重构时不仅不能引入缺陷，也不能添加新功能。重构应当保持系统原有行为。

4. 重构保持原有行为

Martin Fowler提到重构应该“不改变代码的外部行为”，这是什么意思？这是指无论怎么修改代码，所变化的都只有代码的设计和内部结构，外部可见的行为和功能会维持原样。换言之，代码的用户应当对重构毫无察觉。

改变一个类的公共方法的名字，肯定会影响到调用这个方法的代码，但是这个方法的行为不应该发生改变。同样，调整公共接口背后的代码也不会改变接口提供的功能。

注意　现在我们已经知道了什么是重构，不过，知道什么不是重构也很重要。这些年我常听到“重构”这个词，不过在大部分情况下，人们只是想表达“重写”或“编辑”而已。

重构时只能改变系统内部结构而不能改变系统外部行为，对吗？那我们怎么知道真的没有改变系统的外部行为呢？当然是靠测试了！测试可以确保软件正常运行。

1.3.4　保证软件正常运行

保证从项目第一天起就能交付软件，说起来容易做起来难。既要不断重构代码，又要保证重构工作不破坏已有的功能，可不是件容易的事。在无人督促时，我们有可能会偷懒。虽然TDD中测试的主要目的是帮助我们设计和开发软件，不过这种方法也能督促我们坚持正确的做事方式。

测试并不是负担，不过许多人都不这么认为^7。手工测试的确很慢，也容易出错，因此开发人员（甚至专业测试人员）都不喜欢做测试，总会跳过某些手工测试，猜想待测功能不会有问题。不过手工测试的缺点是很容易克服的，把手工劳动转化为自动化形式就是我们软件开发人员的本职工作，因此把手工测试变成自动化测试完全难不倒我们！

7 实际上测试先行地开发时，写测试是种乐趣！

1. 用自动化测试做保护

回归是指返回到更初级的状态。在软件开发领域，回归表明已有的、曾经可用的功能不再能正常运转，即从正常工作的状态退化到不能工作的状态，如图1-11所示。

图1-11　测试套件在代码周围形成了一个模子。若改动破坏了功能，模子就不再符合了。若模子破损了，我们也就知道不好的事情发生了

回归不会平白无故地发生。实际上，是我们开发人员自己修改代码时引入了缺陷才会出现回归。作为专业软件开发人员，我们当然希望能迅速知道当前改动是否会破坏现有功能，因此必须依靠测试套件。我们可以随时执行所有测试，若测试失败了，就表明某项功能出现了问题。

这种测试称为回归测试，在整个开发过程中，回归测试会被反复执行许多遍，以保证以前发现的缺陷不会在系统发生上千次修改及变化后再次出现。换言之，这种测试是为了保证自上次运行测试以来，软件没有发生回归。

测试套件有许多重要特征，首先其必须能容易运行——否则人们就会试图绕过测试，冒着破坏现有功能的风险提交代码。另外还要能快速地执行——否则人们就不会常常执行测试，因此又会跳过很重要的功能验证步骤。

若不频繁地执行测试，那么将来我们可能花费很多宝贵的时间修复缺陷，因为那时完全不知道缺陷是在哪一步被引入的。及时反馈所带来的正确上下文，可以帮我们明显提高开发速度，因为不需要花时间找出引入缺陷时的上下文。如果我们在每次微小变动后都运行自动化测试，那么我们可以精确地知道出错的是哪几行代码。

2. 快速获得反馈

有时我们没法很快地执行完所有测试。那些访问数据库、共享磁盘、Internet上的Web服务器或者目录服务的测试会让测试速度变得很慢，甚至连访问本地文件系统都会使整个测试多运行数分钟时间。而且有时候测试次数太多，就算优化很多次依旧会占用开发人员大量的时间。

在这种情况下，可以选择运行一部分测试 (通常是最可能发现当前改动所引发缺陷的那部分测试)，提交代码，继续下一项工作，让构建服务器（build server）在后台运行所有测试。构建服务器有时称为持续集成服务器^8，因为它经常和“持续集成”一起使用。所谓持续集成是指开发人员频繁地集成修改过的代码，使得集成几乎是“持续”的。

8 Martin Fowler的文章http://www.martinfowler.com/articles/continuousIntegration.html详细地介绍了持续集成。

拥有持续集成服务器并不等于做持续集成

不应该把拥有持续集成服务器与进行持续集成等同起来。持续集成服务器可以使构建过程自动化，还能产生精美的报表。但如果开发人员很少提交代码，那么持续集成服务器就会形同虚设，开发人员的代码也会变得不同步，集成时当然会碰到更多的代码冲突。

这种方法基本上是用“上次修改代码没有破坏任何功能”的信心来交换开发速度的，而这种交换则建立在开发人员挑选的部分测试没有覆盖的功能不会被该修改破坏的假设之上。

我们可以把这种方式想象为快速获得反馈的“乐观锁”。如果测试没有失败，我们可以只关注于开发，完全不用花时间去运行那些不会失败的测试。如果让构建服务器运行的测试失败了，那我们需要用更多的时间找出问题所在，因为手中的任务已经换了，问题发生时的上下文也改变了。我们需要做出取舍，因为若没有问题发生的上下文，修复缺陷的时间和精力会大大地增加。

回归测试必须要能够重复执行。也就是说我们要么雇人用大量时间重复执行成千上万的回归测试，要么自动化所有的测试，让计算机来自动地执行回归测试。

我们现在已经明白了TDD是什么，如何运作，也明白了采用这种技术的原因。我们将会在第一及第二部分的剩下章节中详细讲解这种技术，不过在这之前，我们会先了解验收测试驱动开发的基本功能，以及如何能帮我们开发出满足客户需求的软件。

1.4　做正确的事：ATDD

测试一直都是软件开发过程中重要的一环，测试的具体方法这些年发生了很大的变化。在把软件交付给客户之前，我们开发人员都会想尽办法确保交付的软件没有问题。随着敏捷软件开发（如XP）过程的逐渐流行，我们检测软件质量的方法也发生了不少变化。

测试曾经只是为了验证软件功能和需求文档所描述的一致，不过现在测试的功能已经不局限于此了。实际上，利用测试从功能的角度驱动软件开发过程，正是我们解决软件不能满足客户真正需求问题的方法。这就是验收测试驱动开发（ATDD）的内容。简言之，我们会先写一个测试，然后再实现测试所描述的功能。

因此，测试不仅仅是一种验证工具，还是需求文档必不可少的一部分，同时也是与客户协作的媒介。在本节中，我们会详细讨论测试的这些新用途，先从促进开发人员、测试人员以及客户之间的交互开始谈起，然后讨论如何把测试当作能促进协作的“共同语言”。

在讨论ATDD促进协作等特征之前，我们先来理清ATDD和TDD之间的关系吧。这两者名字很相似，必定存在某种联系。

1.4.1　名字的含义

从ATDD的名字“验收测试驱动开发”中可以看出，这种技术与“测试驱动开发”有一定关系。“验收测试驱动开发”中的“测试驱动开发”部分显然源自TDD，那么剩下的部分究竟有何含义？验收测试是什么？简单地说，验收测试是用来检测某项功能的完成情况的。如果某个功能的所有验收测试都通过了，那么这个功能也就完成了。

无论用何种格式表述需求，都可以采用ATDD。我们可以把需求记录在用户用例、用户故事或者其他文档上，都没关系。有时采用用户故事管理需求的团队倾向将这种方法称为“故事测试驱动开发”，实际上还是指同一种技术。还有人喜欢称其为“客户测试驱动开发”，这样也可以，因为验收测试的所有权本来就是客户的。

注意　尽管ATDD很像TDD，也借鉴了TDD的许多优点，但这两种开发方法可以分开使用。那些不使用用户故事的开发人员，或者实现功能前不先写测试的开发人员，仍然可以使用TDD。那些不用测试先行的方式写代码的团队，仍然可以用ATDD测试先行地实现新功能。这些技术互相弥补互相支撑，结合使用时更能发挥其作用。

无论我们用什么格式或工具来管理需求，ATDD的主要目的都是促进客户和开发团队之间的紧密协作。下面我们来讨论测试驱动方法为何会有这种作用。

1.4.2　紧密协作

在任何由人参与的复杂活动中，紧密协作都至关重要，采用ATDD来开发软件的活动也是如此。我们期望团队能够像一个整体，而不是开发团队、业务分析团队和测试团队截然分开，更别说设立一个单独的质量保证部门了。

要想使整个团队的生产力达到最高，团队成员之间必须能有效沟通，能面对面地讨论如何构建高质量的软件。若客户、测试人员、业务分析人员和开发人员之间用测试计划、需求文档和测试报告来交流和沟通，那可不行。使用ATDD，我们可以把知识、技能和能力汇集到一起，有效地协同工作。

下面我们来看看为什么这种紧密的协作能提高效率，消除对需求的误解，减少返工所带来的开销，进而帮助我们开发出真正满足客户需要的软件。

1. 看得见摸得着的软件

有些团队会闷头数月开发软件，完全不与客户沟通，这样开发出来的软件很少能让客户真正满意。若能不断地向客户展示已完成的功能，当软件中有些功能不正确或者与客户需求不符时，我们会立即得到反馈。若能尽早得到反馈，那么我们就能减少项目风险，降低成本了。此外，若能把所有完成的功能都展示给客户看，那么我们会清楚地了解当前进度，而不会像基于文档开发方式那样常常乐观地误认为任务“已经完成90%了”。

2. 建立信任及信心

尽早以及频繁地交付软件还可以在团队和客户之间，以及团队内部建立起信任感。通过向客户（以及自己）展示已完成的功能，整个团队工作起来会更轻松。

3. 客户做主

增量式软件开发过程中的客户权利，与传统的瀑布式开发过程中的客户权利有很大差别。在增量式开发过程中，客户有权决定哪些功能要先开发。同样，若团队不能在既定的预算和时限内完工，客户也可以取消某些功能。

功能的开发成本当然会影响到客户的决策。开发成本由开发人员估算，包括推迟开发的成本，以及改变开发顺序后的开销，等等。

若用户可以控制自己的钱花在哪些功能上，那么他们对待项目的态度也会发生变化。最终由客户决定他们的钱花在哪些功能上，绝对可以激发起客户的热情。

4. 培养共同语言

通过鼓励测试人员、开发人员以及客户之间的沟通协作，我们可以营造出一个氛围，在这个氛围中，有价值的信息可以在团队中迅速得以分享。此外，随着团队成员间不断地沟通，相互之间也会更了解，同时也会慢慢培养出共同的语言，这样沟通效率会变得更高。软件开发是人参与的活动，无论如何都不能忘记这一点。

我们好好想想，把验收测试作为整个团队（包括客户、测试人员及开发人员等）沟通时使用的共同语言的基础，是否可行呢？

1.4.3　把测试作为沟通的共同语言

需求不清是软件开发中最大的问题之一。要清晰地表达出需求，保证需求在记述过程中还能保持原样，绝不是件容易的事情。有些人甚至认为这根本不可能，毕竟我们不会读心术^1。

1 如果你可以，请马上联系我们，公司还有一个高级客户读心术专家的职位空缺。

当用文档（例如需求文档）作为沟通的媒介时问题尤为突出。文档绝对不是传递信息和交流的好方法。若我们能把客户需求转化成可执行测试，通过测试来验证系统是否满足客户的需求，那么问题就会少很多。这就是“以测试为规约”的好处。

1. 以测试为规约

若把测试当作需求的严格表述形式，那么至少在理论上能通过所有测试的系统一定能够满足客户的需求，当然测试一定要充分覆盖系统的每个部分才行。不过在现实的软件开发项目中，要做到这一点并不容易。

测试不会发现所有的缺陷，有一定商业软件项目经验的人绝不会因此感到困惑。部分原因是我们漏掉了一些应该想到的测试，还有一部分原因是人类的本性和常常欺骗我们的直觉促使我们跳过一些测试以节约时间。

如果测试无论如何都不会彻底，那么把测试当作规约还有意义吗？测试真的可以描述需求，定义概念吗？当然把测试当作规约，不会解决所有的问题，但其确实也有一些显著的优点：

- 可以自动化执行，更快地提供反馈；

- 能更可靠地执行测试；

- 少一个翻译的步骤。

首先，许多费时费力的测试工作都可以让自动化的可执行测试来做，比起手工测试，自动化测试还能显著缩短反馈周期。其次，计算机不会感到疲倦，也不会偷懒，让它帮忙做测试能够避免我们人类与生俱来的人性弱点。再次，反正把需求记录在文档上注定会有问题，现在只不过把记录需求换成记录测试用例而已，事情还能糟糕到哪去呢？

虽然把需求转化成测试的过程中仍然可能出现问题，但是在执行测试时知识转化量越少，需要人解读的地方越少，出错的机会就越少。

2. 以例子为规约

此外，以测试为规约的最显著的好处在于可以采用“以例子为规约”方式来描述需求，而不是用抽象的描述（当然了，在表述需求时，总是要有部分描述性文字的）。换言之，以例子为规约的需求表述方式提倡用例子表达需求，例如“若订阅价格为$20，税率为10%，那么系统应当从用户账户中共收取$22”，而不是用传统的需求文档中常见的“系统应当计算税”这种表述形式。

对于简单的需求，基于例子的方法并不比传统的“系统应当这样……”的方法好多少，毕竟我们都知道简单的计算税金的方法。然而并不是所有功能都这么简单，对于复杂的业务逻辑，误解的可能性会高得多。例如某项特定的交易需要交多种税，而且不同的交易地点有不同的税金计算规则，这时具体的例子会很有用。

以例子为规约的方式很符合我们的直觉，而且还很容易把需求联系到真实世界以及我们要开发的软件上。在TDD中也能使用以例子为规约的方式。只不过以例子为规约的验收测试表述的是系统的功能，而以例子为规约的单元测试表述的是代码实现而已。

软件内部质量和外部质量都很高，我们对工作也更有信心，客户也因为软件完全满足了需要而非常乐于和我们合作——要是能做到这些那就最好了。毕竟，在理论上，一切皆有可能。下面我们就会停止理论层面的分析，开始讲解真正的实践技巧。不过在那之前，我们先来讨论有哪些工具可以使用。

1.5　TDD工具

工具十分重要。想象一下若没有编译器、编辑器以及操作系统，软件开发将会多么困难。经过数十年的技术进步，软件开发工作已经大大简化了。我们在使用TDD开发程序时也一样，好的工具能使情况大为改观。下面我们将会简要介绍三种主要的工具和技术：单元测试框架、持续集成及其实现工具以及代码覆盖率。

1.5.1　使用xUnit做单元测试

Kent Beck在很多年以前用SmallTalk语言编写了一个叫做SUnit（http://sunit.sf.net）的单元测试框架。这个框架在软件开发社区内掀起了一阵狂潮，随后几乎所有语言都有了自己的移植版本^1。对Java开发者来说，JUnit是标准的单元测试工具，它也是由SUnit移植而来（可以在http://www.junit.org/下载）。所有这些参照SUnit或JUnit而设计的单元测试框架都相似，因此被统称为xUnit。只要掌握其中一种，其他的也就不难学习了（只要你熟悉对应的编程语言就行）。

1 SUnit和JUnit在ColdFusion、C#、C、C++、Delphi、Action Script、Haskell、JavaScript、Visual Basic、JScript、Lingo、LISP、Lua、Objective-C、PL/SQL、Perl、PHP、PowerBuilder、Python、Ruby、Scheme、Tcl/Tk及Korn Shell等语言中都有移植版本。 Ron Jeffries维护了一个xUnit框架的列表：http://www.xprogramming.com/software.htm。

xUnit中所定义的单元测试框架到底是何概念？单元测试框架是指能够辅助单元测试编写、测试以及报告的框架。例如JUnit提供了许多基类供开发人员扩展，还提供了一些比较执行结果的类和接口，等等。框架还会提供不同的测试执行器（test runner）来运行单元测试。这个类会收集所有测试类，执行之，然后收集测试结果，最后用文本或者图形的形式将测试结果展现给开发人员。

本书中我们将会使用JUnit及Java，实际上还会用到许多JUnit的扩展，以测试驱动不同的组件。如果你还不熟悉JUnit，参阅附录中有关这个工具的简介。不过现在不要花太多时间研究JUnit，我们还会介绍许多工具，例如支持ATDD的工具等。

1.5.2　支持ATDD的测试框架

大部分的单元测试框架都基于xUnit的概念，但在验收测试世界中，框架的类型就相对较多了。产生这种差异主要是因为功能层面的验收测试概念相对较新，在没有什么可记录的情况下，记录—执行模式的自动测试工具就不能正常工作。

此外，系统用户界面的共性比较少也是造成验收测试框架种类繁多的一个原因。不过Web应用除外，因为我们可以通过标准的HTTP协议访问几乎所有的Web应用程序。除了技术因素之外，是否能很好地支持客户协作也是考察这类工具的一个重要因素。

有些验收测试工具是表格形式的（例如Fit及Fitness），这样开发人员、测试人员以及业务分析师就能很好的与非技术背景的客户协作编写测试了。除了支持表格形式测试的Fit家族，还有能用纯文本编写测试的框架，Exactor是这类工具的代表。采用更技术化的方式编写测试有时也可以，例如脚本语言。而有时候，即使有许多框架可以选择，自己编写一个测试框架也未尝不可。

我们将在第三部分讨论验收测试时详细介绍这些工具。现在我们先继续讨论下一个工具——持续集成服务器。

1.5.3　持续集成及构建

若团队成员会不断改动项目各个部分的代码，那么开发人员集成完成的工作时也会遇到更多问题，这种问题在传统开发方式中并不常见。在采用TDD开发方式的团队中，代码都是集体共有的（Collective Code Ownership）2，每个人都可以修改系统任何部分的代码。

2 如果开发人员需要征得代码所有者同意后才能做重构，那么TDD还能用么？我想不太可能。当然事情不是非黑即白的，我听说已经有团队成功使用了各种混合代码所有权制度。

因此只要团队坚持重构，那么就不断会有小的变化持续地流入代码库。在这种情况下，如果等两天才提交所有的改动，则难免要合并代码以解决冲突，这可不是件有趣的事情。

基于以上原因，我们有必要更频繁地与代码库保持同步。在开发人员本地代码与代码库之间的同步，即持续集成的过程中，我们不仅要保证代码能够集成（能编译），还要运行所有测试，以保证集成后的代码正常运转。不过在实际的项目中我们常需要做些取舍，下面将会讨论。

1. 基于实际情况做取舍

进行持续集成实践的团队在提交代码前常常只运行与所作修改相关的部分测试，让持续集成服务器在后台运行所有的测试（包括单元测试、集成测试和功能测试）。在图1-12中，持续集成服务器在监测代码库的变动。

实际上，这是很多团队需要面对的很现实的取舍。运行所有的测试会耗费大量时间(几分钟，甚至几个小时)，而开发人员所做的改动只会影响到一小部分的测试，也就是说余下的大部分测试不会给开发人员带来有价值的信息。所以在提交代码前只运行相关的一部分测试（持续数秒）既合情又合理。

我们所做的修改偶尔会破坏掉本机运行的测试没有覆盖到的功能，不过持续集成服务器通常在5～15分钟后就能够发现最新的代码中存在的问题。

你现在是不是已经在想如何实现这种持续集成服务器了呢？

图1-12　描述了典型的场景。开发人员在本地运行一部分自动化测试(1)，然后把本地代码提交到代码版本控制系统中去(2)。新提交的代码改动会使持续集成服务器开始一轮新的构建(3)。在新代码构建完成后，持续集成服务器会发送电子邮件把构建报告发送给开发人员(4)

2. 有多种持续构建服务器可以挑选

要使用持续集成，我们不用自己实现持续集成服务器，有许多开源及商业的实现可以选择。其中较流行的有CruiseControl（http://cruisecontrol.sf.net）、AntHill（http://www.urbancode.com）、Continuum（http://maven.apache.org/continuum）以及Bamboo（http://www.atlassian.com/software/bamboo/），新的实现也层出不穷。

如果有兴趣了解持续集成背后的原理及相关工具，请参阅Martin Fowler的文章《持续集成》3。James Shore也写了不少关于持续集成及持续集成工具的好文章^4。

3 http://www.martinfowler.com/articles/continuousIntegration.html

4 http://www.jamesshore.com

1.5.4　代码覆盖率

许多开发人员都可能会熟悉Lint（http://www.splint.org）等静态代码分析工具。不少Java开发人员都用过PMD（http://pmd.sf.net）来检测源代码中的问题或者分析源代码的复杂性。随着自动化测试，特别是自动化单元测试的不断兴起，衡量代码覆盖率（也叫测试覆盖率）的工具也不断涌现。简言之，代码覆盖率是用来衡量自动化测试对产品代码的语句、分支及表达式等的覆盖程度的指标^5。

5 若想了解更多的代码覆盖率的原理以及工具，请参照我的文章《代码覆盖率简介》（http://www.javaranch.com/newsletter/200401/IntroToCodeCoverage.html）。

若在构建中检测代码覆盖率，我们就能很清楚地知道测试是否充分了。在团队刚开始采用TDD或单元测试时，这种方式尤其有用，因为它能指出哪部分代码测试的不够。

就像其他源代码的衡量标准一样^6，代码覆盖率也有可能被人误用。一味追求高测试覆盖率并不是明智的行为。有时因为API等的缘故，我们要去测试那些绝不可能出现的情况才能把覆盖率从99%提高到100%，因此要恰当地使用此工具。

6 你听说过按照代码行数来发工资吗？以前确实有过，不过不是什么好方法。

合理的代码覆盖率

每当谈及代码覆盖率时，总有人会问“覆盖率的标准应当是多少？”应该是100%，90%，还是80%？

答案是，看情况。选择覆盖率的标准时，应该考虑所用的技术、语言以及开发工具等，还要考虑其他许多因素。Java及J2EE项目通常会选择85%作为标准。之所以是85%，并不是因为某些功能没有测到，而是因为语言和API的设计风格使得100%的覆盖率不太现实而已。

我曾经和一位资深咨询师及作家J. B. Rainsberger讨论过这个问题，他对此的看法是 “把100%的覆盖率当作目标，不过你会最终发现85%更加合理”。

我们并不打算详细讨论代码覆盖率工具，有兴趣的读者可以去研究一下Cenqua Clover (http://www.cenqua.com/clover)、Cobertura (http://cobertura.sf.net)或EMMA (http://emma.sf.net)等，这些都是好的代码覆盖率工具。

工具就介绍到这里吧。在后面的章节中我们会学习各种测试的方法，那时会接触到更多有用的工具。不过现在只要了解单元测试框架如JUnit的基本原理，以及其与TDD的关系，就已经足够了。

下面我们来做个小结，然后就该实战了！

1.6　小结

在本章开始部分，我们介绍了在给客户交付支持组织经营运作的软件过程中遇到的问题和挑战。传统的软件开发方法在这些方面尚有相当大的改进余地：制造出的软件缺陷率高，不容易修改，修改成本高，且不能满足客户的真正需要。

TDD是一种设计和开发方法，它能帮我们从项目开始就构建出可运行的软件，以增量的方式添加新功能，使软件在整个开发过程中都能工作良好。通过演进式设计，并且应用重构优化每一步的设计，我们可以防止代码质量随着时间推移而下降。TDD能帮我们正确地做事情。

用这种纪律严明的方法小步前进，我们可以降低缺陷率并交付高质量、易维护，并且容易改动的软件。这样，要写的代码更少，修复缺陷所用的时间更短，有更充足的时间做有用的事情，我们会更加自信，项目进展也会越来越快。

为了开发出正确的产品，我们可以采用ATDD开发方式。ATDD不是一种测试技术，而是一个功能强大的开发工具，它能有效地促进客户、测试人员、开发人员之间的交流协作。通过增量式开发软件，用面向客户的测试作为讨论和反馈的基础，ATDD能使整个团队紧密地协作。依照客户指定的优先级，在整个项目过程中不断开发出可用的具体功能，我们能够赢得信任并增强自信，交流也因相互之间有了共同的语言而更加高效。

在第一部分的后几章中我们将会学习TDD开发的来龙去脉。这种技术在帮我们按时成功完成低缺陷率软件项目方面具有无限的潜力。本书第二部分会介绍如何在Java及J2EE技术下使用TDD。在这些技术下做TDD貌似是种挑战，而事实并非如此。

介绍TDD后，我们会在第三部分中详细讨论ATDD。我们会剖析完整的开发过程，了解如何通过使用神奇的用户故事和验收测试为客户交付更多的价值。

希望您旅途愉快！

第2章　TDD入门

经验是最好的老师，因为实践出真知。

——中国谚言

大的机场都会有许多指示牌。无论从哪个机场飞来，我都不会迷路。不知道该怎么走时，只要抬头向上看就能发现类似这样标记：“42号出口：直走。”

当我开始我的程序员生涯时，情况就不太一样了，就好像机场大楼的门口只写着一句话——“祝你好运”。找出应该修改的地方并正确地解决问题，就像在伦敦希思罗机场，没有任何指示牌或路标，让你找到正确的出口一样困难。对于软件开发，TDD及其简单的指导规则就像机场中的各种指示牌一样，能帮我及时到达目的地。

在第1章中，我们已经大致的了解什么是TDD，以及TDD的各个组成部分，例如增量式设计以及“颠倒”的开发周期等。如第1章中所学到的，TDD既是种设计技巧也是种编码技术。TDD的规则相当简单，即写代码只为通过测试。TDD颠倒了常见的“设计—编码—测试”过程，变为其所特有的“测试—编码—重构”过程，如图2-1所示（引自第1章）。

图2-1　TDD周期包括三个步骤：写测试、添加恰巧能让测试通过的产品代码，最后进行彻底的重构以改善设计

TDD简单高效，能开发出高质量软件的技术，并且由于彻底的重构，软件的扩展性总是很好。由于对自己的代码更有信心，代码缺陷率更低，再加上有优良的设计、完备的测试，比起系统上线前没日没夜的加班同时还要应付气愤的客户，我们的工作效率自然更高，心情也更愉快。

在本章中，我们将会深入地了解TDD，讨论其能提高工作效率的原因。我们将会用测试先行的编码方式，通过实践学习TDD。我们将会写许多小的测试，一步步缔造出系统的各种功能行为，同时还会毫无保留地重构。我们会添加代码，删除代码，修改代码以将无用的部分删掉。我们将会编写一个模板引擎，其健壮性和功能随着开发过程会不断提升。这模板引擎起先会有一些基本功能以及错误处理功能，随后会不断扩展。我们也会讨论完成系统的两种途径：“深度优先”和“广度优先”。

本章内容很多，代码也不少，若能认真地阅读，你将会对TDD有更深刻的理解，同时也会学到许多提高开发效率的技巧。

我们马上要用TDD开发真正的软件了。我们将会实现一个模板引擎，用于渲染含有变量的模板，模板中变量的值可以在运行时填入。用TDD开发时，第一步就是编写一个失败的测试，而要编写测试，我们必须要先弄清楚测试的目标，即要实现的功能。所以在着手开发之前，我们先来讨论如何从抽象的需求中提取出具体的测试吧。

2.1　从需求到测试

假设你在开发协作套件系统的一个子系统^1，这个子系统支持邮件模板功能，CEO助理只需要点击几下鼠标就能给员工发出个性化邮件了。我们该如何用TDD开发这个系统呢？在本章结束时你就会知道答案。开发任何系统前我们都首先需要分解需求，使其变得更小，更具体，本次也一样。

1 实际上是“邮件系统”的一个更炫的名字。

2.1.1　分解需求

你可能想把现有需求划分成“需要做的事”——也就是任务——完成了这些任务，也就满足了需求。这次不妨换种方式。我们可以把需求分解成一系列测试，通过了所有测试也就满足了所有的需求。你能写出来这些测试吗? 我看没问题。

“若模板中没有任何变量，那么就按照原样发出”，“在贺信中，表示收件人名的变量应该替换成每个收件人的名字”，等等。不久，你就会有很多这样的测试，合起来，就不仅仅是在测试系统的一小部分功能了。最终，你将会用测试覆盖到产品的绝大部分，甚至全部的行为和功能。

为了清楚地说明任务和测试之间的区别，表2-1把邮件子系统分别分解成任务和测试，并进行对比。把需求分解成任务后（左栏），我们很难从所产出软件的角度衡量进度。对比左边的任务栏和右边的测试栏，很明显右栏更能表明软件拥有的功能。

表2-1　邮件模板子系统需求的两种分解结果

把模板子系统分解成任务

把模板子系统分解成测试

写一个正则表达式以确定模板中的变量

没有任何变量的模板，渲染前后内容不变

实现一个使用正则表达式的模板解析器

含有一个变量的模板渲染后，变量应当替换为相应的值

实现一个模板引擎。引擎对外暴露一组API，内部实现使用模板解析器

含有多个变量的模板渲染后，变量应当替换成相应值

…

…

把需求转化为测试比转化成任务要好得多，因为像表2-1中的那些任务很容易让人忘记满足用户需求这一最终目标。任务只能提示我们下一步做什么，而没有清晰的“完成”的定义。我们所处的情况就有些尴尬了，就好像买了彩票就声称自己中了大奖一样。

后面我们会见到很多例子，那些例子能充分说明把需求分解成测试的好处。不过在那之前我们还有些其他问题要讨论。

2.1.2　什么是好的测试

虽然大体上测试比任务更能指导我们的工作，但不同的测试编写方法之间区别大吗？当然大。虽然没有一个通用的标准用来衡量“好的测试”，不过我们还是可以依照一些准则来判断测试的优劣的。编写（单元）测试时有许多准则可以遵循^2，不过从把需求分解到测试的角度来看，有两点尤为重要：

2 例如“测试之间应该相互隔离，并且不依赖于执行顺序”，“测试应该能快速的执行”，“测试中不应该有手动的初始化工作”，等等。

- 好测试是原子化的；

- 好测试是独立的。

也就是说，好的测试必须能集中地，原子化地验证待测行为的一小部分。此外测试之间也应该是相互隔离的，测试不该对之前测试的运行结果有任何假设。若能依照原子性准则来编写测试，我们就可以把注意力集中到当前的工作上了，这样就能保证稳健地走好每一步，从而构建出功能齐全，运转良好的完整系统。

只要我们把每一步都表示成毫无歧义的测试，那么每一步和最终目标的联系就会更加紧密清晰了，而且也不易有所遗漏。

在实现功能的每一步中，我们并不是随意写一个测试就开始开发。挑选测试是有一定的技巧的。下面我们一起来讨论。

2.1.3　依照测试的列表工作

有了一个初始的测试列表，我们就可以开始写代码，让测试一个个通过了。首先我们要挑一个测试——通常是我们认为最容易的那个，或者花最少功夫就能取得进展的那个。这时我们要暂时忘记其他测试，只关注选中的测试。下一步该写哪个测试？在第一部分的余下章节，我们会常常遇到这个问题。在TDD过程中，我们每隔几分钟就要做一次这种决定。

那么挑选完测试后该做什么呢？当然是编写测试代码了。在写产品代码之前，我们先要使测试代码通过编译且可执行。不能通过编译，所以没法写测试？不可能为不存在的代码写测试？不，不是这样的。

2.1.4　意图编程

在产品代码存在之前就为其写测试，这听起来有些问题：为了不破坏测试先行的规矩，为一段还不存在的代码写测试？是的，写测试时我们可以假想待测的代码已经存在了。

这怎么可能呢，该怎么想象呢？我们可以站在当前测试的角度来设想产品代码的完美形态。这不是自欺欺人吗？是啊，算是，不过这没有问题。我们在写测试时假设产品代码如我们想象的那般易用，写测试的工作也会变得非常轻松。而且因为产品代码必须要通过测试，所以会和我们想象的一模一样。

这就是想象的力量。这种编程方法还有个名字——意图编程。意图编程，顾名思义，就是说写代码时先假设其他部分代码都已经存在了（即使事实并非如此）。使用这种编程方法时，我们可以把注意力集中在能有的，而不是已经有的东西上。使用意图编程，我们能让代码读起来更流畅，容易理解和使用，也能使代码清晰地表达出自己的本意，而不会过于强调实现细节。

现在我们已经知道了要把需求分解成小的、紧凑的测试，而非分解成任务的原因。我们也了解了意图编程的好处及使用方法。是时间用测试先行的方法开发世界级的模板引擎了。

2.2　选择第一个测试

现在我们要用TDD开发模板引擎了。为了避免同时考虑太多问题，我们会把注意力集中在模板引擎的业务逻辑上，而不会考虑使用模板引擎发送个性化邮件等其他部分。

提示　我强烈建议你现在就打开最喜欢的IDE（集成开发环境），与我们一道开发模板引擎。虽然在这一章中我会尽量让你了解我们在做什么以及这么做的原因，但是自己动手实践的学习效果是最好的！

我们曾提到过，模板引擎应当能读入一个含有任意变量的静态文本模板，模板中有用特殊语法做标记的变量。在使用模板引擎渲染模板之前填入变量值，模板引擎会做相应的处理。

一切都清楚了吧？开始做吧。我们首先要把对模板引擎的相关描述转化成一系列的测试，然后再选择一个去实现。

2.2.1　创建测试列表

在写出测试列表前我们要先了解需求。本例中，邮件子系统需求如下：

- 系统可以使用运行时提供的变量值替换模板中的变量，如${firstname}和${lastname}；

- 若系统试图发送一个变量赋值尚未完全的模板，应该报错；

- 系统会忽略模板中不存在的变量值；

- 系统在模板中支持全拉丁字符集；

- 系统在变量值中支持全拉丁字符集；

- ……

这些是详细需求，对吧？它们是测试吗？不是。它们是比“实现一个邮件模板子系统”更小，更详细的需求，不过还不是测试。测试通常应该更明确地给定出各种情况下系统的行为，而不仅是给出泛泛地描述。

下面我们尝试着把邮件模板子系统的需求转化为测试。

- 对模板“你好，${name}”求值，其中变量name的值为“读者”，结果应当为“你好，读者”。

- 对模板“${greeting}, ${name}”求值，两个变量值分别为“你好”及“读者”，结果应当为“你好，读者”。

- 对模板“你好，${name}”求值，其中变量name没有相应的值，应当抛出MissingValueError错误。

- 对模板“你好，${name}”求值，变量doesnotexist的值为“你好”，name的值为“读者”，结果应当为“你好，读者”。

- 等等（例如，我们可以加入一些例子验证系统可以正常处理拉丁字符集）。

看出区别了吧？需求变成了一些更具体、更加可执行、更像例子的东西。有了这些测试作为评判工作完成的标准，我们根本用不着有任何疑问。有了这些测试，我们也不必琢磨“报错”是什么意思。若“报错”抛出一个异常，我们也不用想抛出的异常应当是什么类型，或者该提示什么错误信息等。测试表明我们应当抛出MissingValueError异常，并且错误信息无关紧要。

有了这种测试，我们终于能够放心地回答“任务已经完成了吗？”这种问题了。不过，测试列表本身永远不会结束。测试列表是一个“活”文档，每当我们前进一步，都会加入新的测试。在初始的测试列表中，我们势必会漏掉些测试，而在深入其他测试时，可能会想起这些被遗漏的测试。那时，我们会写下所想到的测试，继续手中的工作。

我们已经有了能明确表明需求的测试列表，接下来我们将使列表中的测试挨个通过。

2.2.2　写第一个失败的测试

在继续TDD旅程前我们先找个例子练习意图编程吧。拿测试列表中的第一个测试为例来学习编码前先测试的方法，如何？

列表中第一个测试为：

对模板“你好，${name}”求值，其中变量name的值为“读者”，结果应当为“你好，读者”。

注意　我们现在要打开IDE写些代码了。在例子中，我们将会使用JUnit 4，Java 语言的最新标准测试框架。如果你还不熟悉JUnit，请先参照附录中的简介。JUnit本身不是重点，主要目的是实践意图编程，所以只需注意方法中的代码，不用去管那些奇怪的注释语法（annotation）。

因为我们要从测试开始，所以我们先给测试类起个名字，见代码清单2-1。

代码清单2-1　为测试搭架

public class TestTemplate {

}

接下来加入测试oneVariable，如代码清单2-2所示。

代码清单2-2　添加测试方法

import org.junit.Test;

public class TestTemplate {

@Test

public void oneVariable() throws Exception {

}

}

进展不错。你能听到风声吗？没听到也没关系，因为我们本来就该小步前进。接下来我们要意图编程了，这部分将会很有趣。发挥想象力，用自己认为最合理的方式设计模板引擎的工作方式，暂时不用考虑具体实现。如此编写出的测试如代码清单2-3所示。

代码清单2-3　正式开始写测试

import org.junit.Test;

import static org.junit.Assert.*;

public class TestTemplate {

@Test

public void oneVariable() throws Exception {

Template template = new Template("Hello, ${name}");

template.set("name", "Reader");

assertEquals("Hello, Reader", template.evaluate());

}

}

在代码清单2-3的测试方法中，我们把模板文本作为参数传给构造函数，创建出Template对象，然后给变量name赋值，最后调用了evaluate方法，验证结果与期望的是否一致。

怎么样，你认为模板引擎应该这样工作吗？我认为这样没问题。我有些拿不准该给set方法什么名字，也许put更好，因为它和java.util.map接口更一致。不过我想set比put听起来更顺耳，所以先这样吧。让我们继续，看看如何让这段充满想象的代码通过编译。

1. 通过编译

编译器迫不及待地想要告诉我们，无论意图如何，Template类根本不存在，因此第一步我们要先添加这个类。接着编译器又指出Template类没有一个接受String参数的构造函数，它是在及时提醒你set方法和evaluate方法也不存在，所以继续改动代码^1。最终，我们有了代码清单2-4所示的代码。

1 现代的IDE都支持代码产生的功能，能让测试驱动工作更轻松。

代码清单2-4　为通过编译而添加的空方法和构造函数

public class Template {

public Template(String templateText) {

}

public void set(String variable, String value) {

}

public String evaluate() {

return null;

}

}

代码能编译了。接下来做什么？当然是运行测试了。

2. 运行测试

当我们运行这个刚写成的测试时，测试会失败——这一点也不奇怪，因为我们还没实现这些方法。（你没做实现，对吧？）

我喜欢在这种时候运行测试，眼看着测试失败。因为我想确认在IDE中点了“运行”按钮后，测试真的运行了。这可能会让你感到困惑，不过我确实认为这种方法很实用。此外，若测试失败了，我们还能够确认功能真的不存在，但若测试恰巧通过了，则说明这块功能实际上已经有了。

失败的测试也是进度

你可能认为目前我们的工作尚无任何实质性的进展，因为一行实现代码都还没写，但事实并非如此。我们现在已经有了测试，它能明确地告诉我们当前任务是否已经完成。不会太早，也不会太晚。它不会对我们说“你已完成90%了”，或者“马上就完成了，再坚持五分钟”这种模棱两可的话。若测试通过，我们就明确地知道代码现在已经可以正常工作了。

你现在可能觉得我讲的没道理。不过我相信你终究会理解的，而且也会体会到这种思路的转换所带来的改变。不过这种改变会因人而异，因为毕竟每个人都有所不同。在本书的前半部分我们将会用测试先行的方法写很多的代码，所以你会有很多机会检验这种方式是否真的实用。

运行测试，结果如图2-2所示。我们期望得到字符串“你好，读者”，但却得到了空指针。

图2-2　我们第一个失败的测试，值得纪念。图中为Eclipse的JUnit Runner，不过几乎所有主流IDE都会显示类似的红条以及测试的层次结构，也能提供详细错误信息，失败测试的栈跟踪信息（stack trace）等

我们正处在TDD周期中“红”的阶段，因为刚才有测试失败了，这在IDE中用一个红色的条来表示。我们已经用意图编程方法写好了一个测试，也有了一些基本的产品代码。下一步就该实现Template类，让测试通过了。

2.2.3　通过第一个测试

虽然还没有写多少代码，但我们已经做了很多重要的设计决策。我们决定该有个Template类，通过构造函数的参数装载模板文本，能给模板变量赋值，并且可以做求值操作，产生期望的输出。下一步我们该做什么呢？

现在Template类已经有了基本的结构了（如代码清单2-5所示），测试也能编译成功。

代码清单2-5　为了通过编译，暂时不实现Template类的各个方法

public class Template {

public Template(String templateText) {

}

public void set(String variable, String value) {

}

public String evaluate() {

return null;

}

}

为了能通过编译，我们给Template类添加了构造函数及几个方法，不过它们还什么都没做。我们写了一个失败的测试指出下一步该做的事情，也添加了足够的产品代码让测试编译通过，剩下就该添加功能让测试通过了，这是最费脑力的部分。

不过我们不仅是要通过测试，而且是要用最简单快捷的方法通过测试。当前测试失败了，我们正处在“红”的状态，表明代码的健康状况不佳，这时我们要尽快通过测试以摆脱这种状态。

那么，哪种方法可以最简便快捷地通过测试呢？要对“你好， ${name}”这么简单的模板求值，用字符串替换操作来实现，几分钟就能完成。不过，还有一种实现方法既能满足测试的需要，也可以达到我们“越快越好”的目的，见代码清单2-6。

代码清单2-6　用硬编码的返回语句，使测试尽早通过

public class Template {

public Template(String templateText) {

}

public void set(String variable, String value) {

}

public String evaluate() {

return "Hello, Reader";         // 还有比这更简单的吗

}

}

为了节省空间，从现在起所有测试代码都会省掉两条import语句，访问JUnit库时记得加上。

硬编码求值方法，返回“你好，读者”。是的，我没开玩笑。这是通过测试的最简单快捷的方法了。虽然起初这样做看上去没什么道理，但是确实可以帮我们用测试驱动出正确实现。

目前我们既没用真实的变量，也没用模板，因此至少可以从这两个维度同时向正确实现推进。我们来继续写测试，驱动出正确的实现。

2.2.4　再加一个测试

首先要去除硬编码的变量名。为此我们可以在当前测试基础上加一个断言，如代码清单2-7所示。

代码清单2-7　用另一个测试消除硬编码的返回语句

public class TestTemplate {

@Test

public void oneVariable() throws Exception {

Template template = new Template("Hello, ${name}");

template.set("name", "Reader");

assertEquals("Hello, Reader", template.evaluate());

}

@Test

public void differentValue() throws Exception {

Template template = new Template("Hello, ${name}");

template.set("name", "someone else");                   多行：/*（以下2行）用另一个值做三角定位*/

assertEquals("Hello, someone else", template.evaluate());

}

}

在测试中，我们又调用了一次set方法，不过这次参数值不同。我们还添加了一个断言，验证Template对象会用name变量的最新值再次对模板求值。这时硬编码的值显然没法通过测试了，这正是我们的目的。

这种方法有个很贴切的名字—— “三角法”，表明这个方法会从多个方向同时入手，共同确定出恰当的实现。我们可以称之为“找茬”（playing difficult），不过它真的可以帮助防止过早优化、功能蔓延^2以及总体上的过度设计。

2 维基百科全书将这一词汇解释为：软件过分强调新的功能，以至于损害了其他的设计目标，例如简洁性、轻巧性、稳定性及错误出现率等。——译者注

现在，怎么才能让这个测试通过呢？有什么办法可以避免解析模板吗？我想可加入更多的硬编码规则，再次推迟真实的实现。试试看吧，代码如代码清单2-8所示。

代码清单2-8　保存并返回赋给变量的值，使测试通过

public class Template {

private String variableValue;

public Template(String templateText) {

}

public void set(String variable, String value) {

this.variableValue = value;

}

public String evaluate() {

return "Hello, " + variableValue;

}

}

我们再次相当省事地让测试通过了。这种实现方式显然还不够好，因为硬编码仍旧存在，所以我们应当继续使用三角法清理掉代码中最后剩下的一个字符串。下面接着修改代码，以清除硬编码的变量值及变量周围的模板文本，如代码清单2-9所示。

代码清单2-9　对静态模板文本应用三角法

public class TestTemplate {

@Test

public void oneVariable() throws Exception {

Template template = new Template("Hello, ${name}");

template.set("name", "Reader");

assertEquals("Hello, Reader", template.evaluate());

}

@Test

public void differentTemplate() throws Exception {     // 重命名测试，使其与所做工作相匹配

Template template = new Template("Hi, ${name}");

template.set("name", "someone else");

assertEquals("Hi, someone else", template.evaluate());    // 清除更多硬编码

}

}

红条。显然硬编码的返回语句不再能满足需求了。这时我们必须要用某种方式解析模板了。接下来我们先实现解析逻辑，稍后再看这个测试，如何？

下面我们来讨论深度优先和广度优先。

2.3　广度优先，深度优先

现在我们构造的软件规模并不小，不可能在几小时内完成。我曾经参与开发的一个系统有上万行代码，另外一个系统甚至有100万行代码。还有不少人开发或维护的系统代码居然有几百万行之多。吞下整块的松饼很可能会噎着，一次试图把所有的代码都写完往往也会事倍功半。

在模板引擎例子中，我们正好遇见了一块松饼大小的，尚未处理的复杂逻辑。若不先处理好这块逻辑，我们就没法让模板求值的测试通过。

你可能还记得，算法课中曾经教过遍历树有两种方法，一种为广度优先遍历，另一种为深度优先遍历，做TDD时也一样（至少部分如此）。图2-3和图2-4对比了这两种实现方法。

我们可以采用广度优先的方式，先针对Template类的公共接口写测试，然后使用内部和底层功能的伪实现让测试通过，接着再去给下一层的功能写测试，见图2-3。

图2-3　若采用广度优先的方式，我们会集中实现高层的功能，实现过程中会暂时使用底层功能的伪实现

我们还可以暂时删掉当前测试以回到“绿”的状态，然后开始给模板解析逻辑写测试。模板解析功能完成后再回到删掉的那个测试，重新开始。这就是深度优先方式，纵向地实现功能的所有细节，一步步向前推进，如图2-4所示。

图2-4　若采用深度优先的方式，我们会先实现底层功能，在所有底层功能都实现完后才会组合来实现出高层功能

要让模板引擎处理Hello, ${name}，我们可以先用底层功能（即解析模板功能）的伪实现，在此基础上继续添加功能，看看以后会遇到什么情况。

2.3.1　继续使用伪实现

首先我们要保存变量值和模板文本，也要在evaluate方法中用变量值替换掉模板文本中的变量。代码清单2-10中为具体实现。

代码清单2-10　初次尝试真正的处理变量

public class Template {

private String variableValue;

private String templateText;

public Template(String templateText) {

this.templateText = templateText;

}

public void set(String variable, String value) {

this.variableValue = value;

}

public String evaluate() {

return templateText.replaceAll("\\$\\{name\\}", variableValue);

}

}

可能会有人认为这样是作弊，因为我们仍旧硬编码了查找${name}的正则表达式。但这不是真的作弊。我们没有抱着“赶紧写完代码了事”的态度工作。小步前进，还记得吗？最终会实现所有功能的。现在所有的测试都通过了，实现全部功能完全没问题。下一步我们该做什么呢？

该进行重构了。你认为哪里需要重构？看见了令人讨厌的重复了吗？有没有不理想的代码结构？至少我没发现，所以我打算继续增加测试。现在是时候消除那个硬编码的变量名了，在模板中引入多个变量恐怕是最好的消除方法。

2.3.2　清除掉伪实现

是为多变量模板写测试的时候了。顺便提一句，这是我们测试列表中的第二个测试了，进度不错吧。接下来在测试类中添加如下代码：

@Test

public void multipleVariables() throws Exception {

Template template = new Template("${one}, ${two}, ${three}");

template.set("one", "1");

template.set("two", "2");

template.set("three", "3");

assertEquals("1, 2, 3", template.evaluate());

}

这个测试现在会失败，因为evaluate方法原样返回了模板文本，而不是“1，2，3”（这一点也不奇怪，因为硬编码的正则表达式只会查找${name}）。如何处理模板中增加的变化是个大问题。

要最快地通过测试，我们可以查找替换模板中的所有变量。虽然这种方法效率不高，而且处理变量名中包含类似${variable}的字符串可能会有问题，不过目前是足够了。通常我会把代码中任何可能的问题或者缺陷都写成新测试加到测试列表中，比如现在这个正则表达式：

对模板“${one}，${two}，${three}”求值，其中变量值分别为“1”、“${foo}”以及“3”，验证模板引擎渲染结果为“1，${foo}，3”。

当我们在写一个测试时，如果发现功能实现的不正确，或者整块缺失等问题，可以先把问题记下来，继续手中的工作。这样我们可以集中注意力完成一项任务，而不是在几项任务间来回切换，徒然增大脑负担（而且这种过程还有可能使代码质量下降）。

现在，我们看看怎样用查找替换方法让当前测试通过。

1. 新的更加完善的细节

用查找替换方式实现功能后（见代码清单2-11），我们回到了“绿”的状态。

代码清单2-11　用查找替换处理多变量

import java.util.Map;

import java.util.HashMap;

import static java.util.Map.Entry;

public class Template {

private Map<String, String> variables;      // 用散列表存储变量

private String templateText;

public Template(String templateText) {

this.variables = new HashMap<String, String>();     // 用散列表存储变量

this.templateText = templateText;

}

public void set(String name, String value) {    /*（以下3行）用散列表存储变量*/

this.variables.put(name, value);

}

public String evaluate() {

String result = templateText;

for (Entry<String, String> entry : variables.entrySet()) {    // 遍历变量

String regex = "\\$\\{" + entry.getKey() + "\\}";     /*（以下2行）用变量值替换变量*/

result = result.replaceAll(regex, entry.getValue());

}

return result;

}

}

测试都通过，不错。下一步做什么？重构！不过我还是没发现有什么可重构的，所以我们再次跳过这一步，写下一个测试。

测试列表中还剩下这些测试：

- 对模板“你好，${name}”求值，其中变量name没有相应的值，结果应当抛出MissingValueError错误；

- 对模板“你好，${name}”求值，其中变量doesnotexist的值为“你好”，“name”的值为“读者”，结果应当为“你好，读者” ；

- 对模板“${one}，${two}，${three}”求值，其中变量值分别为1、${foo}以及3，验证模板引擎渲染结果为“1，${foo}，3”。

我猜现在第二个测试应该已经可以通过了，来试试看。

2. 针对特殊情况的测试

在下面这个测试中，我们会验证若给模板文本中不存在的变量设值，值会被模板类忽略。我猜当前的实现已经可以完美地通过测试了，不用做任何的修改。

@Test

public void unknownVariablesAreIgnored() throws Exception {

Template template = new Template("Hello, ${name}");

template.set("name", "Reader");

template.set("doesnotexist", "Hi");

assertEquals("Hello, Reader", template.evaluate());

}

测试确实通过了。

IDE运行测试太快了，有时我甚至不确定测试究竟执行了没有，因此我总会先执行一遍测试，让测试失败，然后做实现，让“红”条变成“绿”条。换句话说，首先故意让测试失败，是为了确认测试执行时出现了错误，也就是说新添加的测试确实执行了，只有这时候我们才去做实现，让指示条重新变绿。

提示　大部分现代IDE用代码模板产生代码。我在Eclipse的“test”模板中添加了一条抛出RuntimeException异常的语句，异常信息为“尚未实现”。每个用Ctrl + Space快捷键产生的测试都会包含这条语句。这样不仅能节约时间，还能提醒我先运行一遍测试。

现在代码确实通过新测试了，没做任何修改。如果所有的开发都这么简单那该多好啊。虽然天上不会总掉馅饼，但是时常有个甜点吃也不错，对吧？

让我们继续，现在又到了重构的时候了。

2.4　别忘了重构

我们写了很多的代码，但还不曾作过重构。这是因为我们一直没有发现需要重构之处。现在还不需要做重构吗？或许不需要，因为并没有添加任何新的产品代码，对吗？不对。虽然我们没有添加任何产品代码，但是加了测试代码。测试代码和产品代码同等重要，我们最好别让它慢慢“腐烂”，因为这将在后来给我们带来很大麻烦。

我们首先看看测试代码中有什么可重构之处，然后选择几处进行重构。代码清单2-12是我们现在的测试代码。

代码清单2-12　目前的测试代码——可以做哪些重构

public class TestTemplate {

@Test

public void oneVariable() throws Exception {

Template template = new Template("Hello, ${name}");

template.set("name", "Reader");

assertEquals("Hello, Reader", template.evaluate());

}

@Test

public void differentTemplate() throws Exception {

template = new Template("Hi, ${name}");

template.set("name", "someone else");

assertEquals("Hi, someone else", template.evaluate());

}

@Test

public void multipleVariables() throws Exception {

Template template = new Template("${one}, ${two}, ${three}");

template.set("one", "1");

template.set("two", "2");

template.set("three", "3");

assertEquals("1, 2, 3", template.evaluate());

}

@Test

public void unknownVariablesAreIgnored() throws Exception {

Template template = new Template("Hello, ${name}");

template.set("name", "Reader");

template.set("doesnotexist", "Hi");

assertEquals("Hello, Reader", template.evaluate());

}

}

花点儿时间考虑如何提高测试代码质量。重复，语义冗余，任何有问题的点，任何可做清理的。我们马上将会讨论几个潜在的重构动作，但你最好能先考虑代码中存在的“坏味道”。当你做好准备后，我们将继续。

2.4.1　测试代码中的可重构之处

这段代码存在几个问题。首先，所有的测试都使用了Template对象，所以我们最好能把它提取为成员变量，而不是在每个方法中都重复声明一遍。其次，所有的测试都把evaluate方法的返回值作为assertEquals方法的参数。再次，两处代码在创建Template对象时使用了同样的模板文本，最好能想办法消除这种重复。

不过，有一个测试使用了不同的模板文本。我们应该给每个模板文本都创建一个实例变量吗？若是这样，我们或许应该把测试类拆分成两个，每个都有自己清晰的夹具变量。

注意　若你还不清楚什么是夹具（fixture），我们可以做个介绍。夹具实际上是为测试准备的一系列的对象状态。在JUnit中，夹具就是测试类的一些成员变量及其他的配置好的状态。我们在第4章中会更详细地讲解夹具，不过现在可以把它当成同一个测试类中所有测试方法共享的初始状态。

除了拆分TestTemplate类外还有其他的重构方法，下面我们来讨论。

2.4.2　移除多余的测试

在oneVariable、differentTemplate和multipleVariables测试中存在一个非常明显的重复。想想看，最后一个测试中实际上已经包含了第一个测试，所以如果我们能新加个测试，验证给某个变量设新值后可以重新对模板求值，那么我们完全可以删除那个单变量模板的测试。此外，还可以让unknownVariablesAreIgnored测试使用multipleVariables测试中的模板文本。另外，我不认为differentTemplate测试还有存在的必要，所以也删了吧。

重构后的代码如代码清单2-13所示。

代码清单2-13　移除冗余测试及统一fixture后的测试代码

import org.junit.Test;

import org.junit.Before;

import static org.junit.Assert.*;

public class TestTemplate {

private Template template;      // 所有测试共有的夹具

@Before

public void setUp() throws Exception {

template = new Template("${one}, ${two}, ${three}");      /*（以下4行）所有测试共有的夹具*/

template.set("one", "1");

template.set("two", "2");

template.set("three", "3");

}

@Test

public void multipleVariables() throws Exception {

assertTemplateEvaluatesTo("1, 2, 3");      // 简单紧凑的测试

}

@Test

public void unknownVariablesAreIgnored() throws Exception {

template.set("doesnotexist", "whatever");     /*（以下2行）简单紧凑的测试*/

assertTemplateEvaluatesTo("1, 2, 3");

}

private void assertTemplateEvaluatesTo(String expected) {

assertEquals(expected, template.evaluate());

}

}

你也看见了，我们可以使测试共享模板文本和初始化过程（setup）1，使测试方法本身轻快短小，仅仅关注要测试的业务逻辑。

1 JUnit会在执行每个标着@Test方法前执行标有@Before的方法。

不过，现在该继续写测试，添加新功能了。目前，我们的模板引擎已经有了基本的功能，下一步应该考虑添加错误处理功能了。

2.5　添加错误处理

我们接着添加测试吧。目前列表中剩下的唯一测试是：若模板中存在未被赋值的变量，evaluate方法应当报错。我们还是会先写一个测试，接着实现功能使测试通过，最后重构出理想的设计。

现在，我们来考虑模板引擎对缺失的变量值的处理方式吧。

2.5.1　验证异常

如何在JUnit中验证异常呢？当然是使用try-catch 结构了，不过这次出现异常是好事，是我们期望的行为。代码清单2-14中是JUnit中测试异常的常见的模式^1。

1 见J. B. Rainsberger所著的JUnit Recipes（Manning Publications，2005）一书中的技巧2.8。

代码清单2-14　测试异常

@Test

public void missingValueRaisesException() throws Exception {

try {

new Template("${foo}").evaluate();

fail("evaluate() should throw an exception if "

+ "a variable was left without a value!");

} catch (MissingValueException expected) {

}

}

注意，在evaluate方法后我们调用了fail方法。org.junit.Assert#fail意思是“如果执行到这一步，那么一定是出了错误”，这时fail方法会使测试失败。不过倘若evaluate方法抛出了异常，且异常类型正确，那么我们会捕获这个异常然后忽略之，否则会任JUnit处理这个异常，测试会失败。

使用注释语法测试异常

JUnit 4带给我们一个方便的，基于注释语法的异常测试方法（例如测试代码清单2-14中的missingValueRaiseException）。若使用注释语法语法，测试可以改写成：

@Test(expected=MissingValueException.class)

public void testMissingValueRaisesException() throws Exception {

new Template("${foo}").evaluate();

}

虽然这个基于注释语法的测试看起来比try-catch结构的测试精炼，不过使用try-catch，我们能够验证异常的更多信息（例如其中包括的关键错误信息）。有些人喜欢使用注释语法，另一些人总是偏好使用try-catch模式。我在只测试异常类型时使用注释语法，希望发掘出更多的信息时使用try-catch模式。

好了，我们有了一个失败的测试。现在还不能通过编译，不过添加一个空的MissingValueException类就行了：

public class MissingValueException extends RuntimeException {

// this is all we need for now

}

指示条再次变红，准备让测试通过吧，找出检查缺失变量值的方法。

我们再次要想方设法让测试快速通过。在evaluate方法中，如何能判断出模板文本变量尚没有对应的变量值？检查输出中类似变量的片断是我现在能想到的最简单的方法了。这方法算不上健壮，不过目前足够了，实现也很容易。

实现代码如代码清单2-15所示。

代码清单2-15　在查找替换操作完成后，检查未处理的变量

public String evaluate() {

String result = templateText;

for (Entry<String, String> entry : variables.entrySet()) {

String regex = "\\$\\{" + entry.getKey() + "\\}";

result = result.replaceAll(regex, entry.getValue());

}

if (result.matches(".*\\$\\{.+\\}.*")) {     // 貌似这里缺了个变量？

throw new MissingValueException();

}

return result;

}

又由红变绿了。这次需要重构吗？我认为暂时不用，不过evaluate方法的代码体积有些大了。“大？就那点儿代码？”，我想是的，该重构了。

2.5.2　把方法重构得更短些

方法、类等的大小，每个人都有自己的偏好。我个人习惯每个方法内少于10行Java代码^2。只要超过了这个限度，我就会开始琢磨是否有东西可以丢掉，或者移到其他地方。现在evaluate方法所做的事有些多了，既要替换变量值，又要检查缺失的值。

2 也可以用McCabe 来衡量方法是否过大，此外长的方法的复杂性也会很高。

让我们来做些重构，让evaluate方法变得更简洁吧。我们至少可以把缺失变量值的检查抽取到单独的方法中去。代码清单2-16对evaluate方法进行了重构。

代码清单2-16　把检查缺失变量的逻辑抽取到一个方法中

public String evaluate() {

String result = templateText;

for (Entry<String, String> entry : variables.entrySet()) {

String regex = "\\$\\{" + entry.getKey() + "\\}";

result = result.replaceAll(regex, entry.getValue());

}

checkForMissingValues(result);     // 噢万岁！终于把evaluate()中所有的if - block都处理掉了

return result;

}

private void checkForMissingValues(String result) {

if (result.matches(".*\\$\\{.+\\}.*")) {

throw new MissingValueException();

}

}

已经好多了，不过还可以改进。从evaluate方法中抽取出检查缺失变量逻辑时，我们破坏了evaluate方法的抽象层次，evaluate方法不再如我们期望的那般平衡了。在继续做实现之前，我们先讨论一下平衡吧。

2.5.3　保持方法平衡

保持方法中的代码抽象层次的一致性会影响到代码的可读性。以evaluate方法为例说明：

public String evaluate() {

String result = templateText;

for (Entry<String, String> entry : variables.entrySet()) {

String regex = "\\$\\{" + entry.getKey() + "\\}";

result = result.replaceAll(regex, entry.getValue());

}

checkForMissingValues(result);

return result;

}

evaluate方法做了两样事：用变量值替换变量，以及检查缺失的变量值，两件事的抽象层次完全不一样。比起checkForMissingValues方法调用，循环结构显然包含了太多细节。要添加点新功能很容易，只需要在现有的函数中直接添加代码即可，但如果忽略了抽象层次的一致性，代码很快就会变得一团糟。

不过还好，只需用“提炼函数”重构手法就能解决问题，如代码清单2-17所示。

代码清单2-17　从evaluate方法中抽取出另一个方法

public String evaluate() {

String result = replaceVariables();    /*（以下2行）Evaluate()方法内部更平衡*/

checkForMissingValues(result);

return result;

}

private String replaceVariables() {     /*（以下8行）新方法很简单，目标也清晰明确*/

String result = templateText;

for (Entry<String, String> entry : variables.entrySet()) {

String regex = "\\$\\{" + entry.getKey() + "\\}";

result = result.replaceAll(regex, entry.getValue());

}

return result;

}

private void checkForMissingValues(String result) {

if (result.matches(".*\\$\\{.+\\}.*")) {

throw new MissingValueException();

}

}

运行一遍测试，重构没有破坏任何功能，不错。如果没有测试做后盾，这些重构的工作量可不小。实际上，若没有测试，我们可能完全不会做重构的。

所有测试都通过了，不错。不过异常处理部分还没完全做完，在处理缺失变量值时最好能提供有意义的异常信息。

2.5.4　验证异常中的详细信息

在缺失变量的测试中我们只验证了MissingValueException异常，这样是不够的。我常常见到意义很含糊的异常^3，也常常背地里骂那些不提供有用信息的开发人员。在这种情况下，我们应该在异常信息中说明所缺值变量的名称，见代码清单2-18。

3 “java.lang.NullPointerException: null”这种信息有什么用呢？

代码清单2-18　测试异常

@Test

public void missingValueRaisesException() throws Exception {

try {

new Template("${foo}").evaluate();

fail("evaluate() should throw an exception if "

+ "a variable was left without a value!");

} catch (MissingValueException expected) {

assertEquals("No value for ${foo}",      /*（以下2行）应提供缺失值的变量的名称*/

expected.getMessage());

}

}

这次我们还是要让测试在几分钟内通过。我们打算使用java.util.regx API提供的功能来找出渲染结果中能够与变量模式匹配的部分。也许用代码可以表述的更清楚些：

import java.util.regex.Pattern;

import java.util.regex.Matcher;

private void checkForMissingValues(String result) {

Matcher m = Pattern.compile("\\$\\{.+\\}").matcher(result);

if (m.find()) {

throw new MissingValueException("No value for " + m.group());

}

}

当然我们也需要为MissingValueException类添加一个构造函数：

public class MissingValueException extends RuntimeException {

public MissingValueException(String message) {

super(message);

}

}

完成了。几分钟前，我们想到了一个测试，然后立即动手做了实现。不过若测试工作量相对较大，我们最好先把它加到测试列表中，稍后再着手做。提到测试列表，或许我们现在应该更新测试列表了。

2.6　无穷尽的测试

最初列出的几个测试现在已经都做完了。在实现的过程中，我们也发现了一些问题。其一，我们没有处理好变量值中包含“${”及“}”等字符串的情形。另外我还有些担心模板引擎的效率。算上性能测试，列表中还剩下如下测试：

- 对模板“${one}，${two}，${three}”求值，其中变量的值分别为“1”、“${foo}”以及“3”，验证模板引擎渲染结果为“1，${foo}，3”；

- 若模板包含100个词、20个变量，且每个变量值长度大约为15个字符，求值所需时间应在200毫秒内。

当我们想到新的测试时，应该直接加到列表中，以免忘记。不要放下手中的工作立即去做新的测试，我们不能让新测试打乱工作的节奏。先把新测试记下来，继续手中的工作，稍后再去处理新的测试。

而现在，我们手中并没有其他工作，所以可以直接去处理这些测试了。先看性能测试，然后再处理重复渲染问题，如何？

2.6.1　性能测试

模板引擎的性能如何？我们可以给evaluate方法添加一个性能测试，看看执行时间是否合理。代码清单2-19中的测试类用于检测程序的性能。

代码清单2-19　写个性能测试作为预警系统

import org.junit.Test;

import static org.junit.Assert.*;

public class TestTemplatePerformance {

// Omitted the setUp() for creating a 100-word template

// with 20 variables and populating it with approximately

// 15-character values

@Test

public void templateWith100WordsAnd20Variables() throws Exception {

long expected = 200L;

long time = System.currentTimeMillis();

template.evaluate();

time = System.currentTimeMillis() - time;

assertTrue("Rendering the template took " + time

+ "ms while the target was " + expected + "ms",

time <= expected);

}

}

Template目前的性能还不错，渲染一个含有100个词、20个变量的模板需要大约100毫秒。有了这个测试，若模板引擎因为某些原因变得的过慢了，我们会立即发现。

这种测试方式当然不是完美无缺的。因为我们并不是在真空中执行测试，若测试依赖于执行时间，那么总不会太稳定。测试可能在这个计算机上通过，而在那个计算机上失败，而且在同一台计算机上也可能时而成功时而失败，取决于同时运行的其他的软件在做什么类型的运算。随机失败的测试是不可接受的，因此必须要解决这个问题。不过大多数情况下，我们可以微调测试，使其不会总随机失败，但同时又能及时发现性能问题。

现在先不用考虑这些，下面我们来处理待渲染的变量的值与变量有一样格式的重复渲染问题吧。

2.6.2　有些失望的结局

接下来我们要处理变量值包含“${”及“}”等字符串的情况了，这看起来有些困难。首先，我们不能用赋给模板的变量值一遍遍地做查找替换操作，因为一些先被渲染的变量值可能会被后续的查找替换操作重新渲染，变成完全不同的值。另外，我们也不能通过查找输出字符串中的“${…}”来判别是否存在未设值的变量。

在进行下一步前，我们先不要过多的猜测了吧，写一个测试来检测当前代码行为。把下面的测试加入TestTemplate类中：

@Test

public void variablesGetProcessedJustOnce() throws Exception {

template.set("one", "${one}");

template.set("two", "${three}");

template.set("three", "${two}");

assertTemplateEvaluatesTo("${one}, ${three}, ${two}");

}

这个测试确实能够测出问题。evaluate方法的正则表达式部分抛出了IllegalArgumentException异常，提示“非法组应用”，说明代码的确没有正确处理这种情形。我想现在应该重新考虑一下那个测试，拿个记事本打点草稿了。不过，我们先对这章做个回顾，休息一会儿，在下章中再处理重复渲染问题吧。

2.7　小结

TDD技术很强大，能帮助我们更高效地写出高质量的软件。使用TDD时，我们会把注意力集中在当前需要做的事情上，让当前部分能正常地工作，最后清理实现过程中可能产生的各种杂乱的代码，保证代码的高质量。在TDD的编写测试，写代码以通过测试，最后重构的整个周期中，我们会常常用到意图编程，即写测试时假设实现已经存在了，这种编程方法能让设计的可用性更高，测试性更好。

在本章中，我们真正地使用了TDD，按照TDD所定义的周期进行工作，不过后来发现当前模板引擎的设计不够健壮。我们在写模板引擎时罗列了一些测试，用于说明引擎的行为，而且在整个开发过程中都坚持采用测试—编码—重构的步骤^1。现在的代码已经能够满足大部分的需求了，足以满足许多场合的需要。有测试做后盾，我们进展的速度会更快，也不用担心重构会破坏掉现有功能。

1 如果你没有严格地遵循，我能原谅你。有时候我也会。不过欠下的债总要换的，麻烦通常就在后面等着，这时候我们才能更好地理解TDD所带来的优点。我相信你也会越来越接受TDD的。

现在我们开始学习下一章吧，看看如何处理剩下的问题，把模板引擎变成一个功能完整、设计优美的产品！

第3章　小步重构

只有经过不断的简化和提炼，才能得到最强大的设计。

——凯文·穆雷特，Designing Visual Interfaces: Communication Oriented Technigues

在上一章中，我们用TDD实现了一个模板引擎。虽然基本上是从零开始，但实现的代码却非常有用。事实上，我们只写了少得令人惊讶的代码，就达到了如此效果。看看Template类有多么短，那些有多年瀑布式开发经验，习惯事先设计的人可能会感到出乎意料。当然也有可能只有我感到惊讶，其他人已经有了更好的设计。

在模板文本中用正则表达式查找变量确实有一定的问题。不过，遇见问题正是做设计的好机会。用测试先行的方法做开发时，大多数时间都没什么挑战。倘若遇到一些困难，反而会让人精神一振，解决问题过程中还能充分地发挥想象力。在本章中，我们将会学习用小的、受控的步骤引入大的设计变化。

在本章的开始部分，我们会学习一种名为Spike的研究问题的方法。Spike基本上是一种是以实验为目的的原型方法，目的是为了研究某个解决方法的可能性和可行性。在清楚地了解Java正则表达式API是否能解决当前问题后，我们就可以试着实现解析器了。

我们也将会重构模板引擎的代码。重构完成后，就能很容易地用新解析器替换现有解析逻辑了。在模板引擎中引入模板解析器后，我们将继续深入重构，把代码中隐含的类型提升为设计中的显式的主要概念。

现在开始吧。

3.1　探寻解决方案

你已经想过如何解决重复渲染问题了吗？倘若没有，现在花些时间考虑吧。

考虑完了吗？好。你刚才做了些什么？在纸上打草稿？临时写了段Java代码，想弄清楚如何使用Java正则表达式的API？在Google上搜索？或在Java文档中寻求答案？或者想抛弃正则表达式，另找出路？在TDD的过程中，我们常常需要确定某个方法的可行性，或试着找出问题的最佳解决方法，在极限编程中，这个过程称为Spike。

3.1.1　用Spike开发原型

遇到问题时，放松一下，准备克服困难。这时候我们需要深入研究潜在的解决方案，可能是很新的技术，也可能是非常古老的API，也许是比当前方法更经济可行的算法。Spike算是一个小^1的实验，目的在于让我们掌握解决当前问题所需要的技术、工具、算法，等等。Spike的主要目的是学习知识，通过Spike，我们至少要弄清楚目前解决方法是否值得继续研究，还是要换一种解决方案。

1 这里的“小”是相对的，大部分Spike都会用去15分钟到几个小时，一个TDD周期只会用去几秒钟或者几分钟时间，而不是几个小时。

有些人在做Spike时不会测试先行，而有些人会。测试先行的编码方式能带来许多好处，在做Spike时为何不用这种编码方式呢？不过我们要注意，不能把Spike时写的代码直接贴到产品代码中。因为要保持代码的高质量，因此每写一部分代码都需要测试先行。复制产品代码可不好，我们要应用Spike中学到的知识来写代码，让代码质量维持在很高的水平。

现在，我们来看看做Spike的方式吧。我们会先写一个学习测试（learning test）来学习Java正则表达式API，然后再试着应用学到的知识。开始吧。

3.1.2　写测试学知识

我们写到哪里了？哦，想起来了。模板引擎存在问题，不能正确处理类似于变量名的变量值，有测试为证。也许我有些固执，不过我仍旧认为选择正则表达式没错，Java正则表达式API（java.util.regex）或许真的可以很漂亮地解决这个问题。

研究API如何工作，是否能为我所用之类的小Spike， 学习测试就能够胜任。学习测试目的在于验证对API的理解是否正确，这些测试是学习使用类库的最佳示例。写几个学习测试弄明白Java正则表达式API，或许就能找到修复那个测试的好方法。

加载模板时，把模板解析成纯文本片断和变量片断，然后再按顺序对这些片断求值，最后用StringBuilder把求值结果拼接起来，这办法怎么样？例如，模板“${first} and ${second}”在解析后将会产生如下序列：变量片断first、纯文本片断and以及变量片断second。逐个对这些片断求值，一定能解决重复渲染问题。

先来写些学习测试，学习使用正则表达式API吧。

3.1.3　学习API的Spike样例

Java.util.regex.Matcher类提供一个名叫groupCount的方法。我记得在Java正则表达式中，“子表达式”表示圆括号包围的文本，可以一试。我们用下面的测试验证对API的理解是否正确：

public class RegexLearningTest {

@Test

public void testHowGroupCountWorks() throws Exception {

String haystack = "The needle shop sells needles";

String regex = "(needle)";

Matcher matcher = Pattern.compile(regex).matcher(haystack);

assertEquals(2, matcher.groupCount());

}

}

测试失败了，因为我以为groupCount方法会返回文本中子表达式的匹配次数，而实际上groupCount方法会返回正则表达式自身包含的子表达式数目（请别问我为什么）。还好，至少我能及时发现问题。

仔细地阅读完java.util.regex.Matcher文档后，我发现了如下解释：“find方法扫描输入序列，查找下一个匹配的子串”。此外文档中还提到：

（这个方法）会试图找出输入序列中能够匹配模式的下一个子序列。这方法从输入序列的头部开始扫描。倘若上次对这方法的调用执行成功，且matcher对象尚未重置，那么方法将从上次第一个未能匹配的字符开始扫描。如果匹配成功，调用start、end和group方法可以获取更多信息。

现在我们再写些学习测试，试着用find方法找出所有匹配的片断。

依我对文档的理解，find方法会扫描至第一个needle文本片断，由于查找成功了，返回值应当为true。用个测试来验证吧：

@Test

public void testFindStartAndEnd() throws Exception {

String haystack = "The needle shop sells needles";

String regex = "(needle)";

Matcher matcher = Pattern.compile(regex).matcher(haystack);

assertTrue(matcher.find());

}

测试通过。接下来，我们应该可以调用start和end方法取得相关字符的位置。为此也添加个断言：

@Test

public void testFindStartAndEnd() throws Exception {

String haystack = "The needle shop sells needles";

String regex = "(needle)";

Matcher matcher = Pattern.compile(regex).matcher(haystack);

assertTrue(matcher.find());

assertEquals("Wrong start index of 1st match", 4, matcher.start());

assertEquals("Wrong end index of 1st match", 10, matcher.end());

}

不错，全部正确。那么，再次调用find方法，会返回第二个“needle”文本的位置吗？代码清单3-1为对应学习测试。

代码清单3-1　确认我们可以定位字符串中所有的匹配项

@Test

public void testFindStartAndEnd() throws Exception {

String haystack = "The needle shop sells needles";

String regex = "(needle)";

Matcher matcher = Pattern.compile(regex).matcher(haystack);

assertTrue(matcher.find());

assertEquals("Wrong start index of 1st match.", 4, matcher.start());

assertEquals("Wrong end index of 1st match.", 10, matcher.end());

assertTrue(matcher.find());

assertEquals("Wrong start index of 2nd match.", 22, matcher.start());

assertEquals("Wrong end index of 2nd match.", 28, matcher.end());

assertFalse("Should not have any more matches", matcher.find());

}

好，测试通过了。现在我们可以准确地找到字符串中所有匹配项的位置了。我们肯定能够写出一个相对简单的循环，提取出所有的变量片断及纯文本片断，然后把这些片断加入列表。开始干吧！等等，还有一件事要做。

不晓得为什么，我对代码清单3-1中的正则表达式有些好奇。测试中使用了正则表达式“（needle）”，也许是因为在正则表达式中通常用圆括号来分组。不过，我不确定这些括号在当前情况下是否真的有用。毕竟我们需要的是整个正则表达式的匹配结果，而不是正则表达式中某个子表达式的匹配结果。

实践证明，把正则表达式中的括号删去，测试一样会通过。正则表达式中的子表达式毫不影响匹配结果，这是我在这个小测试中学到了另外一个新知识点。

注意　虽然Spike时一定要写代码，不过这却不是Spike的主要目的。Spike主要是为了学习知识，获取信息。Spike过程中写了多少代码并无干系，重点在于深入理解问题本身，以及探索相应的解决方法。例如在刚才的Spike过程中，我们学到了如何定位变量的起始及结束的位置，有了这些知识，我们就能够把模板分解成纯文本片断及变量片断了。

接着做吧，下面我们就要应用刚才学到的知识了。首先，我们需要把解析模板和渲染结果这两步分开，相应地，应当从evaluate方法中抽取出两个不同的方法。不过直接变动evaluate方法步骤有些太大。我们可以用深度优先的方法进行改动，首先实现新的解析逻辑，然后再替换掉当前实现。

3.2　以受控的方式修改设计

正如前面所提到的，完成整个解决方案有很多种途径，例如深度优先和广度优先等等。那时，我们先使用了模板解析引擎的伪实现，有效地推迟了业务逻辑部分的实现，直到非实现不可之时。实际上选择另外一条途径也未尝不可。没有现成的硬性的规定判断该使用何种方法，具体问题应当具体分析。模板类变得愈加复杂了，因此我觉得深度优先方法可能会更适用。

我们先添加一个名叫TemplateParse的类，这是我们重构的方向。这个类主要负责将一个模板文本分解成纯文本片断及变量片断。当然我们会测试先行。一旦写完TemplateParse类，我们将会用TemplateParse类替换掉Template类的旧解析代码。

开始动手前，先运行一遍现有测试，以确认当前代码没有问题。暂时注释掉那个测出重复渲染问题的测试后，所有测试都通过了。

换一种实现方法

若使用深度优先方法，我们将会先忘记Template类，专心实现TemplateParse类的分解模板功能。这个新类与现有代码没有任何依赖关系，这是个全新的起点。正如前面提到的，简单的测试总是个良好的开端。

1. 先做容易的功能

拿不定主意从哪开始？先写个空模板的测试如何？代码如代码清单3-2所示。

代码清单3-2　用TemplateParse类解析空模板

public class TestTemplateParse {

@Test

public void emptyTemplateRendersAsEmptyString() throws Exception {

TemplateParse parse = new TemplateParse();

List<String> segments = parse.parse("");

assertEquals("Number of segments", 1, segments.size());

assertEquals("", segments.get(0));

}

}

添加TemplateParse类，并使parse方法返回null，这下我们又有了个失败的测试指明前进的方向了。快速添加几行代码，返回包含原始模板文本的列表，测试就能通过了。目前这种实现方式是合理的，因为对于TemplateParse类来说，不包含任何变量的模板就是一个纯文本片断。

我们前进得很快，继续看下个测试：

@Test

public void templateWithOnlyPlainText() throws Exception {

TemplateParse parse = new TemplateParse();

List<String> segments = parse.parse("plain text only");

assertEquals("Number of segments", 1, segments.size());

assertEquals("plain text only", segments.get(0));

}

这个测试基本没有挑战性。实际上，上个测试写的代码，同样可以使这个测试通过。见代码清单3-3。

代码清单3-3　用TemplateParse类解析空模板

import java.util.List;

import java.util.Collections;

public class TemplateParse {

public List<String> parse(String template) {

return Collections.singletonList(template);

}

}

这时，我们的emptyTemplateRendersAsEmptyString测试和templateWithOnlyPlainText测试中又出现了重复。在继续开发TemplateParse之前，我们先处理掉这些重复。

2. 消除测试中的重复

两个测试中都创建了TemplateParse对象，且把模板文本传给了这个对象。很显然这是重复，我们可以把公共部分提取成一个帮助方法，如代码清单3-4所示。

代码清单3-4　消除测试方法中的重复

private List<String> parse(String template) {

return new TemplateParse().parse(template);

}

@Test

public void emptyTemplateRendersAsEmptyString() throws Exception {

List<String> segments = parse("");

assertEquals("Number of segments", 1, segments.size());

assertEquals("", segments.get(0));

}

@Test

public void templateWithOnlyPlainText() throws Exception {

List<String> segments = parse("plain text only");

assertEquals("Number of segments", 1, segments.size());

assertEquals("plain text only", segments.get(0));

}

在此基础上我们还可以继续重构。两段测试都验证了列表长度。两个测试中用的assertEquals方法有明显的规律。因此，我们可以试着重构代码，使其更清晰地表达出本意。看看是否能用Java 5中新添的变长参数语法^1。如代码清单3-5所示。

1 Java 5中引入了变长参数概念，简化了代码。例如自定义的验证一个列表的断言方法。

代码清单3-5　重构断言部分，使代码更清晰表达其本意

private void assertSegments(List<? extends Object> actual,    /*（以下2行）变长参数列表简化了断言*/

Object... expected) {

assertEquals("Number of segments doesn't match.",

expected.length, actual.size());

assertEquals(Arrays.asList(expected), actual);

}

@Test

public void emptyTemplateRendersAsEmptyString() throws Exception {

List<String> segments = parse("");

assertSegments(segments, "");     // 变长参数列表简化了断言

}

@Test

public void templateWithOnlyPlainText() throws Exception {

List<String> segments = parse("plain text only");

assertSegments(segments, "plain text only");     // 变长参数列表简化了断言

}

重构后测试仍旧可以通过。既然如此，我们何不往模板中加入两个变量试试？测试先行，还是先从添加测试开始吧：

@Test

public void parsingMultipleVariables() throws Exception {

List<String> segments = parse("${a}:${b}:${c}");

assertSegments(segments, "${a}", ":", "${b}", ":", "${c}");

}

嗯，越来越有趣了。返回包含一整块模板文本的列表已不再能通过测试了。我们试着真正地解析一段模板文本吧。

3. 应用Spike中学到的知识

这时，我们需要从短期记忆中提取出Spike时学到的知识，解决当前遇到的问题。我首先想到的方法是：从模板首部开始读取纯文本，接着读取变量，这时在当前位置设置一个指针，下次循环中可以从指针处开始继续解析模板。这样依次循环处理，直到模板文本末尾。我们一次性实现了这个解析算法，如代码清单3-6所示。实现中用到了Spike中学到的Java正则表达式API的相关知识，自动化测试再次充当了保护网。

代码清单3-6　消除测试方法中的重复

public class TemplateParse {

public List<String> parse(String template) {

List<String> segments = new ArrayList<String>();

int index = collectSegments(segments, template);    /*（以下3行）高级解析逻辑*/

addTail(segments, template, index);

addEmptyStringIfTemplateWasEmpty(segments);

return segments;

}

private int collectSegments(List<String> segs, String src) {

Pattern pattern = Pattern.compile("\\$\\{[^}]*\\}");

Matcher matcher = pattern.matcher(src);

int index = 0;

while (matcher.find()) {      /*（以下5行）收集变量和中间的纯文本*/

addPrecedingPlainText(segs, src, matcher, index);

addVariable(segs, src, matcher);

index = matcher.end();

}

return index;

}

private void addTail(List<String> segs, String template, int index) {

if (index < template.length()) {    /*（以下3行）如果还有纯文本，附加上*/

segs.add(template.substring(index));

}

}

private void addVariable(List<String> segs, String src,Matcher m) {

segs.add(src.substring(m.start(), m.end()));

}

private void addPrecedingPlainText(List<String> segs, String src,

Matcher m, int index) {

if (index != m.start()) {

segs.add(src.substring(index, m.start()));

}

}

private void addEmptyStringIfTemplateWasEmpty(List<String> segs) {

if (segs.isEmpty()) {

segs.add("");

}

}

}

代码清单3-6中代码结构的复杂性陡然增加。公有（public）的parse方法基本上是这样一种算法。它首先会把输入解析成片断序列，然后把最后一个变量后的所有文本都作为一个片断添加到序列中，最后，若此时解析出的片断数量仍然为零，则往片断序列中添加内容为空的片断。

在collectSegments方法中，解析出的片断会被添加到ArrayList中。我们基本上是在遍历正则表达式匹配的所有模板变量。对于每一个变量，我们会将此变量和前一个变量之间的纯文本加入列表中，然后把这个变量加入到列表中，更新index变量以保存最后一个匹配变量的末尾位置。这些从模板文本中提取出片断的私有（private）函数都相对简单且不难理解，这是因为其名字含义足够明确。

现在我们已经有了一个类了，它能把模板解析成包含纯文本片断及变量片断的序列。往模板引擎中加入闪亮的新解析器之前，让我们先回顾一下刚才做过的事情吧。

4. 继续行进前的反思

用单独的类封装解析逻辑没错，不过，我们的步子有些大了，花了好几分钟时间才完成解析逻辑并通过测试。另外，我们也可以把TemplateParse内部四处传递的片断列表变为成员变量，使其在各个成员函数之间共享。不过，若要使我们的解析器线程安全，那么还需要对parse方法进行同步控制。但是，我们尚不用支持多线程，所以现在只要不共享TemplateParse实例就不会有问题。我们就不一起做这些工作了，不过你可以试着自己去重构。

测试类中的帮助方法并没有准备任何数据，也没有配置任何对象状态，因此这个方法或许应该是TemplateParse类的静态构建方法。

先记下这个潜在的重构，继续Template类的开发。这回记住要坚持小步前行，同时还要时刻留意解析器的复杂程度。下一步我们来修改Template类，用TemplateParse类解析模板。

5. 安全地替换实现

目前TemplateParse已经有了必要的功能，可以把模板文本解析成片断序列。下一步我们想用它替换Template类中的现有解析逻辑。我们并不会改变任何功能，改变功能是我们要尽力避免的！不错，测试都通过了，可以安全地重构了。

6. 使用新实现

代码清单3-7中为当前Template类evaluate方法，我们将会用TemplateParse重写这部分代码。

代码清单3-7　当前evaluate()的实现

public String evaluate() {

String result = replaceVariables();

checkForMissingValues(result);

return result;

}

现在用意向编程，把evluate方法改成：

public String evaluate() {

TemplateParse parser = new TemplateParse();

List<String> segments = parser.parse(templateText);

StringBuilder result = new StringBuilder();

for (String segment : segments) {

append(segment, result);

}

return result.toString();

}

不错，这样就可以了。我们基本上刚刚创建一个TemplateParse对象，让其担负起渲染模板这艰巨任务，然后遍历解析后的片断，用StringBuilder将这些片断挨个拼接起来。编译时编译器提示说append方法不存在，所以需要添加一个append方法：

private void append(String segment, StringBuilder result) {

if (segment.startsWith("${") && segment.endsWith("}")) {

String var = segment.substring(2, segment.length() - 1);

if (!variables.containsKey(var)) {

throw new MissingValueException("No value for " + segment);

}

result.append(variables.get(var));

} else {

result.append(segment);

}

}

append方法会根据片断类型来进行拼接。对于变量，即在$符号后且包含在大括号中的片断，将拼接其对应的变量值。对于纯文本片断，将拼接片断本身。

运行一遍测试，都通过了，表明替换实现后的代码没有问题。这项工作并不困难，对吧？不过我们为什么要做这些改动？以前的代码质量不错，为什么还要重新写一遍？好啦，既然测试都通过了，那么我们就可以放心的重构代码，使其更加健康。另外，因为目前使用了TemplateParse解析模板并用StringBuilder拼接片断，所以我们可以删掉旧实现中的replaceVariables和checkForMissingValues两个方法了。或许目前的代码质量并不差，但是我们仍旧可以试着继续重构，进一步改善代码质量。

7. 保持统一的抽象层次

首先，我们先来处理evluate方法。如下所示，目前实现的抽象层次并不一致：

public String evaluate() {

TemplateParse parser = new TemplateParse();

List<String> segments = parser.parse(templateText);

StringBuilder result = new StringBuilder();

for (String segment : segments) {

append(segment, result);

}

return result.toString();

}

还好，可以很容易地把渲染逻辑提取到单独的方法中去，如代码清单3-8所示。

代码清单3-8　把渲染逻辑提取到单独的方法中

public String evaluate() {

TemplateParse parser = new TemplateParse();

List<String> segments = parser.parse(templateText);

return concatenate(segments);      // evaluate()现在实现的抽象层次一致多了

}

private String concatenate(List<String> segments) {

StringBuilder result = new StringBuilder();

for (String segment : segments) {     /*（以下3行）...concatenate()做的只是一件事*/

append(segment, result);

}

return result.toString();

}

还可以继续重构。append方法仍然不够好看，我们需要对它进行一些修剪，这样我们就可以自豪地向其他同事展示这段代码了。

8. 通过抽取函数来清理代码

我们应当怎么处理掉append方法中那讨厌的if-else呢？看看下面这段代码。

private void append(String segment, StringBuilder result) {

if (segment.startsWith("${") && segment.endsWith("}")) {

String var = segment.substring(2, segment.length() - 1);

if (!variables.containsKey(var)) {

throw new MissingValueException("No value for " + segment);

}

result.append(variables.get(var));

} else {

result.append(segment);

}

}

试着抽取出几个方法如何？我们可以把if块中的代码提取成一个方法，让if块的条件判断语句含义更加明确。这样append方法会更易读。如代码清单3-9所示。

代码清单3-9　重构后的append方法

private void append(String segment, StringBuilder result) {

if (isVariable(segment)) {       /*❶（以下5行）基于段类型的分支执行*/

evaluateVariable(segment, result);

} else {

result.append(segment);

}

}

private boolean isVariable(String segment) {

return segment.startsWith("${") && segment.endsWith("}");    // "${"and"}"周围的变量

}

private void evaluateVariable(String segment, StringBuilder result) {

String var = segment.substring(2, segment.length() - 1);    // 变量的渲染值

if (!variables.containsKey(var)) {

throw new MissingValueException("No value for " + segment);

}

result.append(variables.get(var));    // 变量的渲染值

}

好多了，但是❶append方法中的if-else仍然有问题。这个结构会根据片断的类型来做不同操作，这显然违反了Pragmatic Programmer网站上提到的“Tell, Don't Ask”原则^2。另外，模板片断具有一些特殊的行为和操作，并非简单的文本，若用字符串表示模板片断，则会有些“基本型别偏执”（Primitive Obsession）3的坏味道。如果用第一级对象表示这些片断，那么这些问题就迎刃而解了。我们可以使用多态机制，实现与if-else结构同样的逻辑，这样，我们只需要简单的调用片断对象上的方法（告诉片断去做），而不是先判断片断类型，再做出相应操作。

2 http://www.pragmaticprogrammer.com。

3 参考Martin Fowler所著《重构——改善既有代码的设计》一书。

在进行下一步工作前先运行一遍测试，确认当前代码没有问题。很好，所有的测试都通过了，可以继续下一轮重构了。这些重要的字符串的片断确实应该是第一级对象。

3.3　进一步延伸新设计

目前，我们已经替换了模板引擎的核心实现，重构了代码。不过剩下的工作还不少。

我们期望能够忽略模板片断对象的真实类型，用同样的方法处理各种类型的片断对象，这样可以消除掉那些不够优美的if-else，直接把拼接工作交给片断对象完成。继续用意图编程设计出Segment类的新接口。代码清单3-10表示从Template类角度看，我所期望的Segment类的接口。

代码清单3-10　统一处理不同类型的片断

public String evaluate() {

TemplateParse parser = new TemplateParse();

List<Segment> segments = parser.parse(templateText);     // 用片断而不是字符串

return concatenate(segments);

}

private String concatenate(List<Segment> segments) {

StringBuilder result = new StringBuilder();

for (Segment segment : segments) {

segment.appendTo(result, variables);     // 让片断自己主动拼接到缓存

}

return result.toString();

}

这下代码变得更好了。在代码清单3-10中可以看出，我们应当改变TemplateParse类的parse方法，使其不再返回字符串列表，而是返回片断列表。片断对象应该可以把自己拼接到给定的StringBuilder上，必要时会从参数中的Map对象获取变量值，这样应该没有问题。

此重构该如何进行？由于我们会改变TemplateParse的工作方式，所以应该先改变TemplateParse的测试。等TemplateParse及其测试都修改完后，再去修改Template类。前进的步子不要太大，还记得吧？吸取过去的经验教训，我们先来撤销对evaluate方法的修改，等实现TemplateParse的新功能后再回过头完成这块代码。

3.3.1　保持兼容

为了在引入Segment对象过程中不破坏任何测试，基于字符串的parse方法和基于Segment对象的新parse方法会暂时同时存在，以保持向后兼容。

新parse方法的测试如下：

public class TestTemplateParse {

@Test

public void parsingTemplateIntoSegmentObjects() throws Exception {

TemplateParse p = new TemplateParse();

List<Segment> segments = p.parseSegments("a ${b} c ${d}");

assertSegments(segments,

new PlainText("a "), new Variable("b"),

new PlainText(" c "), new Variable("d"));

}

}

看来我们需要Segment接口的两个不同实现。先添加这两个类，通过编译，以得到一个失败的测试，如代码清单3-11所示。

代码清单3-11　Segment接口及其初始实现

public interface Segment {

}

public class PlainText implements Segment {

public PlainText(String text) { }

}

public class Variable implements Segment {

public Variable(String name) { }

}

以下是TemplateParse类中缺失的parseSegments方法的空实现：

public List<Segment> parseSegments(String template) {

return null;

}

编译可以通过了，但测试是失败的，接下来我们来试着通过测试。

1. 在现有代码基础上进行修改

现有的代码已经可以把模板解析成片断了，所以可以在此基础上进行修改以实现我们想要的功能。我们可以先得到字符串列表，然后将列表中所有字符串转化为对应的片断。实际上，在Template类中我们已经有类似功能的代码了，可以试着重用。

目前我们已经有一个方法来辨别给定字符串是变量片断还是纯文本片断，不过这方法目前是私有成员方法。为了在TemplateParse类中也能访问到此方法，我们可以将其改为公有静态方法。先注释掉失败的测试，然后运行一遍所有测试以确认测试都能通过，再把isVariable方法变为公有静态方法。重新运行一遍测试，全都通过了，表示修改没有问题。现在可以在Template类中调用这方法了。如代码清单3-12所示。

代码清单3-12　用来辨别给定片断是否为变量的工具方法

public class Template {

...

public static boolean isVariable(String segment) {

return segment.startsWith("${") && segment.endsWith("}");

}

}

现在我们可以用这个工具方法实现新parseSegments方法了。虽然这种依赖关系有问题，不过我们肯定能在稍后的重构中解决这个问题。

基于当前解析逻辑的新parseSegments方法如代码清单3-13所示。

代码清单3-13　将String列表转化为Segment 对象列表

public class TemplateParse {

...

public List<Segment> parseSegments(String template) {

List<Segment> segments = new ArrayList<Segment>();

List<String> strings = parse(template);     // 将template解析到String片断

for (String s : strings) {

if (Template.isVariable(s)) {     /*（以下6行）将所有String转换为Segment对象*/

String name = s.substring(2, s.length() - 1);

segments.add(new Variable(name));

} else {

segments.add(new PlainText(s));

}

}

return segments;

}

}

添加恰当的equals实现，新测试就能通过了。如代码清单3-14所示。

代码清单3-14　两个Segment类的最小实现

public class PlainText implements Segment {

private String text;

public PlainText(String text) {

this.text = text;

}

public boolean equals(Object other) {

return text.equals(((PlainText) other).text);

}

}

public class Variable implements Segment {

private String name;

public Variable(String name) {

this.name = name;

}

public boolean equals(Object other) {

return name.equals(((Variable) other).name);

}

}

equals()及hashCode()

代码清单3-14中虽然有了equals方法，但并没有实现hashCode方法。敏锐的读者可能会觉得这样有问题。在Segment类中添加hashCode方法其实很容易，不过当前测试并没有要求这项功能。如果要添加这个方法，需要先用测试明确表明我们的意图，然后再动手实现。虽然我们现在不会那样做，不过这里有个不错的工具类可以供我们使用。这个工具类叫作EqualsTester，它是GSBase JUnit扩展（http://gsbase.sf.net）的一部分。这个类可以彻底地测试equals和hashCode方法。

所有测试都通过了，现在可以试着替换Template类的实现了。不过在修改Template类之前，我们先试着把一些功能添加到新创建的两个Segment类中吧。

2. 把逻辑重构到对象中去

该如何消除那难看的的if-else呢？我们可以使用多态把不同逻辑封装到对应类型的片断对象中。此方法会从包含模板变量值的Map对象中取出值，然后渲染模板片断。下面来为Segment接口添加evaluate方法吧：

public interface Segment {

String evaluate(Map<String, String> variables);

}

首先实现纯文本片断。相应的测试很简单，如代码清单3-15所示。

代码清单3-15　纯文本片断的测试

public class TestPlainTextSegment {

@Test

public void plainTextEvaluatesAsIs() throws Exception {

Map<String, String> variables = new HashMap<String, String>();

String text = "abc def";

assertEquals(text, new PlainText(text).evaluate(variables));

}

}

这种典型的小而集中、测试先行的步骤，通常对应的实现也比较简单（见代码清单3-16）。

代码清单3-16　为纯文本片断实现evaluate()方法

public class PlainText implements Segment {

private String text;

public PlainText(String text) {

this.text = text;

}

...

public String evaluate(Map<String, String> variables) {

return text;     // 但愿所有的编程工作都这么简单！

}

}

纯文本片断已经实现了，轮到变量片断了。代码清单3-17中的测试用于验证Variable对象能够从Map对象中取出变量值并渲染自己。

代码清单3-17　测试变量片断

public class TestVariableSegment {

@Test

public void variableEvaluatesToItsValue() throws Exception {

Map<String, String> variables = new HashMap<String, String>();

String name = "myvar";

String value = "myvalue";

variables.put(name, value);

assertEquals(value, new Variable(name).evaluate(variables));

}

}

实现依旧很简单，如代码清单3-18所示。

代码清单3-18　变量片断的测试

public class Variable implements Segment {

private String name;

public Variable(String name) {

this.name = name;

}

...

public String evaluate(Map<String, String> variables) {

return variables.get(name);     // 变量也没那么难渲染

}

}

测试都通过了。现在我们有了两个不同类型的片断，它们可以用给定的模板变量Map正确的渲染自己。终于可以替换Template类的实现了。

3.3.2　替换实现

有测试作为保护网，我们可以放心的改变Template类的evaluate方法了，见代码清单3-19。

代码清单3-19　替换Template实现

public String evaluate() {

TemplateParse p = new TemplateParse();

List<Segment> segments = p.parseSegments(templateText);     // 将template解析到Segment对象

return concatenate(segments);

}

private String concatenate(List<Segment> segments) {

StringBuilder result = new StringBuilder();

for (Segment segment : segments) {

result.append(segment.evaluate(variables));     // 让Segment对象自修正

}

return result.toString();

}

有了TemplateParse类以及能够渲染自己的Segment对象，改动就很简单了。有趣的是，运行测试后，我们发现有重要的功能被遗漏了。

1. 被安全网所挽救

Template类的新evaluate方法工作完全正常，但是遇到缺失的变量值后不再会抛出异常！幸亏我们有测试，不是吗？

我们来补一个测试吧。应当在哪里为缺失的变量值抛出异常呢？Variable类？因为类会同时访问模板中包含的变量名以及所有已赋值的模板变量。Variable类的完整测试见代码清单3-20。

代码清单3-20　用于验证缺失变量值的测试

public class TestVariableSegment {

private Map<String, String> variables;     // 将代码副本重构入夹具中

@Before

public void setUp() {

variables = new HashMap<String, String>();    // 将代码副本重构入夹具

}

@Test

public void variableEvaluatesToItsValue() throws Exception {

String name = "myvar";

String value = "myvalue";

variables.put(name, value);

assertEquals(value, new Variable(name).evaluate(variables));

}

@Test

public void missingVariableRaisesException() throws Exception {    /*（以下10行）为缺失的变量补测试*/

String name = "myvar";

try {

new Variable(name).evaluate(variables);

fail("Missing variable value should raise an exception");

} catch (MissingValueException expected) {

assertEquals("No value for ${" + name + "}",

expected.getMessage());

}

}

}

若没有那第二个测试，我们很可能会无意间改变了模板引擎的外部行为。多亏有了测试，我们尽早发现问题并纠正了错误。代码清单3-21中为修正后的代码。

代码清单3-21　Variable类在渲染时检查缺失值

public class Variable implements Segment {

...

public String evaluate(Map<String,String> variables) {

if (!variables.containsKey(name)) {      /*（以下4行）检查到缺失值时发出异常警告*/

throw new MissingValueException(

"No value for ${" + name + "}");

}

return variables.get(name);

}

}

运行一遍测试，一切正常，除了……

2. 删去无用的代码及进一步清理

用Segment对象替换Template类旧实现后，我们会发现不少方法已经没用了。删除这些代码会让工作变得更轻松，不是吗？在清理包括那难看的if-else块等无用的代码时，我们注意到了isVariable方法。当初为了能在TemplateParse类中访问到此方法，我们将其改成了公有静态方法。而目前只有TemplateParse类才会用到这个方法，所以我们可以将其移到TemplateParse类中，变为TemplateParse类的私有方法。

这一部分内容很多，不过学习效果不错！我们的代码质量变得更好了，类之间的责任划分也更加清楚了。

不过在完成模板引擎这件作品之前，我们还有很多工作要做。首先，因为其他类不再会访问TemplateParse类那种基于字符串的解析方法，所以可以将其改为私有方法。另外，既然没有人会再使用旧的解析方法，我们可以继续简化TemplateParse类，直接把PlainText和Variable对象加入列表中，而不是先把字符串加入列表中，再把这些字符串转化为片断对象。有了完备的测试做保护，这些工作应该都是小问题。就当作是留给你的重构练习题吧。

现在，让我们结束本章，继续下一个话题。关于TDD，我们还有很多知识点需要讨论！

3.4　小结

在本章中，我们用TDD技术开发出了一个非常稳定的模板引擎，而且花费的时间不比实现设计的方式长。此外，我们有了充足的测试覆盖到代码的每个分支，在添加新功能时不用担心破坏掉现有功能。

不过模板引擎不算复杂，因为我们在项目中写的大部分代码都不是如此的独立。不过不用担心，在下面的章节中我们会学到各种解决这类问题的技巧。在第二部分中，我们会遇到许多实际工作中的问题，比如复杂的API及各种麻烦的技术，等等。

虽然我们现在对TDD充满热情，但是要知道TDD并不是万能的。TDD能帮我们写出运行良好的软件，不过我们还是要想办法保证交付的功能正好能满足客户的需要。在第三部分中，我们会把测试先行及TDD的概念应用到用户故事、功能以及需求等更高层的方面。我们将这种技术称为验收测试驱动开发。

剩下的内容还不少。现在开始第4章吧，我们会介绍更多的技巧，更深入地理解TDD技术。

第4章　TDD的概念与模式

我记得模式是和电视机同步发展的，最初只是个测试模式，偶尔需要写一点儿程序。

——弗朗西斯·福特·科波拉

在第2章和第3章中，我们学习了如何用TDD开发模板引擎。我们专门挑选了模板引擎这个简单的例子。这例子相当理想化，实现过程中不会遇到太多困难。在本章中，我们将会学到许多不同的模式和技术。老练的TDD开发者都用这些模式和技术清除前进的障碍。这些模式涉及的面很广，从如何使产品代码的可测试性更佳，到组织和运行测试的各种方法等，都有涉猎。我将会向你们展示一些独门秘籍。

我们首先会讨论如何编写测试，在此过程中，会谈到许多选择测试的技巧，以及一些需要牢记在心的大体准则。我们也会讨论三种有略微差别的通过测试的技巧。这些技巧十分关键，重要性甚至不亚于编写测试。学习完如何编写测试及通过测试的技巧后，我们将会讨论一些重要概念，包括“夹具”（fixture）、“测试替身”（test doubles），还会讨论到“基于状态的测试”（state-based testing）与“基于交互的测试”1（interaction-based testing）的区别。我们还会重点讨论如何用测试替身处理难缠的协作对象。我们也将会探究三种不同的测试替身，包括伪实现(fakes)、测试桩（stubs）及模拟对象（mock object）。

1 或称behavior-based testing，基于行为的测试。——译者注

讨论完测试相关的概念后，我们将会转而讨论设计问题。首先，我们将会提及四条准则，用于提高设计的可测试性。然后，我们将会研究一些与单元测试相关的模式，涵盖了从不同类型的断言到如何在较高层面上组织测试等一系列知识点。

在本章结尾部分，我们将会讨论如何在遗留代码基础上进行TDD。这些遗留代码在设计时通常都没有考虑到可测试性，通常也不会有现成的测试套件（test suite）。

本章内容很多，讲解的速度也会很快。不过不用担心，若步伐太快，我们会停下来小憩。现在，我们先来讨论如何编写测试。

4.1　如何编写及通过测试

正如生活中的很多事情一样，编写测试以及测试驱动说起来容易做起来难。新手上路，第一个问题就是从哪个测试开始写起，这可不是很容易选择。此外，整个TDD周期中每一步的质量都与开发人员有直接的关系，测试的目标，测试的组织方式等各个元素都会影响到质量。

为了帮助新手顺利渡过第一关，我们将会讨论许多测试选择技巧，这些赋有启发性的方法可以帮我们选择测试。我们还会讨论到测试驱动的整体原则。这些重要的准则必须要严格遵从，其余的可以根据自己的喜好及习惯进行调整。

首先，我们来学习一些有趣的测试选择技巧。

4.1.1　测试选择技巧

如果你打算买个新的数码相机，你该如何在众多的相机中做出选择？如果你和我一样，那么你可能会花几个小时来浏览网页和相机杂志，阅读各种评论，比较技术特性和价格，从美学角度衡量相机，在许多同样优秀的相机之间犹豫不决。没有哪个简单直接的相机特性能帮我们作出决定。

选择测试也是如此。在衡量待选测试的各个特征时，我们需要依靠直觉，也需要注意收集各种带有启发性的线索。首先，下面四个技巧可以帮我们选择下一步的测试：

- 深入细节与整体考虑

- 探索未知与轻车熟路

- 最大限度地获取价值与摘取现成的果实

- 走通基本功能路径^1（happy path）与先处理出错情况

1 http://en.wikipedia.org/wiki/Happy_path。

下面讨论这四个测试选择技巧。

1. 细节与整体

我们都曾在计算机科学的课程上学到，树的遍历有多种方法，如深度优先遍历或广度优先遍历。同样，我们可以沿着不同路径测试驱动出预想的设计和功能。

例如，我们可以先实现整个框架，而在其内部使用一个算法的伪实现。在整个框架都运转良好后再着手实现真实算法。或者可以从细节入手，先实现算法，准备好各种构建材料后，再开发整个结构。

我将这两种方法分别称为“细节优先”和“整体优先”。当然这两种方法各有优劣。细节优先有利于降低风险，例如“我们能够对用户提交上来的图片进行模式识别吗？”，不过从整体角度考虑，处理模式识别问题会耽搁总体进度。从另一方面考虑，整体优先能够很快的验证总体设计，同时不用过多操心细节。整体优先的劣势在于其推迟了细节方面的工作进度。

实际上，我们会对各种方法做出评估，通常会（但不总会，如下节所述）先解决我们最关心的问题。这样就引出第二个测试选择技巧——探索未知与轻车熟路。

2. 不确定与熟悉

减少不确定性是我们选择测试时要考虑的因素之一。除此之外，我们还可以先挑选自己熟悉的测试，稍后再处理不确定因素较多的测试。处理不确定性的好处简单明了，因为把未知转化成已知能有效的降低风险。

一直在我们熟悉的设计领域工作有什么好呢？那又不会带来任何新的知识和信息。这种工作方式有什么好呢？可是，付出并不总会得到回报。虽然降低风险很有价值，但是比起爬到高高的枝头探索未知的空间，有时摘下唾手可得的果实更加实惠^2。

2 毕竟摘几个苹果吃就不会那么饿了。

这又引出第三个测试选择技巧——高价值与现成果实。

3. 高价值与现成果实

回顾我们曾写过的测试，其中一些的工作量显然比其他的要多。同样，每个测试对整体功能做出的贡献也不尽相同。理想情况下，我们应当挑选那些工作量最少，回报却最多的测试。不过通常待选测试之间的区别并不是很明显，我们需要在付出回报比差别不大的各个测试间做出选择。

测试对象的基本功能还是测试对象对null输入的处理，是高价值与现成果实的一个典型例子。开发基本功能需要更多的时间精力，价值也更大。防御出错的功能、处理null输入，这些都很容易实现，但价值也小得多。

第四个，也是最后一个测试选择技巧，是关于基本功能与出错情形。

4. 基本功能与出错情形

通常，我会先设法完成基本功能，然后再处理出错情形，如第三方类库抛出的异常及非法的输入等。这样做的主要原因是出于价值的考虑。一个能够处理任何出错情况的异常健壮的系统，若不能提供基本功能，根本毫无用处。另一方面，若某个系统在网络出现问题时会崩溃，那么在崩溃前，至少还有一定使用价值。

话虽如此，有时候还是需要把基本功能放到一边，先处理所有的出错情况。若挨个处理出错情况相当自然并符合直觉，并且只有正确处理了所有错误情况后系统的价值方能得到体现，则应当使用这种策略。例如，我们需要实现一个类似于状态机的功能，或是某种既定的工作流，若其中任何一个环节出现问题，则会直接跳过余下的工作流逻辑。这时处理出错情况显得尤为必要。不过那怕是这种情形，基本功能也比错误处理更加重要！

现在我们已经学到了很多选择测试的技巧。不过，不必过多考虑哪个测试才是“正确”的选择，测试的选择并没有一个标准答案。你会逐渐消化那些测试选择技巧，使用起来也会更加得心应手。不过总体上，从简单的测试入手应该是个好主意。一旦完成了第一个测试，接下来的工作就会变得更有头绪了。

不过在选择下一个测试之前，我们需要先使第一个测试通过。让我们来看看该如何做。

4.1.2　实现技巧

正如有多种方法选择及编写测试，使测试通过也有多种途径。在著名的Test-Driven Development by Example一书中，Kent Beck列举了3种实现方式：伪实现（faking it）、三角法（Triangulation）以及显而易见的实现（obvious implementation）。在本节中，我们会详细介绍这三种方法。

先从最直接的方式讲起——伪实现。

1. 伪实现

写完测试时，我们并不总是清楚如何正确的实现功能使测试通过。这时候，我们可能会先伪实现某个功能，尽快回到绿的状态。可能是因为我们步伐过大，或是触到了系统内的某个“边界”，不过无论是什么原因都需要尽快通过测试，回到稳固可靠的状态。而伪实现功能比停留在红的状态好得多。返回硬编码的值可能是最简单的伪实现方法。你可能还记得，我们在上一章开发模板引擎时曾用过这种方法。

在伪实现某个功能后，我们可以很容易地切换到“三角法”模式，因为产品代码中绝不能包含硬编码的值，所以要想方设法清除掉硬编码部分，使用真实的实现。当然，为了达到目的，我们需要写一个新的测试。

2. 三角法

在第3章中，我们曾使用三角法来清除产品代码中硬编码的字符串。若我们还不清楚如何总结归纳出某段硬编码的应用逻辑，则可以用三角法向正确的实现逐渐演进。

“三角法”，顾名思义，正如电视中的警察们利用手机信号追踪犯罪嫌疑人。警察们从多点监测疑犯的信号进行三角定位。已知观测点的位置，警察们就可以在地图上标出两条线，线的交点则为疑犯位置。

我们在使用三角法时，当然不会监测信号，也不会在地图上做标记。我们每写一个测试，都会在一个维度上约束了可能的解决方案。当测试足够多时，如图4-1所示，测试就能有效地缩减解空间，三角定位出我们期望的实现。

图4-1　三角法能不断地缩减解空间，直到完全符合我们的期望

例如，在开发信用卡验证组件时，我们可能不知道如何组织类的继承结构以支持各种信用卡略微不同的卡号长度或验证规则^3。使用三角法，我们可以先验证一种信用卡，例如MasterCard。这种测试应该很容易写，相应的功能也不难实现。

3 大部分信用卡商家都用Luhn算法计算校验值，还会用一些固定的前缀来区分每个牌子。在Google中搜索“信用卡校验”以及“luhn算法”可以得到更详细的信息。

接下来我们可以选择另外一种卡，例如Visa，然后测试我们的组件能够正确的验证Visa和MasterCard，并可以区分这两种卡的差别。这些新测试会逐渐使得通用验证逻辑更加明显。轮到添加Diners Club卡时，我们也许就可以归纳总结出逻辑验证代码了。

3. 显而易见地实现

还好，通过测试的方法通常都显而易见。当然我们并不是说像硬编码返回值那样显而易见，而是指正确的实现通常都很容易。在这种情况下，我们大可以快速前进，直接做实现，而不用像三角法或者做伪实现时那样谨小慎微。

给定一个集合，依据条件求其子集等日常编码工作是显而易见的实现方法的例子之一。遇到这种情况，我们大可直接实现，看看是否能通过测试。如果测试失败了，则直接撤销修改。全部撤销，一点不剩。不会再试第二次！

在下一节中，我们将会学到一些非常简单的测试驱动准则。当你逐渐熟练地掌握TDD后，就可以多研究测试选择技巧了，不过这些简单的准则足以保证你在学习时不会走弯路。

4.1.3　测试驱动的基本准则

我曾经和我的朋友Bas一起开设过TDD的课程。在教学过程中，见到许多很有激情的开发人员努力想写出高质量的测试，试图高效地进行测试驱动，尽全力想要避免各种陷阱，但却迟迟不得要领。

一天，我们坐火车去某地开设课程。在路上，我们试着制定出一个简短的清单，列举出各项TDD原则。有了这个清单，即使没有资深TDD开发人在场指导，初学者也不会迷失方向。我们希望使清单尽可能短，以方便记忆。这份清单应当像一座灯塔，不仅能帮助人们启航，还能引导TDD开发人员安全的驶向高生产率的港湾。

最终，我们总结出以下几条指导准则：

- 绝不跳过重构

- 尽快变绿

- 犯错后减慢速度

下面我们来挨个讨论这些准则，看看它们有多么重要，以至于需要添加到这如此简短的清单中。

1. 绝不跳过重构

如果你还没考虑过把“重构”一词写在手背上，现在是时候了。我并不完全是在说笑。观察了许多初次使用TDD的团队后，我发现，重构不足是最大的问题。

若不能彻底地重构，代码中仍旧会存在重复，这基本上等于在自己的椅子上安装定时炸弹。我们能够清晰地记得TDD中的“测试”及“编码”阶段，但却很容易忽略亟待重构的代码坏味道。

因此，切记，不要跳过重构。如果你在和别人结对编程，那么你们可以互相监督，指出任何被对方疏忽的代码重复。并且，请把Martin Fowler的《重构》一书放在卫生间^4，随手翻阅。另外，学会使用IDE中的自动重构功能。这都是为你好，相信我。

4 Martin肯定不会介意的，毕竟他在乎的是研究工作。

如果我姿态太高，我道歉，不过全程遵循TDD周期相当重要。好的，第一个准则已经讨论完了，还剩两个要讨论。其中，第一个“尽快变绿”与TDD周期的编码阶段相关，先来讨论这个。

2. 尽快变绿

测试驱动时，我们要用最简单的方法解决手中的问题。不过这不是编码阶段所追求的目的，编码阶段应当试图尽快回到绿的状态。重构时再考虑优化设计。

你或许想大声诵读上一段文字。不用担心别人会把你当傻瓜，你只是在说实话。

写一辈子程序，即使采用了TDD，也难免会犯错。我们这第三个准则提醒我们出错后需要放慢前进的脚步。

3. 出错后放慢脚步

开发人员在实践TDD过程中会自然地逐渐增大步伐。不过有时候，我们前进步伐太大，以至于出现错误而前功尽弃。这时，我们应当意识到步伐已经太大了，修改已超出理解范围。我们需要小心行事，小步前行，并彻底地重构。就这么简单。到饮水机那弄杯水喝，休息休息，也不错。

这些准则当然不是成功测试驱动的参考大全。指导准则不能凭空创造成功，只有人才可以。即便如此，我希望这些准则能帮你顺利的提高工作效率，绕过困扰很多TDD新手的陷阱。

接下来我们将会讨论一些类似的准则及技巧用于提高设计的测试性。不过在那之前，先来了解一些测试的基本概念。这些概念我们曾遇到过，今后也会很常见。

4.2　重要的测试概念

法国政治学家Alexis De Tocqueville曾说过：“词汇表是政党丢弃的最后一样东西”1。软件开发工程师们也是如此。词汇表不仅是词组而已。词汇表能够帮我们有效地和同行进行沟通。好的词汇表还能够帮助理解概念所要表达的目的及本意。

1 见Alexis de Tocqueville，《旧制度与大革命》, ed. J. P. Mayer及A. P. Kerr（Doubleday, 1955）。

测试是TDD中的重要一环，很多相关概念值得着重讲解。下一步，我们首先会介绍“夹具”、“测试替身”等术语。然后会重点讲解测试过程中两种有用的验证方式。我们也会讨论这两种验证方式各自的适用范围，以及何时该结合使用。最后，会在4.3节中详细讨论测试替身。

现在，我们回到编程方面来，介绍夹具。

4.2.1　夹具是测试的上下文

在第2章中，我们把夹具定义为测试类中所有测试方法共有的初始条件。我们将会深入探讨这个概念，搞清楚为何夹具是必备要素。下面，首先讨论初始状态的各个组成部分及其成因。

1. 状态概观

迄今为止，我们都是在某类初始化方法中创建相互关联的对象以及构建出各个测试方法所需的夹具的。不过夹具的范畴不仅如此。

你可能还记得，在第2章中我们曾提到（假设选择JUnit作为单元测试框架），夹具包括测试类的初始化方法中实例化过的成员变量及其他配置好的状态。这“其他配置好的状态”指的是什么？

实际上是指执行初始化方法时整个运行时的状态（例如运行测试的Java虚拟机实例）。产品和测试代码中的静态变量，以及在加载类时静态初始化块（static initializer block）中创建的各种对象等，都属于“其他配置好的状态”。

例如，我们可能有一些平台相关类，加载类的过程中，会根据系统属性给平台类的相关属性值赋值。当运行测试时，系统的当前状态成了当前测试夹具的一部分，作为测试执行的初始环境。

归结其本质，夹具是整个运行时的状态，而并非仅指测试类的成员变量值，或相关对象的内部状态。

那么我们为什么需要夹具？我们又为何要重视夹具？为其劳心费神有何必要？这是因为它能帮我们消除重复，使测试更加清晰紧凑。

2. 夹具可消除重复

追求优良的设计，是重视夹具的部分原因。我们应当用产品代码的标准来衡量测试代码，没有重复可能是最关键的衡量标准之一。夹具把多个测试共享的状态移至一处，有效消除了重复。

正如各种设计准则，事无绝对。例如，盲目的消除所有重复并不总是件好事。虽然消除重复大体上正确，不过有时为了提高可读性，少量的重复也是可以接受的。

但是不能够走极端，光板夹具（clean slate fixture）是绝对要避免的反模式（anti-pattern）。光板夹具，指每个测试方法都从头构建出的夹具；各个测试方法的初始化过程毫无共性。这表明测试间要么存在大量重复，要么毫无内聚性。若是后者，则需要把测试类分成几个类。

虽然能够消除测试代码中的重复这一特点已经足够引起我们对夹具的重视，但是还有一大好处值得一提：恰当的夹具能够使测试更加紧凑。

3. 夹具使测试更紧凑

熟练的TDD开发人员很容易写出紧凑的测试。其要诀是，利用夹具设置与测试相关的系统、对象，只用几行代码就可以完成验证逻辑。

测试方法只关注真正要测的东西，同时避免其他任何干扰，这样做的好处显而易见，因为我们不会被一叶障目而不见泰山。这样，我们不再反复阅读代码，企图弄清楚“那段逻辑”到底在哪儿。若夹具足够好，其本身就能直接给出上下文，这样测试代码就可以直指要害了。

若这些理由还不够充分，多从内聚性和复杂性角度保证夹具的质量，可以使测试的质量不断提高，也能使测试有效地表达出其所测试的点。相反，如果测试过于复杂，一周后可能就不清楚测试正确与否了。所以要善待你的夹具们。相信我，没错的。

关于夹具的介绍到此为止，接下来介绍另一个前面章节中并没有提及的关键测试概念——测试替身。

4.2.2　用测试替身替换依赖

测试中遇到的困难不少是因为被测对象和其他对象之间存在协作，或存在其他类型的依赖关系。例如某个类的构造函数参数是一个java.sql.ResultSet对象。Java.sql.*接口很难实例化，因为这些接口没有独立的标准实现。通常JDBC驱动厂商会提供这些接口的实现，而这些实现很可能会连接数据库。因此这类依赖通常很难处理。

如果要做TDD，想写自动化测试，我们经常会遇到这类问题。这是一个很棘手的问题。不过还好，大部分时候都会存在某种替代方法帮我们更容易完成工作，同时不牺牲质量、速度和效果，这种替代方法叫做测试替身。

我们可以用测试替身来替代真实对象。测试替身乔装打扮，使用者对此毫不察觉。无论从实际执行时间角度，还是从开发和维护测试所耗时间角度，测试替身通常都比真实对象更快。我们通常这样使用测试替身：首先创建测试替身（数量多少根据需要而定），然后配置测试替身的状态、行为和期望，最后把测试替身传给待测类，验证执行结果。

现在我们已经大体了解了测试替身。在深入探讨不同类型的测试替身之前，我们先来了解两种不同类型的测试。

4.2.3　基于状态及基于交互的的测试

在最高层面上，根据验证期望行为的方式不同，测试替身可分为两类：基于状态的测试以及基于交互的测试^2。下面分别进行讨论。

2 Martin Fowler在“Mocks Aren't Stubs”（http://www.martinfowler.com/articles/mocksArentStubs.html）一文中详细地讨论了基于状态和基于交互的测试。

1. 基于状态的测试

基于状态的测试利用对象内部状态来验证执行结果的正确性。我们需要获取待测对象及其协作对象的状态，然后与期望的状态做对比，进行验证。

为更好地理解基于状态的测试，请参考以下代码：

@Test

public void listShouldNotBeEmptyAfterAddingSomethingToIt()

throws Exception {

List<String> list = new ArrayList<String>();

assertTrue(list.isEmpty());

list.add("something");

assertFalse(list.isEmpty());

}

以上是基于状态测试的最简单的形式，测试中完全使用了真实产品代码中的类。用测试替身也可做同样的测试。例如，我们可以替换掉测试中真实的System.out，用PrintStream实现，而PrintStream会保存输出内容。这样，测试就能够读出输出的内容，以验证写入System.out中的内容是否与我们期望的一致。

有时，若夹具不大，而且不需要太多初始化过程，那么基于状态的测试能够很好的利用测试替身。特别当已经有了现成的测试桩可以利用，不用自己构建时，这个优势更加明显。不过有时候，我们更希望测试交互，而不是测试对象内部状态的变化。

2. 测试交互

基于交互的测试的验证方法截然不同。基于交互的测试验证待测对象与其协作对象以我们期望的方式进行交互，而非验证这些对象的最终状态是否匹配。换句话说，我们并不关心对象的内部状态，而更在乎待测对象是否正确调用了协作对象的方法，是否使用了正确的参数。如果可能，还需要验证调用顺序是否正确。

要实现基于交互的测试，需要使用动态模拟对象库（dynamic mock objects library）。在Java中，这种库有EasyMock^3、jMock^4以及rMock^5等。这些库都是开源的，用户也很多。

3 http://www.easymock.org。

4 http://www.jmock.org。

5 http://rmock.sf.net。

有了这些库，我们可以给定一个接口，然后指定期望的交互行为（方法调用），这时，库会返回给我们一个已经实现好接口的测试替身。我们将此测试替身传递给测试代码，在测试中，这个测试替身被称为模拟对象。运行完测试后，我们可以要求模拟对象验证实际交互行为与预期的是否吻合。附录C中有使用EasyMock API实现交互测试的例子。

自制的基于交互的模拟对象

我们当然也可以写出自制的模拟对象，用于记录方法调用的次数以及调用参数。即便如此，使用完整的模拟对象框架，也比用巧妙方法自制的静态测试桩所需的工作量少。那些框架通常也支持更复杂的功能。例如从字节码层面模拟具体类（而非接口），自己实现这些功能并非易事。

交互测试时有一点需要注意：如果交互变得过长或者过于复杂，那表明应该进行重构了。如果不重构，将会使测试变得很脆弱，对象之间交互行为上一些无关的变动，也可能使测试失败。这通常表明接口太大，行为过多，或者表明对象间的交互抽象层次不一致。

3. 各种方法的适用范围

正如你所看到的，模拟对象或基于行为的测试，通常比基于状态的测试更加繁琐。对于像这个例子一样的简单测试来说，这不足为奇。当测试更复杂，需要关注许多不同对象之间的交互，而非简单计算或状态变化时，不用自己构造一个基于状态的测试替身的好处就开始显现出来了。不过别担心，在第二部分中，我们有大量时间实践基于交互的测试方法。

基于状态的测试和基于交互的测试都很有用，两者结合使用才能够覆盖所有待测点。引用J. B. Rainsberger所著的《程序员实用测试技巧》（Manning出版社，2005）一书中的文字，“我们依赖基于交互的测试来验证待测对象如何与其协作类进行对话；用基于状态的测试验证对象如何做出回应”。换句话说，我们结合使用两种方法来回答下面的问题：“我正确使用了我周围的对象吗？”，“我对外部输入做出正确的回应了吗？我正确处理了其他对象的反馈吗？”

我们可以验证状态，也可以验证行为；每种方式各有优劣。下面，我们将进入测试替身的纷繁世界，讨论伪实现、测试桩和模拟对象的定义及分类。

4.3　近处观察测试替身

有时，我们并不希望在测试中使用真实的类，原因可能有几种：

- 太慢

- 目前尚不存在

- 其依赖的东西并不存在

- 很难实例化，为测试配置状态也不容易

这时，我们需要测试替身，即真实类的替代实现。下面来举例说明。

4.3.1　测试替身的例子

例如，有个方法需要com.acme.PricingService的实例来计算订单的总价。创建PricingService的实例可能并不困难，但是如果执行calculateDiscountedPrice(Order)却需要整整一秒钟，那会怎样？如果每个测试都花费几秒钟，那么我们肯定不会希望有太多测试使用真实对象了。一秒钟听起来并不多，不过若一个测试能够执行一整秒，那么其他测试也有可能花费同样多的时间，累积起来，时间就不短了。

使用真实对象还有其他的问题。例如，如果需要准备大堆测试数据才能使PricingService正常工作，那会怎样？如果PricingService需要数据库连接呢？或者PricingService尚未实现呢？一些不确定的行为也可能影响可测试性，例如主键的产生，或与时间相关的功能点。另外，在测试中使用真实对象，也很难模拟抛出异常的情形（例如，请设想用插拔网线的方式来模拟暂时的网络故障）。

代码清单4-1中演示了如何使用测试替身替代真实的PricingService实现。

代码清单4-1　测试替身的典型使用模式

public class PricingServiceTestDouble extends PricingService {     /*❶（以下9行）测试替身*/

private float discount;

public PricingServiceTestDouble(float discount) {

this.discount = discount;

}

public float getDiscountPercentage(Customer c, Product p) {

return discount;

}

}

public class OrderProcessorTest {

@Test

public void testOrderProcessorWithMockObject() throws Exception {

float initialBalance = 100.0f;     /*❷（以下11行）安排测试数据和伪实现*/

float listPrice = 30.0f;

float discount = 10.0f;

float expectedBalance =

(initialBalance - (listPrice * (1 - discount/100)));

Customer customer = new Customer(initialBalance);

Product product = new Product("TDD in Action", listPrice);

OrderProcessor processor = new OrderProcessor();

PricingService service =

new PricingServiceTestDouble(discount);

processor.setPricingService(service);

processor.process(new Order(customer, product));     // ❸ 调用操作

assertEquals(expectedBalance,

customer.getBalance(), 0.001f);     // ❹ 证实结果符合期望

}

}

简言之，代码清单4-1中的例子说明了如何透明地给待测对象传入❶PricingService的伪实现，从而避免了耗时的数据库连接等操作。比起飞快的伪实现，这些操作极其耗时，而且并不是我们测试的目的所在。相反，我们配置了待测对象❷，使其使用测试替身，调用待测对象❸，然后验证结果是否符合期望❹。

我们刚才见到了测试替身的一个例子。我们可以通过由伪实现、测试桩和模拟对象构成的简单分类表进行区分。

4.3.2　伪实现、测试桩和模拟对象

法国诗人雨果曾写道“未来有不同的名字：对弱者，未来遥不可及；对胆怯者，未来是未知；对勇士，未来代表机会”。测试替身也是类似。测试替身拥有不同的名字，各有其深意。尽管有其他的分类方法（而且可能和这个分类有些冲突），不过我试图给出个合理的整体定义，如表4-1所示。

表4-1　分类整理不同类型的模拟对象

Mock类型

描　　述

测试桩

测试桩实际上是给定接口最简单的实现。例如，测试桩内的方法通常返回硬编码的、无意义的值

伪实现

伪实现比测试桩更复杂，通常可以认为是接口的另一种实现。换言之，虽然伪实现并不是鸭子，但是其外形和行走方式和鸭子并无二致。相比之下，测试桩只是看起来像鸭子而已

模拟对象

从实现角度而言，模拟对象更加复杂。模拟对象可以验证待测对象与其协作对象的交互。由于具体实现方式不同，有些模拟对象可以返回硬编码的值，而有些能够提供逻辑的伪实现。模拟对象通常由框架或类库（像EasyMock）动态产生，不过也可以手动实现

刚才提到了，这只是伪实现、测试桩和模拟对象这几个术语的定义方式之一。你一定还会遇到其他的定义，可能与当前定义不完全吻合。这些定义可能来自Gerard Meszaro的网站xunitpatterns.com（参见“Test Double Pattnerns”）。不过从现在起，我会使用模拟对象和测试替身统称以上几种测试替身，除非具体类型在文中有特别含义。

伪实现和测试桩的定义相当直白，它们就是给定接口或类的替代实现。不过第三种测试替身则需要详加解释。我们或许应该试着动手使用模拟对象，来弄清楚表4-1中那复杂的定义到底是什么意思。

4.3.3　模拟对象实战

在代码清单4-1中，我们曾给OrderProcessor传入一个伪PricingService，因为使用真实的PricingService可能需要使用网络，也可能会很慢或者存在其他的问题。代码清单4-1中使用的测试替身属于伪实现，因为其使用了简化的计价算法，对所有的货物都给出十元的折扣。

我们用基于交互的测试方法重写代码清单4-1中的测试，演示复杂的模拟对象的动态自动验证的特性。测试中会使用模拟的PricingService实现。代码清单4-2中演示了如何用EasyMock^1库实现基于模拟对象的测试OrderProcessorTest。

1 通常我们会同时使用EasyMock及其扩展，这样EasyMock就能同时模拟具体类和接口了。

代码清单4-2　用模拟对象重新实现OrderProcessorTest

import static org.easymock.classextension.EasyMock.*;

import static org.junit.Assert.*;

import org.junit.*;

public class OrderProcessorEasymockTest {

@Test

public void testOrderProcessorWithEasyMock() throws Exception {

// arrange

float initialBalance = 100.0f;     /*（以下7行）为测试创建普通对象和数据*/

float listPrice = 30.0f;

float discount = 10.0f;

float expectedBalance =

initialBalance - (listPrice * (1 - discount / 100));

Customer customer = new Customer(initialBalance);

Product product = new Product("TDD in Action", listPrice);

// record expected collaboration with mock PricingService

PricingService mock = createMock(PricingService.class);     /*（以下4行）为PricingService创建动态模拟*/

expect(mock.getDiscountPercentage(customer, product))

.andReturn(discount);

replay(mock);

// act

OrderProcessor processor = new OrderProcessor();

processor.setPricingService(mock);      // 将模拟对象传给待测对象

processor.process(new Order(customer, product));

// assert

assertEquals(expectedBalance, customer.getBalance(),

0.001f);

verify(mock);      // 让模拟对象验证

}

}

代码清单4-2中的示例代码首先创建了几个普通对象，然后再为PricingService这个难处理的角色创建了模拟对象。在EasyMock中，所有的模拟对象，自创建时起就处于录制模式。在录制模式下，我们可以录制模拟对象与其协作对象的交互方式，以及模拟对象本身的行为。录制时，只需要调用模拟对象的方法，然后再告诉EasyMock模拟对象对该调用当做何反应，例如该返回什么值，或者该抛出何种异常。如代码清单4-2所示：

PricingService mock = createMock(PricingService.class);

expect(mock.getDiscountPercentage(customer, product)).andReturn(discount);

当录制完期望的协作及行为后，我们应当让EasyMock从录制模式切换到播放模式（replay），模拟对象开始监听各种事件。接着，我们把模拟对象传给待测对象，作为其协作对象。这时，待测类会与其协作对象交互，当然包括我们创建的模拟对象。当实际交互行为与录制的期望行为不符时，测试会失败。

最后，我们用模拟对象作为协作对象执行测试代码，若模拟对象没有因遇到非预期的调用而抛出任何异常，那我们就可以让模拟对象验证是否所有期望的调用都发生了。

测试替身的介绍就此为止。在讨论遗留代码基础上使用TDD前，我们先来谈谈设计，看看这些准则如何帮我们造出可测试性更佳的设计^2。

2 毕竟我们不想给接手我们工作的人留下不好的代码，是吧？

4.4　提高设计的可测试性的准则

知道各种类型的测试替身，确实有利于测试代码的维护工作，也可以提高其执行速度。不过这只是提高可测试性的一方面。伪实现、测试桩和模拟对象，或基于状态测试、基于交互测试等，都是解决测试技术问题的基本工具。虽然技多不压身，不过如果从一开始就拿出个好设计，避免各种亡羊补牢的办法，岂不是更好吗？

在用测试驱动的方式写代码时，我们实际上是在做设计，而这些设计会直接影响这些代码将来的友好程度。在编码前先写测试确实能够提高代码的可测试性，但若我们知道哪些设计会提高可测试性，而哪些设计只是饮鸠止渴，也会很有帮助。例如，当我们知道Singleton模式^1通常会影响可测试性，那么在写测试时就可以避免把代码向Singleton模式上驱动。

1 http://en.wikipedia.org/wiki/Singleton_pattern。

为了帮您避免可测试性问题，这里列举了一些值得注意的设计准则：

- 尽量使用组合（composition）而非继承（inheritance）

- 避免使用static关键字，以及Singleton模式

- 隔离依赖（Isolate dependencies）

- 注入依赖（Inject dependencies）

这份清单当然没有包括所有能提高可测试性的设计准则，不过这四条准则确有成效。一起来研究一下这些准则如何？

4.4.1　尽量使用组合而非继承

如何从各个独立的功能构建出复杂的对象，对代码的可测试性影响很大。在面向对象语言中，继承以及组合可以用来实现此类功能。

继承是指子类从父类继承功能，如图4-2所示。继承是继承体系中重用功能的传统做法。虽然这方法不错，但是继承在设计的可测试性、可维护性以及复杂性方面都有负面影响。

特别是在测试中试图实例化对象时，对象的继承体系有时会带来不必要的麻烦。例如在Java中，我们可能只关注子类的特征，但却必须要提供只有其父类构造函数才需要的各项参数。如果这些参数本身又是很复杂的对象，需要费很多功夫才能初始化，那这项缺点就更加明显了。

图4-2　子类继承了父类的所有功能，不过这使得实例化子类变得有些麻烦

另外，哪怕是极小的修改所产生的影响，都可能在整个继承体系内产生较大的影响，显然这样并不好。继承带来的限制过多，使测试很麻烦。这时，我们不得不考虑另一种替代方法，即组合，如图4-3所示。

图4-3　使用组合能够更灵活的重用类功能。例如，我们可以实例化一个组合的对象，而将其组成部分替换为更容易测试的对象

组合是指通过组装一些稍简单的类来获得一个功能复杂的大类的过程。顶层的组合对象会把工作委托给其各个组成部分，而不是通过调用父类的方法来完成工作。实际上组合是基于对象级别的责任划分，而不是静态的、类级别上的划分。组合比继承稍显麻烦，代码量更多，不过组合能够提高可测试性、适应性以及可维护性，这些优点远大于多出几行代码所带来的麻烦。

4.4.2　避免使用static关键字以及Singleton模式

静态方法调用及Singleton模式也会影响可测试性。取决于待测代码与静态方法或Singleton模式的纠葛程度，在测试中用测试替身替换静态方法可能极其困难。或许很多时候我们用不着替换掉 Singleton或静态方法，但如果想这么做（例如在测试过程中，静态方法或者Singleton实例会试着连接远程服务器），可没那么容易。

图4-4中待测类ClassUnderTest调用了静态方法，或者使用了Singleton。

图4-4　很难用伪实现替换静态方法，因为类的类型信息已经硬编码在代码中了。同样，用getInstance()方法获取的Singleton类也很难用伪实现替换

其实Singleton模式本身并没错，而是其默认实现方式存在问题。有时，我们需要在测试过程中替换掉静态方法或者Singleton的实现。要完成替换，我们需要有一个静态方法，用于替换当前实现，在测试完成后，还需要把替换过的实现恢复原样。

在这种情况下，我建议首先试着把静态方法变为成员方法。现代虚拟机创建对象的开销极低，所以通常只用删去static关键字，把静态引用变为成员方法的调用即可。

欲知静态变量及方法给测试性带来的问题，请参见代码清单4-3。在清单中的代码所描述的情形中，不可能通过简单的继承及覆盖来把方法替换成伪实现。

代码清单4-3　静态方法的可测试性问题

public class Database {

public static Object findById(String id) {

// fetch an object from the database,

// returning a null if the id is not found

}

public static boolean objectExists(String id) {

return (findById(id) != null);

}

}

public class TestDatabase {

@Test

public void testObjectExists() throws Exception {

// How can I fake findById() to return

// "true" or "false" as I wish?

assertTrue(Database.objectExists("123"));

}

}

在代码清单4-3所示的代码中，若不修改编译过的字节码是没有办法替换findById方法的，因为待测代码显式地引用了特定的实现。我们没法继承或覆盖这些方法，因为它们全都是静态的^2。

2 我们可以修改字节码，但是代价会很大。

使用Singleton模式通常是为了提供单个的访问点。虽然有单个的访问点是个好主意，但若在代码中四散着对这个点的访问就会有问题了。例如，代码清单4-4的代码调用了静态方法来获取依赖。

代码清单4-4　代码味道：通过调用静态方法获取依赖

public class OrderProcessor {

public void process(Order order) {

PricingService service = PricingService.getInstance();

// use the PricingService object for processing the order

}

}

这种方法并不好。我们把获取依赖与使用依赖的逻辑混在了一起。简言之，我们需要隔离依赖。

4.4.3　隔离依赖

为了能够方便地用测试替身替换依赖，隔离依赖使其更容易替换非常关键。有几种方法可以解决这个问题：把静态方法的访问移至成员方法，如代码清单4-4所示。多了几行代码，不过这样我们就可以覆盖那方法了，进而消除调用静态方法所导致的紧耦合问题。代码清单4-5为使用隔离依赖的示例。

代码清单4-5　已消除了代码味道：静态方法的调用包装进了成员方法中

public class OrderProcessor {

public void process(Order order) {

PricingService service = getPricingService();      // 通过替换获取依赖

// use the PricingService object for processing the order

}

protected PricingService getPricingService() {     /*（以下3行）覆盖返回的测试替身*/

return PricingService.getInstance();

}

}

在Working Effectively with Legacy Code^3一书中，Michael Feather定义了接缝（seams）的概念：“不用修改直接影响行为的代码就能改变系统行为的那个点”。换言之，在测试期间可以在某个点用一段代码替换另一段代码，而无需修改待测试代码，这个点就是接缝。在代码清单4-5中，从测试用例调用process方法的角度来看，getPricingService方法的调用实际就是接缝。接缝，根据其定义，由一个或几个入侵点（enabling points）构成，即入侵接缝的不同途径。在代码清单4-5中，getPricingService本身就是入侵点。我们可以使用代码清单4-6中的方法将其覆盖。

3 Michael Feathers，Working Effectively with Legacy Code（Addison-Wesley, 2004）。我极力推荐这本书。如果你想让写出的软件能经受得起时间的考验，那么就应该去读这本书。

代码清单4-6　入侵接缝

public class OrderProcessor {

public void process(Order order) {

PricingService service = getPricingService();     // 这就是接缝

// use the PricingService object for processing the order

}

protected PricingService getPricingService() {     // 入侵点

return PricingService.getInstance();

}

}

public class OrderProcessorTest {

@Test

public void testOrderProcessorByExploitingTheSeam()

throws exception {

OrderProcessor p = new OrderProcessor() {

protected PricingService getPricingService() {

return new FakePricingService();     // 通过入侵点使用接缝

}

};

...

}

}

我们刚才看到的接缝属于对象接缝，利用面向对象功能，在调用代码中透明的覆盖方法。还有其他多种类型的接缝（每种语言都不一样），如预处理接缝、链接接缝等。例如，在C/C++中，开发人员可以使用ifdef以及宏（macro）等预处理接缝。而Java开发人员可能把属性文件（property files）、XDoclet标签等配置元素与一些巧妙的Ant脚本技巧结合起来作为接缝使用。又如，可以利用LD_LIBRARY_PATH或CLASSPATH一类的环境变量，替换掉动态库或者JAR文件。

隔离依赖对可测试性及可维护性至关重要。在第二部分中，我们会遇见很多隔离依赖的例子。Michael Feather的著作Working Effectively with Legacy Code也详细讨论了这个问题，不过在继续讲解前，我必须先着重介绍一个非常有用的模式——反转依赖关系，让外部环境注入依赖，而不是在产品代码中自己查找依赖。这种方式能够极其有效地提高可测试性。

4.4.4　注入依赖

依赖注入（Dependency Injection，DI）4是近来软件圈子里的热门话题之一。这种代码组织方式可以减少直接依赖，将其变为间接依赖，或者说把getter变为setter。在把你弄得更糊涂之前，我们来用以前见过的一段代码解释这个概念，见代码清单4-7。

4 这个概念最初叫做依赖反转（Inversion of Control），表示依赖反转了。ThoughtWorks公司的Martin Fowler引入了依赖注入这个术语来更好的表达出概念的本意，即注入依赖而非查找依赖。

代码清单4-7　getter依赖

public class OrderProcessor {

public void process(Order order) {

PricingService service = getPricingService();     // 代码主动获得依赖

// use the PricingService object for processing the order

}

protected PricingService getPricingService() {

return PricingService.getInstance();     // 代码主动获得依赖

}

}

这段代码展示了如何隔离依赖，方便以后替换PricingService实现。这里的getter是指getPricingService的默认实现，使用PricingService的静态方法主动获得PricingService的实例。OrderProcessor类与PricingService及其getInstance方法存在耦合关系。虽然这种耦合关系并不会影响可测试性，只需要覆盖getPricingService方法就可以替换PricingService的实现，不过事情还可以变得更简单。

OrderProcessor依赖于PricingService，是指需要访问PricingService的一个实现，这并不意味着OrderProcessor需要知道如何获得这个实现。注入依赖可以解决这个问题，既能使OrderProcessor得到所需的依赖，又不会使其与PricingService过于亲密。图4-5表示反转获取依赖的过程。

图4-5　依赖注入反转了获取依赖的过程

简言之，依赖注入是指让OrderProcessor明确表示“我需要一个PricingService，使用我之前必须先满足我的需要”。具体实现见代码清单4-8。

代码清单4-8　用依赖注入解决紧耦合问题

public class OrderProcessor {

private PricingService pricingService;     // 用实例变量保存依赖

/**

* Hand me my dependency by calling this method.

*/

public void setPricingService(PricingService pricingService) {    /*（以下3行）让其他变量给出依赖*/

this.pricingService = pricingService;

}

/**

* Please call setPricingService() before invoking me. Thanks.

*/

public void process(Order order) {

float price = pricingService.getDiscountedPrice(order)     // 直接使用依赖

}

}

就这样。getPricingService方法魔术般的变为setPricingService方法，用成员变量保存注入的依赖。而且，process方法不再需要主动获得依赖，依赖早已准备好了，就在实例变量中！

如果你在想这种方法会对测试产生何种影响，请参照列表4-9。我们不用再覆盖getPricingServide方法了，待测对象的配置变得一目了然。

代码清单4-9　有了依赖注入，测试代码可读性更强了

public class OrderProcessorTest {

@Test

public void testOrderProcessorWithDependencyInjection()

throws Exception {

OrderProcessor p = new OrderProcessor();

p.setPricingService(new FakePricingService());

...

}

}

很好的办法吧？

虽然依赖注入使代码测试性大有改观，但也要付出一定代价。因为测试能够随意配置各种对象，测试代码比以前更清楚了。不过一旦考虑如何在真实的产品环境中处理这些关系，依赖注入带来的问题就显现出来了，某些人（或某些东西）必须把所有的依赖递给各个组件。

依赖注入的不同类型

代码清单4-8及代码清单4-9展示了所谓的setter-based 依赖注入，通过调用setter注入所需的依赖。不过这不是唯一的依赖注入方式。field-based依赖注入，及基于构造函数的依赖注入也很常见。

field-based注入实际和setter-based注入一样，唯一的区别是在field-based注入过程中，会直接给实例变量赋值，不通过setter，所以不能对注入的依赖进行任何操作。部分由于这个原因，field-based注入通常用在框架代码中，使用反射API给任意对象注入依赖，而不强求开发人员为纯依赖注入而写setter这种的样板代码。

基于构造函数的依赖注入是指用构造函数参数注入依赖。这种方法比setter-based注入方法要好，因为要使用setter-based注入，程序员必须要清楚以哪种顺序注入依赖。换句话说，用setter-based注入方法，可能会使目标对象处于未完全配置的状态。

基于构造函数的依赖注入方式也有不足，因为构造函数并不能体现在接口上，所以不能通过接口向对象注入依赖^5。另外，把所需的各种依赖作为构造函数参数，可能会使构造函数参数列表过长^6，含义模糊（这有可能是设计缺陷的象征）。

5 通过接口注入依赖并不是一个好办法，因为对象需要何种依赖完成自己的功能属于具体实现细节，并不应该体现在接口上。接口上只应该存在对外提供的功能，具体实现类上可以有各种setter用于注入依赖。——译者注

6 如果构造函数参数过长，可以考虑类的职责是否划分得清楚，因为若一个类需要太多依赖，说明这类做了太多的工作。此时可以考虑把相对独立的功能作为一个新类分出去，相应的，把构造函数中的依赖注入也挪至新类中。直到类的职责功能变得一目了然，这重构工作就算是完成了。——译者注

理论上，我们需要抽象工厂（abstract factory）或工厂方法（factory method）来做处理依赖。而实际上，我们可以利用控制逆转容器（Inversion of Control container）来做这项工作。这类容器可以有效帮我们处理依赖关系，集中配置各项依赖（通常使用XML或者属性文件等）。这类容器包括Spring框架中的BeanFactory^7、PicoContainer^8及它的兄弟NanoContainer^9。

7 Spring框架（www.springframework.org）是个相当流行的开源框架，提供了面向切面编程（Aspect-Oriented Programming）、Web应用的MVC框架、以及依赖注入等支持）。

8 PicoContainer （www.picocontainer.org）是第一个广泛应用的开源DI/IoC框架及容器。这个工具功能相当简单，只提供编程形式的配置，而且一个接口只能有一个实现。

9 NanoContainer （www.nanocontainer.org）也是开源的。其扩展了PicoContainer，提供了一些新功能。

在第二部分中，我们将会再次见到DI及IoC。在这之前，我想先谈些别的内容。首先，会提及单元测试的常用模式，讨论处理遗留代码这一棘手问题的几种不同办法。内容不少，赶紧开始吧。

4.5　单元测试模式

单元测试是TDD中的重要一环，所以了解单元测试的常用模式很有必要。在这一节中，我们将会了解不少模式，包括：

- 写断言的模式

- 组织及构建夹具的模式

- 测试类总体模式

这些知识点相当简单，连UML图都用不上，所以讲解起来会很快。先从断言的五个模式开始。

4.5.1　断言模式

断言对测试至关重要，若没有断言，测试就失去了本来意义。在第一部分中，我们已经看到了很多测试和断言，第二部分中还有更多。接触过单元测试及断言后，现在是时候给那些常用模式起个名字了。这里提到的模式都是基于Gerard Meszaros在xunitpatterns.com中的杰出工作的。在Gerard的网站上可以找到一些模式的变体和这些模式的更详细的描述。

现在我们来看第一个断言模式，结果状态验证。

1. 结果状态验证（Resulting State Assertion）

结果状态验证是单元测试中最常用的方法。这种方式是指先调用对象的功能，然后验证其内部状态与期望的是否一致，如下所示：

@Test

public void sizeOfListReflectsItemsAddedToIt() throws Exception {

List<String> list = new ArrayList<String>();

list.add("something");

assertEquals(1, list.size()); // state verification

}

测试验证了调用add方法后列表对象不为空，注意到了吗？严格的讲，这个测试没法保证正确性。如果size方法总是返回1测试也能通过。在这种情况下，虽然这个测试足以表达作者的本意，不过若能同时验证之前的状态就更好了，这就是防卫断言模式的作用。

2. 防卫断言（Guard Assertion）

防卫断言用来明确的验证调用功能前对夹具所做的各项假设。下面这个简单的例子表明了防卫断言的用途：

@Test

public void listIsNoLongerEmptyAfterAddingAnItemToIt()

throws Exception {

List<String> list = new ArrayList<String>();

assertTrue(list.isEmpty());   // guard assertion

list.add("something");

assertFalse(list.isEmpty());  // state verification

}

请注意，在调用add方法前，防卫断言保证了空列表的isEmpty方法正确的返回了true，确保所测试的确实是期望的行为。

防卫断言模式常常与结果状态验证模式一同使用。这两种方法常常结合在一起，首先验证调用前状态与期望的一致，然后调用功能，验证结果状态，与上面的例子完全一样。

不过有时候，使用防卫断言是为了保证夹具的初始状态的正确性。这时，可以把防卫断言移至初始化方法的末尾，因为这些断言实际上是为了验证初始化方法。

3. 差值断言（Delta Assertion）

有时在进行测试时我们需要在控制权不完全在自己手中的代码基础上工作。尤其是我们的测试可能并不能完全控制夹具。由此，在不能硬编码夹具状态的情况下，如何写出可靠、健壮及自检查的测试呢？解决方法是不要验证绝对值，而是验证代码执行前后的差值。

代码清单4-10展示了差值断言的基本逻辑。

代码清单4-10　差值断言模式的例子

public class TestAddingToArrayList {

private ArrayList<String> list;

// setup method omitted for brevity

@Test

public void sizeOfListReflectsItemsAddedToIt() throws Exception {

int sizeBefore = list.size();   // record the "before" state

list.add("something");

assertEquals(sizeBefore + 1, list.size());

// delta verification

}

}

这个例子很简单，但却很好地突出了这种模式的关键点：先记录下状态，执行完后把当前状态与保存状态做对比，验证差值是否正确。

不可否认，这种测试写起来更麻烦，不过比起结果状态验证，差值断言有独特的优势。这种方法更容易抓住本质，而不会把执行结果与某个貌似随意的值做比较，也不会让我们那可怜的同事努力地试图搞清楚为什么列表长度一定要等于某个魔术数字（magic number）。努力让测试清楚地表达出本意，这么做永远没错。这也是我们下个模式的目标。

4. 自定义断言

有时候用于验证期望的代码比调用待测对象所需的代码要多得多。这时（特别是当出现了不止一次时），可以从测试代码中提取出一个自定义断言，把复杂的验证逻辑封装进一个小巧的方法之中，以备测试代码调用。代码清单4-11中的代码是这种模式的一个小例子。

代码清单4-11　自定义断言方法的例子

public class TestMeetingCalendar {

@Test

public void timeslotsAreOnWeekdays() throws Exception {

MeetingCalendar calendar = new MeetingCalendar();

// omitted: add appointments to calendar until

//          end of office hours next Friday

Date time = calendar.nextAvailableStartingTime();

assertIsDuringOfficeHoursOnWeekday(time);

// encapsulate logic

}

private void assertIsDuringOfficeHoursOnWeekday(Date time) {

// actual assertion logic omitted for brevity

}

}

自定义断言的一个常见用途是做不同类型的模糊匹配。例如，我们可能希望用对象的一部分属性来比对两个对象。另外一个常见原因是对象并没有正确的实现equals方法，同时我们还不能对其进行修改。此外，创建自定义断言可以在验证失败后能够提供更有意义的错误信息（注释21：有意义的出错信息非常有用，特别是测试变得过大时）。

下面要介绍的模式与前面看到的有一定的差别，这模式叫做交互断言，是基于交互的测试不可或缺的一部分。

5. 交互断言（Interaction Assertion）

我们的最后一个验证模式称为交互断言。交互断言很有趣，其并不验证代码结果的正确性，而是验证代码与其协作对象的交互行为的正确性。代码清单4-12中的例子讲明了此问题。

代码清单4-12　交互验证的例子

public class TestExpectedInteractions {

private List<Customer> delivered;     // ❶用测试替身录制交互行为

@Before

public void setup() {

delivered = new ArrayList<Customer>();     // ❶用测试替身录制交互行为

}

private class MockCustomer extends Customer {

@Override

public void onPaperDelivery(DailyPaper paper) {     /*❶（以下3行）用测试替身录制交互行为*/

delivered.add(this);

}

}

@Test

public void paperBoyShouldDeliverPapers() throws Exception {

Customer david = new MockCustomer();

Customer esther = new MockCustomer();

PaperBoy paperboy = new PaperBoy();

paperboy.addToRoute(david);

paperboy.addToRoute(esther);

paperboy.deliver(new DailyPaper());

assertTrue(delivered.contains(david);     /*❷（以下2行）验证交互是否正确*/

assertTrue(delivered.contains(esther);

}

}

代码清单4-12中的测试类相当简单。我们❶为每一个测试创建一个空列表对象，用测试替身把交互行为录制到列表中。这样，我们就可以调用产品代码，让报童投递日报，然后❷用交互断言验证报童投递路线上的两个顾客都收到了报纸。

代码清单4-12中的测试替身实现类似于EasyMock等模拟对象库的内部工作机理。这些库把实际的方法调用及期望的方法调用记录到一个类似于列表的数据结构中，然后把期望的列表与实际列表相比较。值得一提的是，代码清单4-12的测试没有验证报纸是否真被投递了，只验证了报童骑着自行车在两个客户房前绕了一圈，因为我们确实没有验证投递的到底是什么东西！

在这个例子中，我们整个夹具都由自制的、基于交互的测试替身，即模拟对象所组成。实际上，若有必要，我们可以在一个测试中混合使用基于状态的测试方法及基于交互的测试方法。例如在代码清单4-12中那个报童的测试中，我们可以用交互断言验证报童正确的投递了报纸（正如代码清单4-12所示），用结果状态断言方法来验证报童走完投递路线后一份报纸都没有剩下。

断言部分就介绍到这里。现在我们回过头来看初始化及清除夹具的相关模式。

4.5.2　夹具模式

夹具是测试的重要组成部分。夹具的结构通常并不简单，我看到了不少巨大的夹具，用巨大的初始化方法创建出巨多的对象，这方法大到一屏都显示不下。无论在产品代码中，还是测试代码中，这种庞杂的代码都是个问题。还好，这些年来我们发现了不少模式，用于解决这类问题。

我们将会谈到3个此类模式。前两个模式关于在夹具中创建对象，第三个关于在测试结束时处理创建出的对象方法。准备好了吗？

1. 参数化创建方法

典型的夹具中的大部分对象是所谓的实体对象（entity objects），用来表示业务领域中存在的实体或者实际概念。这类对象通常有很多属性，我见到的大多数糟糕的夹具就是因为填充这些属性，尽管不少属性对当前测试根本不重要。

下面的代码示例展示了一个填充大量属性的初始化方法，其中不少属性对当前测试毫无用处。

@Before

public void setUp() throws Exception {

alice = new Person();

alice.setId(1L);

alice.setFirstname("Alice");

alice.setLastname("Adams");

alice.setSsn("111111");

billy = new Person();

billy.setId(2L);

billy.setFirstname("Billy");

billy.setLastname("Burke");

billy.setSsn("222222");

clark = new Person();

clark.setId(3L);

clark.setFirstname("Clark");

clark.setLastname("Cable");

clark.setSsn("333333");

alice.isInLoveWith(billy);

}

参数化创建方法（Parameterized Creation Method）可以解决此问题，把不重要的属性从初始化方法中移到单独的创建方法中。此创建方法接受变量的属性值作为其参数，而把常量或者随机值直接赋给不重要的属性。此外，参数化创建方法也可以填充那些对当前测试并不重要的属性，同时保证属性的合法性（例如唯一性）。

代码清单4-13演示了如何使用参数化创建方法。这样可以简化初始化方法，隐去不重要的属性。（在这个例子中，我们可以假设人的姓和名是测试的关注点。）

代码清单4-13　参数化创建方法的例子

public class ParameterizedCreationMethodExample {

private Person alice, billy, clark;

@Before

public void setUp() throws Exception {

clark = createPerson("Clark", "Cable");    /*❶（以下3行）在初始化方法中重要参数可见*/

billy = createPerson("Billy", "Burke");

alice = createPerson("Alice", "Adams");

alice.isInLoveWith(billy);

}

private Person createPerson(String firstName, String lastName) {

Person person = new Person();

person.setFirstname(firstName);

person.setLastname(lastName);

person.setId(UniqueNumber.next());     /*❷（以下2行）不重要的属性隐藏在创建方法中*/

person.setSsn(String.valueOf(UniqueNumber.next()));

return person;

}

@Test

public void aliceShouldAcceptWhenProposedToByBilly()

throws Exception {

billy.proposeTo(alice);

assertTrue(alice.isEngagedWith(billy));

}

}

从代码清单4-13中我们可以看出，使用参数化创建方法，只有❶重要的属性在初始化方法中可见，其余不重要的属性都❷隐藏在了创建方法内部。这样，创建及填充Person对象的过程不存在任何重复，我们可以立即理解初始化方法所做的工作，不用来回阅读代码。夹具的初始化工作都集中在了要点上。

参数化创建方法也会有特例，例如有时我们不在乎创建对象的具体参数，只要这些值合法，例如每个人的身份证号码必须唯一，且长度有效即可。另外，我们也可能用多种不同的创建方法来创建不同的对象，这在夹具中也很常见。这时就需要使用下面要介绍到的对象母亲（Object Mother）模式了。

2. 对象母亲

重构测试类，在类中加入创建方法从而消除重复，这种修改一开始效果会很好。不过不久我们就会发现不同类的创建方法间存在重复。接下来自然要把创建方法移到单独的类中以消除重复。对象母亲模式就是创建方法的聚合体。

实际上，正如Peter Schuh和Stephanie Punke在其文中所述^1，对象母亲是一个对象或者一组对象的集合，其特征如下：

1 http://www.agilealliance.org/system/article/file/910/file.pdf。

- 提供一个完整的业务对象，此对象所有的必填属性也是完整的业务对象；

- 在整个生命周期内都可以返回被请求的对象；

- 可以定制所返回的对象；

- 测试过程中可以更新对象；

- 如果有必要，可以在测试结束时回收对象及所有关联对象。

总的来说，对象母亲模式是一个复杂的对象工厂，用于创建领域对象（domain object）的整个对象网络（object graph），还可以创建出不同状态下的实例。此外，对象母亲也可以提供方法修改某个领域对象，例如在对象间建立关联关系、移除关联关系、或者把对象设置为特征状态。

除了可以消除测试代码中的重复，对象母亲模式还可以使TDD的初学者方便的获得需要的对象，这可以鼓励他们多写测试。若创建对象过于麻烦，他们也许会打消写测试的念头。

不少团队在开发过程中都会定义一套人物角色（personas）2，若把对象母亲与这套角色结合使用，效果会更好。例如，若团队定义了爱丽丝、贝利和克拉克等人物角色，每个人都代表交易系统中的不同角色，这时对象母亲应该把这些人物作为其API接口。这种概念上的关联可以帮我们更容易的编写测试，不用翻查API，更不用深入创建方法内部弄清楚该使用哪个对象做测试。我们只需要说：“好，我需要一个购买订单，这订单由克拉克提交，吉姆审批。”

2 Alan Cooper，The Inmates are Running the Asylum (SAMS, 1999)。

虽然对象母亲是个强有力的工具，能够有效的促进测试的编写，不过构建出整套对象母亲需要不少时间。所以，我建议小步重构当前的测试代码，起先可以引入紧凑的创建方法，最后把这些创建方法及其提供的测试数据整个移到对象母亲中。

还记得几页前提到的参数化创建方法吧？还有一个使用创建方法的原因我们尚未提及，它和自动清理（automated teardown）有关。

3. 自动清理

在测试框架（如JUnit）中之所以需要清理方法，是为了执行测试后做必要的清除工作，例如在集成测试后删掉数据库中保存的数据，或者移除测试过程中创建出的文件。假如清理逻辑很复杂，或者需要清除的对象过多，我们的测试代码会变得很混乱，也很容易漏掉某些需要清除的对象。这会使后续的测试出现问题，而且极难调试或跟踪出问题的源头。

自动清理模式可以解决这类问题。它把清除逻辑封装到一个单独的类中，把触发这类逻辑的代码与创建对象的代码放在一起，如图4-6所示。

图4-6　向注册表内注册一个夹具对象使我们可以很容易的在夹具清理过程中清理所有注册过的对象

可以看出，在自动清理模式中，夹具的初始化方法不仅仅创建出了夹具对象，而且把这些对象添加到测试对象注册表中^3。这注册表只不过是一个对象引用的集合，当夹具的清理方法触动注册表时，注册表会清除每一个注册了的夹具对象。

3 这两步通常都封装在一个创建方法中。

虽然自动清理模式在集成测试中应用最为广泛，不过这种模式有一个变体对单元测试也极为有用：用创建方发创建出可自验证的模拟对象，同时保存所创建对象的引用，将来可以在某个方法中统一调用。换句话讲，我们可以直接调用replayAll或者verifyAll等方法来验证所有模拟对象都交互正确，而不用挨个为所有模拟对象调用replay或verify方法。

迄今为止，我们已经见到了一些在细节层面上的断言模式和初始化及清除夹具的模式。现在该了解一些总体上编写测试的模式和技巧了。

4.5.3　测试模式

我们已经接触到了防卫断言到对象母亲等一系列测试相关模式。接下来我们会先学习一些更为通用的测试模式，然后是本章的最后一部分内容，即如何在遗留代码上工作。下面要介绍的这些模式主要是关于如何可使代码的可测试性更佳，以及使测试代码更紧凑，结构更好的Java语言技巧。

我们要介绍的第一个模式，参数化测试，主要用于编写数据驱动的测试，读取不同的数据集，重用同样的测试逻辑。

1. 参数化测试

有时，我们会发现我们编写的测试几乎一模一样，只有个别输入值不同，而测试逻辑完全相同。这时，我们或许可以把这类测试重写成参数化测试。

这类测试的基本要点是，只编写一个测试方法，包含应用于测试数据的测试逻辑。当然，显然还需要一个方法提供参数化数据，再用一些代码绑定给定的测试数据到测试方法上。

还好，JUnit4为此提供了很多便利，要创建参数化测试只需要给测试类加上相应的注释（annotation）即可。代码清单4-14是在JUnit4中编写参数化测试的例子。

代码清单4-14　在JUnit4中编写参数化测试的例子

import org.junit.runner.RunWith;

import org.junit.runners.Parameterized;

import org.junit.runners.Parameterized.Parameters;

@RunWith(Parameterized.class)

public class ParameterizedTest {

@Parameters      /*❶（以下2行）提供参数化数据*/

public static Collection<Object[]> parameters() {

Object[][] data = new Object[][] {

{ 0, 0, 0 }, { 1, 1, 0 },

{ 2, 1, 1 }, { 3, 2, 1 },

{ 4, 3, 1 }, { 5, 5, 0 },

{ 6, 8, -2 } };

return Arrays.asList(data);

}

public int expected, input1, input2;

public ParameterizedTest(int expected, int input1,

int input2) {     // ❷数据通过构造函数绑定

this.expected = expected;

this.input1 = input1;

this.input2 = input2;

}

@Test     /*❸（以下2行）每个对象数组调用一切测试方法*/

public void executeParameterizedTest() throws Exception {

assertEquals(expected, new Calculator().add(input1,

input2));

}

}

代码清单4-14中的参数化测试由三部分组成。首先，我们用一个标有@Parameters的静态方法提供参数化数据。此方法返回一个对象数组的集合，每一个数组都表示一个数据集或者一个测试实例❶。换句话讲，JUnit会为每一个对象数组都重新实例化一次测试类。JUnit❷会把对象数组作为构造函数参数传给测试类，通常测试类会把这个参数保存在内部变量中，在❸测试方法中使用。最后，JUnit会调用标有@Test的测试方法。这就是整个测试过程。

参数化测试模式（Parameterized Test Pattern）可以很好的用来实现数据驱动测试。一旦写好了一个类似于代码清单4-14中的架子，添加新测试就非常方便了，只要加新的对象数组就行。

不过，添加一个对象数组不是创建新测试用例的最佳方法。如果代码清单4-14中的测试，每一个测试用例都是一行代码的断言，那么可读性会好得多。确实，当测试数据量非常大，而且是从XML或ASCII文件类的外部数据源获得，那么使用参数化测试会很合适，解析外部数据源的工作可以交给标有@Paramters的方法做。这样，测试类难免会变得稍微有些复杂，不过好处是可以以更合适的语法和文件格式描述测试数据。

不过需要处理大量数据的测试毕竟是少数，我们接下来会讨论些更常见的东西。因为很多对象都和其他对象有依赖关系，或许接下来应该介绍自分流（Self-Shunt）模式。

2. 自分流

前面在4.3节中，我们讨论过几种不同的测试替身，不过没有提及自分流模式。自分流模式也是一种测试替身，同时也是我们的测试类。自分流模式首先由Micheal Feathers提出^4，这种模式是指在测试中，测试类本身充当测试替身。请参见代码清单4-15中的例子。

4 http://objectmentor.com/resources/articles/SelfShunPtrn.pdf。

代码清单4-15　自分流模式的例子

public class SelfShuntExample implements PricingService {     /*❶（以下5行）实现PricingService接口*/

@Override

public float getDiscountPercentage(Customer c, Product p) {

return 10.0f;

}

@Test

public void testOrderProcessorWithMockObject() throws Exception {

// some setup omitted for brevity...

OrderProcessor processor = new OrderProcessor();

processor.setPricingService(this);       // ❷ 将“this”传给待测对象

processor.process(new Order(customer, product));

assertEquals(expectedBalance, customer.getBalance(),

0.001f);

}

}

从代码清单4-15中可以看出，测试类实现了PricingService接口❶，这样我们就可以直接给待测类传入this❷，作为其协作对象，而不用重新创建一个测试替身，或者写出匿名类这样的凌乱代码。对于这种简单的情况，匿名类也是个不错的选择，不过随着方法数量的增多，匿名类代码也会变得越加凌乱，这时最好能使用自分流模式，或者单独的测试替身类。

要想在需要的地方快速创建出简单的测试替身，Shelf-Shunt是个绝佳的工具。不过随着测试替身的逻辑变得更加复杂，把这些逻辑移到独立的测试替身中会更合适。即便如此，我们要知道，独立的测试替身也有缺点。如果我们想在测试方法间或者测试替身间共享对象及数据，无间内部类（Intimate Inner Class）也许是个更好的选择。

3. 无间内部类

有时我们会想在测试类间或者测试替身间共享对象及数据。这问题有几种解决办法（例如添加个getter），无间内部类作为解决方法之一，可能并不很一目了然。不过这并不意味着这种方法不好。实际上，非静态内部类那能够读取和修改测试类成员变量的能力，可以使测试代码更紧凑，比起用getter暴露测试替身内部状态要好得多。

我们可以用一段代码展示无间内部类的作用。代码清单4-16中的示例代码测试的行为是，Server对象在初始化时会从ThreadFactory中获取一个Thread对象，停止时会挂起这个线程。解决办法是使用无间内部类，它将一个新创建的Thread实例赋给包含测试类的thread成员变量。

代码清单4-16　无间内部类的例子

public class IntimateAnonymousInnerClassExample {

private StartStopSynchronizedThread thread;     // ❶在测试类和测试替身间共享

@Test

public void

testStartingAndStoppingThreadsThroughAnExecutorService()

throws Exception {

Server server = new Server();

server.setThreadFactory(new ThreadFactory() {     /*❷（以下6行）无间内部类*/

public Thread newThread(Runnable task) {

thread = new StartStopSynchronizedThread(task);     // ❶在测试类和测试替身间共享

return thread;

}

});

server.start();

thread.shouldBeStartedWithin(1, TimeUnit.SECONDS);     // ❸测试即可访问共享域

server.stop();

thread.shouldBeStoppedWithin(1, TimeUnit.SECONDS);     // ❸测试即可访问共享域

}

}

可以看到，无间内部类❶使用了测试类中的thread成员变量❷，这样测试中就可以访问thread的成员变量做验证了❸。

从技术层面上，无间内部类可以实现为匿名类或者内嵌类。像代码清单4-16中的内部匿名类，可能会变得不好控制，但是它可以访问创建它的方法中的final变量。相比之下，内嵌类更清楚一些，不过数据访问上存在限制。两种方式都可以访问测试类的成员变量。

说到访问成员变量，下一个模式专门介绍如何访问那些本不该被访问到的成员变量。

4. 特许访问（Privileged Access）

有时候你可能想改变一点系统现有代码，以测试新添加的代码，但由于某种原因，不能够修改那些代码。这时，“侵犯”现有代码的隐私，通过反射（reflection）API直接读取内部数据，就可以绕过问题，写出测试了。

JUnit邮件列表^5的文件区有PrivilegedAccessor类的源代码，可以用来做这类工作。另外JUnit-Addons的开源库^6里包含PrivateAccessor类、Langhing Panda社区开发的BeanInject工具^7，也有类似的功能。例如用BeanInject中的Inject类，我们可以只用一行代码就把一个测试替身强行的赋给某个类的私有成员变量：

5 http://groups.yahoo.com/group/junit/files/src/PrivilegedAccessor.java（必须先注册才能访问）。

6 http://junit-addons.sourceforge.net/junitx/util/PrivateAccessor.html。

7 http://www.laughingpanda.org/mediawiki/index.php/Bean_Inject。

Inject.staticField("foo").of(LegacyCode.class).with(ourTestDouble);

若从一开始就使用TDD，那么这种访问其他类的私有变量的方法并不很有用，但是如果在那些编写时没有考虑可测试性的代码基础上工作，这些技巧和工具可以节约很多时间。但是如果我们可以改变遗留代码，那么就不要使用这些反射技巧了。下一个模式就是讲如何改变代码，提高代码的可测试性。

5. 额外构造函数（Extra Constructor）

计算机世界里面充斥着大段大段怪物般的代码。与这种代码一起工作，很容易头昏脑胀，因为要实例化一个类，可能需要准备一大堆其他对象。这问题很可能是因为没有正确地隔离依赖。当然相应的解决方法是恰当地隔离依赖，把整个架构往依赖注入方向引导，不过这需要花费很多时间。这时，可以使用额外构造函数模式作为临时解决方法。

因为我们没法从外部替换掉依赖，所以要从内部替换。因此，需要给待测类添加额外的构造函数，把依赖作为参数传入，保存到相应的成员变量中，替换真实的依赖。

若原先的构造函数会创建或配置依赖（若不是，我们也可以把依赖配置代码移至这里），我们则可以修改原先构造函数，使其调用新构造函数，如代码清单4-17所示。

代码清单4-17　用额外构造函数暴露依赖的例子

public class LogFileMerge {

private URL logFileA, logFileB;

public LogFileMerge() {      /*❶（以下4行）添加额外的构造函数*/

this(new URL("http://server1/system.log"),

new URL("http://server2/system.log"));

}

LogFileMerge(URL a, URL b) {      /*❷（以下4行）测试所用的依赖*/

this.logFileA = a;

this.logFileB = b;

}

...

}

代码清单4-17中的例子很简单，不过却很好的演示出了额外构造函数模式的用途。在待测类LogFileMerge中，❶原有构造函数创建出两个硬编码的网络地址，赋给server1及server2。这里有两个问题，其一，我们不想让单元测试访问网络。其二，要确认两个服务器中都包含我们期望的Log信息可不是件容易的事。

因此，与其在原有的构造函数中直接给私有成员变量赋URL对象，不如引入❷额外构造函数，让原有构造函数调用新构造函数。让额外构造函数对其他包也可见，我们的测试就可以用其构造出LogFileMerge类，传入URL对象，指向本地文件等，而非网络地址。

再强调一遍，这种模式只是用来弥补非TDD方式开发出的不可测的代码。下一个，也是最后一个模式，同样是关于如何在这种遗留代码中进行TDD的。

6. 测试专用子类

违反单一职责原则（Single Responsibility Principle）8，是我编程生涯中常见的另一个反模式（anti-pattern）。假如我们正在测试驱动某个记账功能，需要一些复杂的信用卡处理服务。这些服务由同一个类提供，这类由公司内的其他团队开发。

8 http://www.objectmentor.com/resources/articles/srp.pdf。

假设卡号验证逻辑藏在一个protected方法后面，因此信用卡号的验证必须通过某个商业服务提供商才能完成，如下段代码所示：

public class CreditCardProcessing {

public boolean isValid(String cardnumber) {

return validationCodeMatches(cardnumber)

&& cardIsActive(cardnumber);

}

protected boolean validationCodeMatches(String cardnumber) {

// validation logic omitted for brevity...

}

protected boolean cardIsActive(String cardnumber) {

// access to merchant system's web service

// omitted for brevity...

}

}

假如由于公司制度和代码所有权等问题，我们无法修改此类。这种情况该如何处理？除了把验证逻辑复制到别处去测试，制造出大堆重复代码外，还有其他办法吗？

这时测试专用子类可以帮我们解决问题。在信用卡的例子中，我们可以创建CreditCardProcessing的子类，覆盖cardIsActive方法，让其总返回true。我们可以利用这个半真半假信用卡处理服务来测试其他部分的代码。只要我们不特别区分信用卡无效的缘由，这种方法就没有问题。

暴露行为或状态的子类是测试专用子类的另一个常见变体，在这种情况下，我们不需要修改类的工作方式，只是需要获得类的内部状态或者想要了解外部API被调用后其内部会如何运作。再强调一遍，若代码都是用TDD驱动出来的，代码很干净漂亮，那么这些方法都没用。只有在处理不怎么样的遗留代码时这些方法才能派上用场。

说到糟糕的遗留代码，我们在下一节中将进入遗留代码的世界，讨论在没有测试的代码及糟糕设计的代码包围的情况下如何进行TDD。

4.6　在遗留代码基础上工作

遗留代码通常是指其他人以前写的代码，说白了就是老代码。糟糕的老代码！遗留代码之所以不受欢迎，不是因为它的年代久远，而是因为遗留代码通常很难读懂，错误也不少，而且没法判定改动一处是否会使其他的某部分功能出现问题。基于这种想法，Michael Feathers在其《修改代码的艺术》一书的前言中，把遗留代码定义为“没有测试的代码”1。

1 Michael在一篇同名文章中首次提出了这个定义（http://www.objectmentor.com/resources/articles/WorkingEffectivelyWithLegacyCode.pdf），不过这个概念直到书出版后才变得流行。无论如何，我认为这个定义还是很不错的。

注意　如把遗留代码定义为“没有测试的代码”，那么如果非测试先行地写代码，就像是在写瞬时遗留代码。

或许我们中的大部分人都不是在理想状况下进行开发，而是在现有系统基础上进行维护，继续开发，或是与某个已经上线了几个月、几年、甚至几十年的系统上进行集成。

那么如何在遗留系统基础上进行测试驱动开发？在你预订Michael的那本遗留代码工作的圣经的同时，我会试着总结如何用测试驱动的方法更改遗留代码。

4.6.1　测试驱动遗留开发

在遗留代码基础上进行测试先行的开发，正如和项目或团队中其他根本不写测试的人一起工作一样。我们首先为要改动的代码编写测试，这可能要在没有测试做保证的情况下先移除某些依赖。这步骤将比平常慢许多。不过别灰心，移除这些依赖、补全测试，回报立竿见影。

Michael在书中阐述了在遗留代码基础上工作的过程，如图4-7所示。此过程试图把改变遗留代码变为一个严格的软件开发活动，而不是敲几下键盘然后开始对代码进行跟踪调试的随意行为。

图4-7所示的过程可以分为以下几个主要过程：

- 分析变化

- 准备变化

- 测试驱动变化

开始改动时，当前的代码已经很稳定了，我们要在这个基础上改变系统的部分代码。例如，我们可能期望添加个新的配置项，用于控制系统行为。从这个状态起，我们会历经3个阶段：分析变化，准备用安全、可控的方式引入变化，最后测试驱动该变化。最终得到包含新变化的代码。

接下来，我们将详细讲解每个阶段，讲述图中的5个步骤究竟是什么含义。

图4-7　在遗留代码基础上工作的过程

4.6.2　分析变化

当着手分析变化时，我们首先要确定变更点（change point）。变更点是指引入变化时需要修改的那部分代码。实际上无论是遗留代码还是其他类型的代码，改动前都需要先确定变更点，其主要的区别在于遗留代码比起用单元测试这“文档”详细描述过的类和方法更难理解。

当确定需要修改的部分后，我们就可以进而确定出测试点（inflection point或test point）。测试点是指我们所修改的代码的“下游”部分，是指变更点变化后影响到的其他部分代码，例如变更点周围的函数调用等。图4-8中描绘出了采用近距测试点的情形，在改动的代码附近进行测试。

图4-8　近距测试点通常能够紧密的覆盖变更点，能够较容易地详细测试到当前行为

不过有时，像图4-9那样在距离变更点更远些的地方找出测试点也合乎情理。外部系统的网络、变更点周围写入的日志信息等就是这种远距测试点的例子。有时甚至把系统的持久化数据源作为测试点都不为过。实际上就是在编写测试的容易程度及这些测试能够提供保障性之间做出权衡。

图4-9　远处的测试点，顾名思义，覆盖了更多代码，因此可能会消除在分析中漏掉的由变化所导致的副作用

近距测试点能够提供更贴近局部的检查点，在信号左右不会有太多噪声干扰。另一方面，远距测试点，则更可能找出分析中没有发现的其他副作用，不过也会导致编写测试时花费更多功夫，因为这时不会有变更点附近才能够得到的详细信息。

分析完代码，检查过变更点及测试点后，我们会很清楚哪些代码该做变动，以及该在哪里加测试。下一阶段，我们会用一种安全的、测试驱动的方式引入这些改动。

4.6.3　准备好变化

一旦确定好变更点及测试点，我们就可以为系统的当前行为特性添加测试了。我们可能要小心地移除依赖，暴露出依赖的接缝，方便以后测试。

我们编写的覆盖测试点的测试通常称为塑形测试（characterization tests），因为这类测试只是描述出当前功能，而不管这些功能是否正确。塑形测试通常也是学习测试，都是用来验证确定变更点时所作的假设。

一旦有了足够的塑形测试，就可以放心进入我们的测试驱动遗留开发过程的第三阶段了，即引入变更。

4.6.4　测试驱动变更

当我们围绕变更点及测试点的测试的覆盖率足以让我们放心时，就可以着手改动代码，添加新功能了。当做出改动时，塑形测试可能告诉我们这些改动是否引入了问题，新添加的测试会告诉我们新引入的功能是否工作正常。最后，改变完成，所有的测试都通过了，我们一如既往的重构代码，享受自动化测试带来的便利。

这就是在遗留代码基础上工作的大致过程。正规的TDD周期和上面描述的过程主要的区别在于我们必须先为已有的行为添加测试，再为新功能添加测试，而且我们会常常在没有安全网保护的情况下做出一些小的移除依赖的工作，以方便测试的编写。要更细心一些才行。

4.7　小结

在第一部分的最后一章中，我们了解了一些测试驱动开发人员的高度机密。这世界上没有银弹^1，它们只不过是些技术而已。现在它们已经躺在了我们的工具箱内，必要时可随时使用。

1 Fred Brooks搞错了。实际上是有银弹的，只不过没有狼人而已。

一开始，我们探讨了如何编写测试，介绍了一些测试选择技巧。有了这些技巧，选择测试时有了更多的依据。然后我们转而讨论最为基础的技术，如何使测试通过。这些技术有：伪实现、用三角定位法逐渐逼近正确的功能实现、及时常遇见的显而易见的实现。接着，我们简要陈述了测试驱动的三项准则：毫无保留的重构，尽快变绿，出了问题后放慢速度。

我们也讨论了几个重要的测试概念。我们把夹具定义为测试执行的上下文环境。我们研究了测试替身的多彩世界，学习了它的分类，包括：伪实现、测试桩及模拟对象。接着，我们比较了基于状态的测试及更为复杂的基于交互的测试。

通过深入地探讨伪实现、测试桩，特别是模拟对象，我们对测试替身的理解也更加细致。然后我们转而讨论提高可测试性等更高层次的准则。我们讨论了继承及组合，讨论了静态方法和Singleton模式的潜在问题。在讨论完用接缝解决依赖问题后，我们进一步讨论了依赖注入带来的好处。

在理解了测试的重要概念，及具有良好的可测试性的设计的准则后，我们回到了模式方面的讨论。这些模式不少源自Gerard Meszaros的xunitpaterns.com。我们探讨了单元测试的相关模式，从不同断言间的微小差别，到初始化及清除夹具的大家伙模式。

我们的模式乐园之旅正式结束啦。在旅程的最后，我们介绍了六个generic测试模式，其中不少是专为处理遗留代码而设计的。在本章的最后部分，我们讨论了处理遗留代码的技巧，这些技巧都是从Michael Feathers那里借来的。

无论使用的是什么技术，这些技巧基本上天天都会用到。在第二部分，我们将集中讨论如何在Java EE技术上应用TDD。Java EE技术威力巨大，不过要想在这平台下验证自己的代码是否编写的正确，则要费尽周折。或许这只是假象？一会儿便知。

狂野之旅即将开始，挺住！

第二部分　针对特定技术应用TDD

第一部分讨论了如何面向普通的Java代码进行测试驱动开发，另外也有选择地介绍了一些技术和方法。但在现实当中，只面对普通Java代码是不可能的。换句话说，我们会面对各种其他人所认为的“不可能做到测试驱动”的情况。显然，事实并非如此；第二部分将一一驳斥这些谬论，每次围绕一种技术来探讨。

第5章将学习在Java EE Web应用中实现测试驱动，即不局限于Java Servlets和Spring控制器这样的纯Java代码，还要涵盖用来生成标记的视图模板。第6章深入应用程序的底层，学习测试及以测试驱动方式编写访问数据的代码。不仅涉及使用JDBC存取和操作数据的代码，也包括使用Spring Framework的JdbcTemplate和常用的Hibernate对象关系映射框架的代码。第7章将进入更陌生一些的领域，学习如何测试并行及其他不易预测的代码。这一部分最后全面介绍了测试驱动Java Swing应用程序，也介绍了一些简化相应任务的设计模式。

本部分内容

- 第5章　测试驱动Web组件

- 第6章　测试驱动数据访问

- 第7章　测试驱动不可预测功能

- 第8章　测试驱动Swing代码

第5章　测试驱动Web组件

测试驱动Java EE Web组件可不容易。

—— 一句传言

在本章中，我们将会讲解用TDD在Java企业版（Java EE）平台下开发Web应用的方法，解决各种让开发人员头疼的问题。迄今为止，我们介绍的那些内容只是些简介性的材料，在一般的TDD培训班中即可学到。这些材料虽然能帮我们理解技术背后的理念，但在Java EE的环境下进行TDD可没那么容易。尽管理论上Java EE非常灵活，不过开发人员的工作可不轻松。读完这章后，我们对Java EE平台下的Web应用开发就不会有问题了。本章结束后，我们将掌握Java EE平台下Web应用开发的基本理论和工具。

Java EE拥有超强的灵活性，不过由于篇幅限制不能在此阐述得面面俱到，我们会尽可能详细讨论Java平台下Web应用中普遍使用的MVC（Model-View-Controller，模型—视图—控制器）设计模式中的V部分和C部分。如果你还不熟悉MVC模式，我希望你能够花时间读一下《J2EE核心模式》之类的关于模式的书。再强调一遍，有很多框架能帮我们实现控制器（Controller）和视图（View）。为了用最少的努力覆盖到最多的内容，我们会在这两方面各挑一个较好的技术进行详细介绍。

我们先会用纯Java Servlets技术实现控制器，体验一下不用框架做开发的感觉，然后再用Spring框架实现同样功能的控制器。通过对比我们就会知道有测试框架的支持后情况会有多大的改观了。

在视图方面，我们将会介绍如何在容器外测试驱动JSP视图。我们还会介绍另一种视图技术，Apache Velocity，看看不用部署到容器中就能测试的视图技术会使测试驱动变得多么简单。

最后，我们会介绍基于控件的框架（component-based frameworks），尝试在这种非请求—应答（request-response）式的框架下进行测试驱动。我们也会简单地介绍Wicket，一个开源的控件式的Web框架。

我刚才说过，本章内容可不少。不过我保证，读完本章后，会有不少人觉得自己更聪明了！

5.1　在60秒内介绍Web应用中的MVC

在测试驱动这些控制器之前，我们先来了解一下MVC模式。

MVC指Model-View-Controller，即模型—视图—控制器。这种软件架构（或设计模式）把面向用户的组件分成几个单独的部分，分别为负责领域逻辑和数据的部分（模型）、负责表示的部分（视图）和应用的控制逻辑部分（控制器）。原则上模型只负责业务逻辑及数据，视图只负责渲染模型以及与用户交互，控制器只负责根据与用户的交互来修改模型及选择需要渲染的视图。

Java EE Web应用中的MVC主要用于分离模型和视图，与原汁原味的MVC并不完全一样（MVC起源于Smalltalk社区和桌面图形用户界面领域）。控制器通常会给视图传入特殊的模型（即专为视图而造的模型），而不是真实的模型。

例如，要显示一份美国制造的汽车列表时，控制器会从模型中读取数据，产生一个列表，然后把这个列表传给视图，而不会直接把真实模型传给视图。如果我们想修改数字格式或者货币格式等数据显示格式，就可以直接在控制器中做相应改动，而不用修改视图。图5-1用框和箭头表述了这个概念。

图5-1　MVC各部分的责任划分得一目了然。当控制器收到一个HTTP请求时，请求的生命周期就开始。接着控制器会把任何关于业务逻辑和数据操作的工作都交给模型，然后从模型中获取必须的数据。第三步，控制器把数据填给应答（response）对象，最后把渲染的工作交给视图处理

这介绍超过60秒了吗？不好意思。我只是想让大家对控制器、模型和视图有共同的理解。现在，我们回到测试先行的编程方法上。因为MVC中的模型部分就是普通的Java代码，我们很清楚如何测试先行地编写这部分代码，所以将把重点放在控制器和视图的介绍上，有必要时才会提及模型。

我们先从控制器开始介绍，然后再谈视图。

5.2　控制器

在MVC架构中，控制器是应用程序和浏览器HTTP数据流间的“泵”。虽然控制器没有（或至少不应该）包含核心的业务逻辑，但是在Web应用的整体功能方面有举足轻重的地位。因此，我们有必要像对待模型代码一样仔细地用自动化单元测试覆盖控制器代码。

测试驱动控制器代码，或者为控制器写自动化测试等工作有时会很困难，这是因为控制器依赖了一些复杂的，或者难以模拟的API。这类API之所以不好模拟，是因为代码中不仅会使用直接依赖，还会使用间接依赖（所谓间接依赖是指通过直接依赖获取的依赖），所以要模拟接口时不可避免地要模拟整个体系。这就是AndyHunt和Dave在《程序员修炼之道》里提到的“最少知识原则”的设计准则。

Java Servlets是Java EE世界中控制层的基础，所以我们会先介绍Servlet，然后再讨论更高级的框架。由于Spring框架应用相当广泛，所以我们就把它当作高级框架的例子来分析吧。

5.2.1　测试驱动Java Servlets

Servlets是Java web应用的基础。虽然现在很少有Web应用会直接构建在Servlet API之上，而是会选择像Spring、WebWork、Struts、Tapestry、Wicket或JavaServer Faces（JSF）等支持绿场部署项目的框架，但还有大量的遗留代码用到了Servlet。

在开始测试驱动Servlet之前，我们先来了解一些基础知识。

1. Server API基础

先来看个简单的例子。代码清单5-1中的Servlet类把请求参数以纯文本格式回显，每个参数值占一行。

代码清单5-1　回显参数的Servlet示例

import javax.servlet.*;

import javax.servlet.http.*;

import java.io.*;

import java.util.Enumeration;

public class EchoServlet extends HttpServlet {

protected void doGet(HttpServletRequest request,

HttpServletResponse response)

throws ServletException, IOException {

response.setHeader("Content-Type", "text/plain");    /*（以下2行）应答对象生成响应*/

PrintWriter writer = response.getWriter();

Enumeration e = request.getParameterNames();    // 请求对象封装请求参数

while (e.hasMoreElements()) {

String parameter = String.valueOf(e.nextElement());

String[] values = request.getParameterValues(parameter);     // 请求对象封装请求参数

for (int i = 0; i < values.length; i++) {

writer.write(parameter + "=" + values[i]);     /*（以下2行）应答对象生成应答*/

writer.write("\n");

}

}

writer.close();

}

}

Servlet通常会有一两个公有方法，如代码清单5-1中doGet方法等。每个公有方法处理一种类型的HTTP请求，例如GET、POST、HEAD、PUT等。每个方法都以doXXX格式命名。尽管开发人员偶尔要用Servlet处理HTTP HEAD或者其他较少见的请求类型，大部分情况下都只处理GET和POST请求而已。Servlet的基类javax.servlet.http.HttpServlet会把HTTP请求映射到对应的doXXX方法上，如图5-2所示。

图5-2　HttpServlet类的service方法负责处理所有HTTP请求。我们的Servlet类会继承自这个HttpServlet类。HttpServlet的Service方法根据HTTP请求的类型把请求转发到相应的doXXX()方法上

代码清单5-1中的EchoServlet类只用doGet方法处理GET请求。若把其他任何的请求重定向到这个EchoServlet上都会出错。这样很合理，因为EchoServlet的作者并没有打算处理其他类型的请求。

我们来看看这些怪异的接口和类到底是什么东西。

方法的第一个参数HttpServletRequest封装了HTTP请求，其包含请求相关参数、HTTP头、及其他信息。请求对象也可以把数据保存在请求属性中，以此把数据从一个请求处理组件传递给另一个组件。例如我们想用Servlet对特定请求进行预处理时，可以把预处理后的中间结果保存到请求的属性中，然后再把请求转发给其他的Servlet继续处理。在代码清单5-1的doGet方法中，我们从请求对象中读出所有请求参数的名称，然后挨个获取参数值。

第二个参数HttpServletResponse主要用于给客户端回送应答。任何发回给浏览器的HTTP头都可以放到应答对象中。所有想输出的内容都可以传给应答中的PrintWriter对象和ServletOutputStream对象。例如在代码清单5-1中，Servlet把收到的参数都回写给了应答对象中的PrintWriter对象。

就这么简单，这些API其实并不复杂。下面来试着写测试吧。总的来说，在测试中我们需要模拟请求和应答。

2. 模拟请求和应答

如果我们要为刚才的Servlet写个测试来验证应答与期望是否一致，那么我们就要为Servlet提供HttpServletRequest和HttpServletResponse实例。代码清单5-2中为EchoServlet的测试。

代码清单5-2　EchoServlet的单元测试示例

import org.springframework.mock.web.MockHttpServletRequest;

import org.springframework.mock.web.MockHttpServletResponse;

public class TestEchoServlet {

@Test

public void testEchoingParametersWithMultipleValues()

throws Exception {

MockHttpServletRequest request =

new MockHttpServletRequest();    /*❶（以下6行）创建并填充模拟对象*/

MockHttpServletResponse response =

new MockHttpServletResponse();

request.addParameter("param1", "param1value1");

request.addParameter("param2", "param2value1");

request.addParameter("param2", "param2value2");

new EchoServlet().doGet(request, response);      // ❷ 用模拟指行代码

String[] lines = response.getContentAsString().split("\n");     /*❸（以下7行）从应答对象中获取结果*/

assertEquals(

"Expected as many lines as we have parameter values",

3, lines.length);

assertEquals("param1=param1value1", lines[0]);

assertEquals("param2=param2value1", lines[1]);

assertEquals("param2=param2value2", lines[2]);

}

}

看看代码清单5-2，情况似乎并不太糟，是吧？我想是的，特别是在MockHttpServletRequest和MockHttpServletResponse类的帮助下^1。我们创建了两个测试替身❶，然后填充进去一些必要的请求参数、HTTP头及请求的URI等❷，来模拟浏览器发起的请求。以模拟的请求和应答对象当参数调用Servlet后，我们只要❸从应答对象中获取结果信息进行验证就行了，和其他类型的JUnit测试一样。

1 在代码清单5-2中用到的模拟对象的实现是由Spring框架引入的，在网上还有很多免费的模拟对象库。

有了第三方提供的测试替身来模拟请求对象和应答对象，我们就可以测试Servlet了（当然不一定必须由第三方提供，自己写一个也不难）。不过再强调一遍，刚才这个Servlet相当的简单。如果要测试现实中的更复杂的Servlet，情况可就大不一样了。因此，我们最好找个比EchoServlet更贴近真实的例子来练习。

3. 测试重定向

让我们想想什么样的Servlet的例子既够“真实”，又能很好地体现测试先行开发方法的特点？用于登录的Servlet怎么样？假设这个Servlet首先需要验证登录信息，然后判断用户最后一次修改密码是否在90天以前，若是就重定向到“修改密码”页面，否则直接进入首页。怎么样，听起来不错吧？动手吧！

我们首先该测什么？我想先测登录失败的情形，因为这比较容易。我们可以验证密码错误时LoginServlet会拒绝用户登录并显示错误页面。可以这么实现：

MockHttpServletRequest request = new MockHttpServletRequest();

MockHttpServletResponse response = new MockHttpServletResponse();

HttpServlet servlet = new LoginServlet();

servlet.service(request, response);

assertEquals("/invalidlogin", response.getRedirectedUrl());

我们想模拟一个请求，然后验证请求会被重定向到了出错信息页面。HttpServletResponse的伪实现能帮我们实现这个测试。

我们还要能模拟用户的登录信息。把登录信息放到j_username和j_password两个请求参数里如何？此外，我们还要用请求对象和应答对象的伪实现，用于传入登录参数以及验证重定向后的结果。

我们可以使用EasyMock等模拟对象框架，也可以弄套伪实现库来做实现。Spring框架里提供了许多类，这些类实现了javax接口，例如HttpServletRequest、HttpServletResponse等。这些类很方便使用，虽然我们并不会在产品代码里使用Spring框架，但可以先在测试中使用这些类。

代码清单5-3中为LoginServlet的第一个测试。

代码清单5-3　LoginServlet的第一个测试

import javax.servlet.http.HttpServlet;

import org.junit.Test;

import static org.junit.Assert.assertEquals;

import org.springframework.mock.web.MockHttpServletRequest;

import org.springframework.mock.web.MockHttpServletResponse;

public class TestLoginServlet {

@Test

public void wrongPasswordShouldRedirectToErrorPage()

throws Exception {

HttpServlet servlet = new LoginServlet();

MockHttpServletRequest request =     /*（以下4行）创建伪请求对象……*/

new MockHttpServletRequest("GET", "/login");

request.addParameter("j_username", "nosuchuser");

request.addParameter("j_password", "wrongpassword");

MockHttpServletResponse response =

new MockHttpServletResponse();     // ……伪应答对象

servlet.service(request, response);

assertEquals("/invalidlogin", response.getRedirectedUrl());    // 验证

}

}

就这么简单。我们在登录请求中添加了两个参数：username和password，然后调用了即将创建的LoginServlet类，最后读取伪应答对象，验证是否重定向到了/invalidlogin页面。目前我们并不会关心/invalidlogin页面是什么样，只需要确认登录失败后请求会重定向到这个页面即可。

现在可以创建LoginServlet类了。首先根据编译器的提示，我们应该先创建一个LoginServlet类，创建完后运行测试——不出所料，测试失败了。然后我们来实现service方法。让测试通过的最简单的办法遇到无论遇到什么请求参数都直接返回/invalidlogin，见代码清单5-4。

代码清单5-4　LoginServlet拒绝所有登录请求

public class LoginServlet extends HttpServlet {

@Override

protected void service(HttpServletRequest request,

HttpServletResponse response)

throws ServletException, IOException {

response.sendRedirect("/invalidlogin");     // 拒绝所有登录请求

}

}

运行一遍测试，都通过了，不过显然还有许多功能没实现。现在开始写测试验证正确登录的情形吧。

4. 使用参数和会话

我们怎么知道登录成功了？至少要把用户重定向到首页吧。另外我们也需要用某种方法记住用户的登录信息。最终我们可能要用一个复杂的身份验证框架，不过现在把用户信息存到会话（session）里就足够了。

继续些测试，如代码清单5-5所示。

代码清单5-5　我们的下一个测试，用来验证登录成功的情形

@Test

public void validLoginForwardsToFrontPageAndStoresUsername()

throws Exception {

HttpServlet servlet = new LoginServlet();

MockHttpServletRequest request =

new MockHttpServletRequest("GET", "/login");

request.addParameter("j_username", "validuser");

request.addParameter("j_password", "correctpassword");

MockHttpServletResponse response =

new MockHttpServletResponse();

servlet.service(request, response);

assertEquals("/frontpage", response.getRedirectedUrl());

assertEquals("validuser",

request.getSession().getAttribute("username"));

}

这个测试和我们第一个测试很相似。要通过这个测试我们必须要实现登录逻辑，不能再一味地拒绝用户登录了。我们还要检查会话中的username变量，验证用户的登录信息是正确的。我们遇到问题时通常不会花太多时间考虑，而是会很快地做出决定，将来遇到问题了再做修改。现在只要会话中包含用户名，就表明此用户已经成功登录了。

在IDE中切换回LoginServlet的实现代码后，我突然意识到还有一个设计问题没解决。我们的LoginServlet如何知道“合法用户”真的合法？如何知道“正确的密码”真的正确？换句话说，LoginServlet如何验证用户？来，带上设计者的帽子，想想这个问题。

5. 演化出更棒的设计

要使这个新添加的登录测试通过，我们必须能正确地区分有效登录和无效登录。我们能用多种方式实现登录功能，可以用文件系统或是数据库，也可以用LDAP服务器，不过我现在还不想深入登录的实现细节。我们现在要进行模块化设计，具体实现方法并不是重点。我们最好把登录验证功能交给其他类处理，让Servlet只负责控制器的工作即可。

由此可以写出代码清单5-6中的验证服务代码。

代码清单5-6　AuthenticationService 接口

public interface AuthenticationService {

boolean isValidLogin(String username, String password);

}

因为现在并没有使用真的用户数据库，也没有用其他的验证服务，所以我打算先给出AuthenticationService接口的伪实现，如代码清单5-7所示。

代码清单5-7　AuthenticationService接口的伪实现

public class FakeAuthenticationService implements

AuthenticationService {

private Map<String, String> users = new HashMap<String, String>();

public void addUser(String user, String pass) {

users.put(user, pass);

}

public boolean isValidLogin(String user, String pass) {

return users.containsKey(user)

&& pass.equals(users.get(user));

}

}

在这个伪实现中我们用一个简单的Map对象保存用户名和密码，同时提供了个给Map中添加有效用户的方法，以控制“用户数据库”。

LoginServlet该如何得到一个AuthenticationService的引用？因为实例化Servlet类是由Servlet容器负责的，所以我们没法通过构造函数注入依赖。我们可以把AuthenticationService变为一个抽象类，再添加一个静态getter方法用来获取当前实现，相应的有一个静态setter方法来用来设置当前实现。这主意其实并不怎么样，使用依赖注入当然会更清楚。

为了避免在这种架构问题上耽搁太多时间，我们可以暂时推迟这个决定，先给LoginServlet类添加一个protected getter方法，用于获取AuthenticationService的实现。我们可以在子类中覆盖这个方法，返回模拟的AuthenticationService。

代码清单5-8中的代码由代码清单5-5中的代码修改而成，并使用了AuthenticationService。

代码清单5-8　重写登录测试，使用AuthenticationService

@Test

public void validLoginForwardsToFrontPageAndStoresUsername()

throws Exception {

final String validUsername = "validuser";

final String validPassword = "validpassword";

final FakeAuthenticationService authenticator =     /*（以下3行）确认伪验证服务*/

new FakeAuthenticationService();

authenticator.addUser(validUsername, validPassword);

LoginServlet servlet = new LoginServlet() {

@Override     /*（以下4行）用伪对象*/

protected AuthenticationService getAuthenticationService() {

return authenticator;

}

};

MockHttpServletRequest request = new MockHttpServletRequest();

request.addParameter("j_username", validUsername);

request.addParameter("j_password", validPassword);

MockHttpServletResponse response =

new MockHttpServletResponse();

servlet.service(request, response);

assertEquals("/frontpage", response.getRedirectedUrl());

assertEquals("validuser",

request.getSession().getAttribute("username"));

}

这测试可真大，测试通过后我们肯定要做些重构了。现在代码还不能通过编译，不过在LoginServlet类上添加getter方法即可。

public class LoginServlet extends HttpServlet {

protected AuthenticationService getAuthenticationService() {

return null;

}

@Override

protected void service(HttpServletRequest request,

HttpServletResponse response)

throws ServletException, IOException {

response.sendRedirect("/invalidlogin");

}

}

编译通过了，测试失败了，因为重定向的地址不对。现在动手实现功能吧。

虽然引入AuthenticationService接口算是个大的设计变动，不过修改当前实现以通过测试一点也不难，如代码清单5-9所示。

代码清单5-9　LoginServlet把验证的任务交由其他的服务处理

public class LoginServlet extends HttpServlet {

protected AuthenticationService getAuthenticationService() {

return null;     // 还没必要实现此方法

}

@Override

protected void doGet(HttpServletRequest request,

HttpServletResponse response)

throws ServletException, IOException {

String user = request.getParameter("j_username");

String pass = request.getParameter("j_password");

if (getAuthenticationService().isValidLogin(user, pass)) {    // 基于验证服务规则的分支执行

response.sendRedirect("/frontpage");

request.getSession().setAttribute("username", user);

} else {

response.sendRedirect("/invalidlogin");

}

}

}

现在这个测试通过了，上个测试却失败了。这是因为在第一个测试中并没有给LoginServlet配置AuthenticationService。我们可以把第二个测试的初始化LoginServlet部分提取出来，作为两个测试共用的初始化方法，这样就可以修复第一个测试了。

我们可以把LoginServlet匿名子类的实例保存在测试类的成员变量中，也可以对FakeAuthenticationService实例做同样的处理。在两个测试中，我们也会用到伪请求和应答对象，所以也把它们移入测试类的成员变量中。另外，还可以把重复的字符串提取成常量。这下测试代码清楚多了。见代码清单5-10。

代码清单5-10　为LoginServlet配置AuthenticationService

public class TestLoginServlet {

private static final String CORRECT_PASSWORD = "correctpassword";

private static final String VALID_USERNAME = "validuser";

private LoginServlet servlet;       /*（以下4行）将普通对象移到域中*/

private FakeAuthenticationService authenticator;

private MockHttpServletRequest request;

private MockHttpServletResponse response;

@Before

public void setUp() {

authenticator = new FakeAuthenticationService();

authenticator.addUser(VALID_USERNAME, CORRECT_PASSWORD);

servlet = new LoginServlet() {

@Override

protected AuthenticationService

getAuthenticationService() {

return authenticator;

}

};             /*（以下20行）我们的测试方法非常简洁，并且很到位*/

request = new MockHttpServletRequest();

response = new MockHttpServletResponse();

}

@Test

public void wrongPasswordShouldRedirectToErrorPage()

throws Exception {

request.addParameter("j_username", VALID_USERNAME);

request.addParameter("j_password", "wrongpassword");

servlet.service(request, response);

assertEquals("/invalidlogin", response.getRedirectedUrl());

}

@Test

public void validLoginForwardsToFrontPageAndStoresUsername()

throws Exception {

request.addParameter("j_username", VALID_USERNAME);

request.addParameter("j_password", CORRECT_PASSWORD);

servlet.service(request, response);

assertEquals("/frontpage", response.getRedirectedUrl());

assertEquals(VALID_USERNAME, request.getSession()

.getAttribute("username"));

}

}

提取出公用初始化方法后，测试都通过了。不过j_username和j_password这两个字符串还存在重复，也可以重构成常量。虽然有时候可读性比“不能有重复”更重要，不过就这具体情况，我强烈建议做重构。这重复不但存在于测试方法之间，还存在于测试代码与产品代码之间，为此我们需要定义公共的常量，让产品代码和测试代码都使用此常量，这样就可以消除重复了。这个任务交给你来做。

关于这个话题，再写10页都没问题。不过为了留些时间讲解其他技术，我们就到此为止吧。现在来介绍另一种与纯Servlet API不太一样的技术。

6. 回顾Servlet技术上的测试驱动

现在来看看我们对AuthenticationService接口做了哪些工作。我们引入了AuthenticationService接口，这样LoginServlet就不用知道过多验证细节了。至于如何给Servlet注入AuthenticationService暂时不做考虑，先使用getter方法，做测试时可以覆盖这个方法。目前我们还没实现真正的AuthenticationService，只有一个伪实现。

采用分治法，我们可以保持Java Servlet的代码既干净，可测试性又好。使用意图编程，小步地通过测试，我们正在一步步地实现完整的功能。在LoginServlet例子中我们也看到了，用测试驱动的方法编写Servlet代码并不比写普通代码难多少。

现在我们已经知道如何测试驱动Servlet了，下面来看看若使用现代的MVC框架做开发情况会有多么不同吧。目前Spring框架应用非常广泛，我们就用它吧。

5.2.2　测试驱动Spring控制器

Spring框架带有很多用于实现控制器的类。这些类与Servlet相似，不过抽象层次更高。这些Spring控制器不会把请求直接分发到视图上，而是会返回一个ModelAndView对象，通知框架下一步该做什么。为了更清晰地阐述，我们来看一些代码。

代码清单5-11中的SampleController实现了Spring MVC中的Controller接口。在SampleController中需要实现一个简单的方法，这个方法负责处理请求，返回ModelAnd- View对象，Spring会根据此对象中的数据决定下一步的操作。SampleController所对应的URL可以在配置文件中配置。传入的请求对象中包含了所有SampleController需要的数据，而返回的ModelAndView中包含所有Spring执行下步操作时所需的信息。

代码清单5-11　Spring控制器的例子

public class SampleController implements Controller {

protected ModelAndView handleRequest(HttpServletRequest request,

HttpServletResponse response)

throws Exception {

return new ModelAndView("viewname");

}

}

这就是Spring MVC的简单介绍。现在，我们来试着用测试驱动的方法编写Spring MVC控制器。

1. 编写第一个测试

接下来要用Spring MVC中的Controller类来实现与LoginServlet一样的功能。那么，测试该怎么写呢?当用不正确的密码登录时，控制器还是应当把用户重定向到“错误密码”页面。对应的测试如代码清单5-12所示。

代码清单5-12　LoginContorller的单元测试例子

public class TestLoginController {

@Test

public void wrongPasswordShouldRedirectToErrorPage()

throws Exception {

MockHttpServletRequest request =

new MockHttpServletRequest();     /*❶（以下5行）构成伪对象*/

request.addParameter("j_username", "nosuchusername");

request.addParameter("j_password", "nosuchpassword");

MockHttpServletResponse response =

new MockHttpServletResponse();

Controller c = new LoginController();     /*❷（以下2行）调用控制器的处理Request()方法*/

ModelAndView v = c.handleRequest(request, response);

assertEquals("wrongpassword", v.getViewName());     // ❸用户登录Wrongpassword页面

}

}

从这段代码可以看出，Spring MVC版“登录失败”测试和Servlet版区别不大。我们先创建出请求和应答的测试替身❶，然后将其传给控制器的请求处理方法❷，最后验证执行结果❸。

Spring控制器与Servlet执行结果所不同之处在于Spring控制器会返回ModelAndView对象，这个对象在控制器和视图之间添加了一层抽象。控制器只需要返回一个ModelAndView对象，这对象中含有所有渲染视图所需的数据、需要渲染的视图的名称等。Spring MVC框架会负责剩下的工作，包括用ModelAndView对象中包含的数据渲染视图，等等。

现在我们已经有了测试，创建好LoginController类后就可以运行测试，得到那熟悉的红条了。

2. 以最简单的方式通过测试

只要添加一行指定视图名称的代码就能通过代码清单5-12中的测试了，代码见代码清单5-13。

代码清单5-13　失败的登录

import javax.servlet.http.*;

import org.springframework.web.servlet.ModelAndView;

import org.springframework.web.servlet.mvc.Controller;

public class LoginController implements Controller {

public ModelAndView handleRequest(HttpServletRequest request,

HttpServletResponse response)

throws Exception {

return new ModelAndView("wrongpassword");

}

}

这很简单。运行一遍测试，通过了。下一步该做什么？

3. 引入依赖

Spring框架的依赖管理功能很强大，因此Spring控制器的可测试性比普通Servlet也更好些。我们来为登录成功的情形编写一个测试以展示这个特性。

假设控制器同样把登录验证工作交给AuthenticationService。为了更容易测试，我们必须想办法把AuthenticationService的实现传给控制器。有了Spring框架的依赖注入功能，我们只需要给LoginController添加一个setter，setAuthenticationService，然后在配置文件中把AuthenticationService的实现与LoginController关联起来，这样在部署的时候LoginController所需的依赖就会自动注入了。见代码清单5-3。

在测试中我们给LoginContorller传入一个AuthenticationService的测试替身。在生产环境中Spring会根据配置文件给LoginContrller注入真实的AuthenticationService。

图5-3　把LoginController及其依赖映射到AuthenticationService实现上

代码清单5-14中有效登录的测试与LoginServlet的测试很相似。

代码清单5-14　测试有效登录

private static final String CORRECT_PASSWORD = "correctpassword";

private static final String VALID_USERNAME = "validuser";

...

@Test

public void validLoginForwardsToFrontPage() throws Exception {

MockHttpServletRequest request = new MockHttpServletRequest();

request.setMethod("GET");

request.addParameter("j_username", VALID_USERNAME);

request.addParameter("j_password", CORRECT_PASSWORD);

MockHttpServletResponse response =

new MockHttpServletResponse();

FakeAuthenticationService mock =

new FakeAuthenticationService();

mock.addUser(VALID_USERNAME, CORRECT_PASSWORD);

LoginController c = new LoginController();

c.setAuthenticationService(mock);     // 模仿依赖注入

ModelAndView v = c.handleRequest(request, response);

assertEquals("frontpage", v.getViewName());

}

LoginServlet测试和LoginController测试中调用的方法不同（分别为Servlet的service方法和Spring控制器的handleRequest方法），另外有了Spring MVC，我们就不用再担心如何构建依赖注入架构了，Spring已经替我们准备好了。只需要加个setter，然后修改配置文件即可。

我们先来看看如何使测试尽快通过，然后结束这部分内容。代码清单5-15是控制器的完整实现。

代码清单5-15　包含依赖注入的LoginController的完整实现

public class LoginController implements Controller {

private AuthenticationService authenticator;

public void

setAuthenticationService(AuthenticationService authService) {

this.authenticator = authService;

}

public ModelAndView handleRequest(HttpServletRequest request,

HttpServletResponse response) throws Exception {

String user = request.getParameter("j_username");

String pass = request.getParameter("j_password");

if (authenticator.isValidLogin(user, pass)) {

return new ModelAndView("frontpage");

}

return new ModelAndView("wrongpassword");

}

}

没什么新东西。运行所有测试，当前有效登录的测试通过了，第一个测试却失败了。这是因为我们没有给第一个测试中的控制器注入AuthenticationService。我们可以把公共的初始化代码提取成一个成员方法，再给这个方法标记上@Before。这一步重构的结果就不在这里演示了，你可以自己试试，重构后代码会很简洁。

看完这些例子后，我想不会再有人认为现代MVC框架下测试驱动控制器类有任何麻烦或者困难了。这些实际上都只是普通的Java代码，只不过接口稍微大了一点而已。

现在我们知道测试驱动MVC控制器完全可能，丝毫不用委屈求全地退回“非测试驱动”的方式。下一步我们来讨论视图部分的处理方法。

5.3　用测试先行的方法构建视图

不得不说，缺乏信心是实现自动测试视图的最大障碍。有些人不相信这工作也能自动化，甚至认为这样做不合情理。Web应用程序中某些部分确实要靠眼睛来验证，例如整体美感或者页面样式在所有浏览器上都显示正常等。这部分测试没必要自动化，因为成本高却回报少，而且还有些脆弱。（如果计算机告诉你网页看起来一切正常，你相信吗？）在图5-4中我们划分了需要用视觉验证的部分与自动化测试能够覆盖的部分。

图5-4　虽然自动化测试很难验证视图的外观部分，但却可以验证逻辑部分。所谓视图逻辑就是说测试视图做了些什么。此外即使不能自动化测试到饼状图的整个左半边，自动化其中一部分也是可以的。例如，我们可以测试网页是否符合“508条款”1中对网络内容“可接近性”的要求（“508条款”中不需要人为判断的那部分，如每个IMAGE标签都需要在alt属性中有一段文字描述等）

1 “508条款”是对1973年颁布的美国劳工复健法的改进，它是一部联邦法律，规定了所有由联邦政府发展、取得、维持或使用的电子和信息技术都必须能让残疾人“可以访问”。如果某技术能够由残疾人像没有残疾的人一样有效的使用，那么这项技术就被认为是“可以访问”的。——编者注

然而视图层的某些部分还是可以自动化测试的，我们称这部分为视图逻辑。所谓的视图逻辑是类似于“给定一个物品列表，只有第一个物品的详细信息会显示出来，其他物品仅显示名称”的逻辑。

从技术角度来测试驱动视图层确实有些麻烦，如果想使TDD周期尽量短就需要想办法克服这些技术问题。用JSP编写的视图就是一个例子，因为需要JSP引擎才能将JSP渲染成HTML（或转换成其他任何你期望形式的内容），这给测试工作带来不少麻烦。

别担心，这个问题可以解决。在单元测试中嵌入一个轻量级的JSP引擎就能渲染JSP页面了。因为这引擎很“轻量级”，所以初始化过程不会占用太多时间，比起一个必须部署到功能完整的应用服务器上才能运行的测试，这种单元测试运行速度要快得多。

我们先来看看如何测试先行地用JSP技术开发经典的登录页面。讲完这部分内容后，我们将会用Velocity——另一种视图技术——进行开发，体验一下TDD的整个过程会有多大区别。最后我们将会进入基于控件的Web框架世界，体验在这种技术下测试驱动视图的感觉。

5.3.1　用JspTest测试驱动JSP

刚才提到过，测试驱动JavaServer Pages需要JSP引擎等基础设施的支持。我们会使用一个叫做JspTest的开源库。JspTest提供了必备功能的一个最小集合，它会先把JSP编译成Servlet类然后再执行此Servlet。JspTest内部使用了Jasper做实现。有很多Web容器，例如Apache Tomcat、Jetty、Glassfish等都使用了这个引擎。虽然Jasper不是最成熟的，不过已经够用了。

现在着手干吧！

1. 构建必要的基础设施

接下来我们会一步步构建出必要的基础设施，这样就能对各个细节了如指掌了。

JspTest里有一个叫做HtmlTestCase的抽象类，所有测试都需要继承这个类。HtmlTestCase提供了一些方法来模拟HTTP请求和渲染JSP文件以及做HTTP相关的验证等。

JspTest默认在当前目录下开始寻找JSP文件。如果我们没有把jsp文件放在当前目录而是web/jsp目录中，那么在每个测试中我们明确指定websrc/jsp路径，这显然很麻烦。要解决这个问题，我们可以添加一个虚基类，在虚基类中覆盖getWebRoot方法，然后让所有测试都继承这个虚基类：

import net.sf.jsptest.HtmlTestCase;

public abstract class MyJspTestCase extends HtmlTestCase {

@Override

protected String getWebRoot() {

return "./websrc/jsp";

}

}

现在我们只要指定/foo.jsp就能渲染websrc/jsp/foo.jsp了。这样做的好处目前看来并不起眼，不过随着代码量的增加，优势会逐渐体现出来的——特别是改变JSP源文件的目录时。

完工了（当然还要把JspTest等依赖放到类路径（class path）中），现在开始写测试吧。

2. 测试渲染出的内容

下一步就要用TDD开发JSP登录页面了。在第一个测试中我们可以验证用户打开页面时能看见一些必要的表单元素。再次使用意图编程，写出代码清单5-16中的测试。

代码清单5-16　使用JspTest虚基类

public class TestLoginPage extends MyJspTestCase {

public void testFormFieldsArePresent() throws Exception {

get("/login.jsp");

form().shouldHaveField("j_username");

form().shouldHaveField("j_password");

form().shouldHaveSubmitButton("login");

}

}

简言之，这测试用HTTP GET请求获取login.jsp，然后验证页面中包含j_username和j_password两个输入项和名叫login的提交按钮。来运行测试吧。

测试失败了，因为JspTest在Web根目录中没找到login.jsp文件，不过这正是期望的结果，因为我们在以测试驱动的方式进行开发。现在我们来写点JSP代码让测试通过吧。

为什么不用JUnit 4注释语法

我们够幸运，能使用最新最好的技术，不过有许多技术人员仍然在使用Java 1.4甚至更早版本。JspTest需要支持各种用户，因此没有用注释语法。因为JUnit 4向后兼容，因此这些测试可以与基于注释的测试一起运行。

3. 创建正巧足够的JSP

我们先来写个最简单的JSP页面以验证新JSP文件加对了地方，如代码清单5-17所示。

代码清单5-17　login.jsp

空白页。不可能比这还简单了。有了这个测试做保证，我们埋头写了一大堆代码后不会心碎地发现改错了文件。我们稍后会逐渐加快开发速度，不过慢速起步总是更安全些。

再运行一遍测试，JspTest可以在文件系统中找到我们的JSP文件了。不过测试还是失败了，这是因为页面中没有“j_username”输入框。这很正常，因为我们只有一个空白JSP页面而已。接着我们要添加“j_username”输入框和一个基本的HTML框架。按照测试结果的指示，我们还要加一个“j_password”，然后添加提交按钮，最后整个测试都通过了。如代码清单5-18所示。

代码清单5-18　使第一个测试通过的JSP代码

<%@ page language="Java" %>

<html>

<body>

<form>

<input type="text" name="j_username" />

<input type="password" name="j_password" />

<input type="submit" name="login" />

</form>

</body>

</html>

测试已经可以正常执行了，而且我们还测试驱动出了用户界面的一项具体功能。我们已经添加了不少用于测试的基本功能，随着新需求不断出现，我们会相应添加更多的功能。现在继续写测试吧，每个测试都能使咱们的JSP页面更加完善。

4. 把行为驱动到页面中

继续开发登录页面。我们急切地想试试用测试驱动的方法给页面添加动态行为，所以来写个测试吧，验证登录失败后登录页面会自动在j_username框内填入用户名。代码清单5-19中为我们的测试。

代码清单5-19　测试在登录失败后用户名会保留，而密码不会保留

public void testPreviousUsernameIsRetained() throws Exception {

setRequestAttribute("j_username", "bob");

get("/login.jsp");

form().field("j_username").shouldHaveValue("bob");

}

这个测试告诉我们，在渲染页面时应该用请求参数j_username填充输入框。运行测试，失败了，因为就没填充输入项。代码清单5-20中的代码能够通过测试。

代码清单5-20　增加脚本

<%@ page language="Java" %>

<html>

<body>

<form>

<%

String username =

(String) request.getAttribute("j_username");

if (username == null) username = "";

%>

<input type="text" name="j_username" value="<%= username %>"/>

<input type="password" name="j_password" />

<input type="submit" name="login" />

</form>

</body>

</html>

是不是很简单？谁说不能测试驱动JSP页面？现在已经没有什么好争论的了吧？测试驱动JSP既现实又可行。

5. JspTest之外的选择

JspTest完全能胜任大部分JSP页面的测试工作。不过这个测试工具还很年轻，并不很完善，例如在HTML相关的验证和其他内容验证方面还有待提高。此外，并不是所有的内建对象（例如request、response、session、pageContext等）都能自由定制，所以有时需要另辟蹊径解决问题。

在写这本书时，还有一个名为ServletRunner的优秀JSP单元测试工具（HttpUnit项目的一部分）。ServletRunner是一个轻量级的Web容器，用于测试Servlet和JSP。关于这个工具的详细信息，可以参考J. B. Rainsberger的优秀作品《程序员实用测试技巧》一书中的技巧12.3。图5-5为这种方法的整体架构，用转发Servlet来访问和测试JSP输出。

另外我们还可以把Java EE web容器嵌入测试中。Jetty这种轻量级且兼容标准的Web容器就很适合做这项工作。不过Jetty需要一个有效的Web应用环境，这点比JspTest更复杂（因为JspTest把这类细节都隐藏了起来，所以更简单些）。

图5-5　要测试驱动JSP，可以将其部署到轻量级、内嵌的Servlet容器中，然后用HTTP请求或者容器内部的API来访问页面进行测试

把JSP代码部署到一个单独的容器中进行测试也可以，不过会比内嵌的容器慢不少。如果你倾向于这种集成测试，那么可以尝试Jakarta Cactus，它是该领域的一种重要的测试工具。不过，别怪我没提醒你，这类工具可不像JspTest和他的朋友们一样，能快速的提供反馈，这类测试运行速度都相对较慢。

JavaServer Pages的介绍目前已经足够了。接下来我们来一起看看开源Java视图技术的那一方天空有多么蔚蓝。

5.3.2　测试驱动Velocity模板

Velocity是一个通用的模板引擎，其本身与Java EE平台毫无关系。虽然从Java EE开发人员角度来看这也许不是好事，不过比起功能过于强大的JSP，它有自己独特的优势。

我们刚才提到Velocity是种轻量级的视图技术。为什么是轻量级的？可以看到，用JspTest框架做测试时要写很多代码，因为需要很多基础设施来渲染JSP页面。虽然已经有人写了工具帮我们简化工作，但无论如何，渲染视图这工作应该很简单才是。

Velocity专门为嵌入其他工具或应用程序而设计，完全不依赖于Java EE平台，因此我们可以考虑用其简化视图开发。虽然测试驱动视图仍然需要一些基础设施支持，不过已经比JSP强很多了。因为我们不需要为模板引擎准备容器，模板引擎本身也比Apache Tomcat的Jasper小得多。

在深入讨论前，我们先了解一些Velocity模板的基础知识和Velocity API。

1. Velocity基础

Velocity的概念很简单：给定一个模板及模板中使用的变量，把这两样东西塞给Velocity，Velocity会计算模板中的纯文本、变量、基本的条件分支结构、循环结构等，然后给出渲染结果。见图5-6。

Velocity的API并不复杂。先创建出VelocityContext对象，填入些变量，然后创建VelocityEngine，让引擎解析模板，最后把渲染结果写到指定的输出流上（即java.io.Writer）。换言之，要用Velocity渲染模板，只需要写如下的代码：

String template = "Hello $user";

StringWriter writer = new StringWriter();

VelocityContext context = new VelocityContext();

context.put("user", "Reader");

VelocityEngine engine = new VelocityEngine();

engine.init();

engine.evaluate(context, writer, "test", template);

图5-6　Velocity的基本理念十分简单。Velocity会解析带有特殊标记的变量数据和基本程序结构（programming constructs），读取变量值，然后渲染出结果

执行以上代码后StringWriter中就会包含渲染结果。结果应该是Hello Reader，因为在VelocityContext中，我们把变量值Reader付给了变量user。

接下来我们看看用Velocity模板编写的登录页面需要什么样的支持设施以写出紧凑且含义明确的测试。

2. 构建基础设施

我们刚才已经看到了，要渲染Velocity模板，需要准备好VelocityContext（等同于JSP和Servlet中用到的请求属性（request attributes））、用于输出的Writer，并指定所用模板的名称和模板文本。假设从文件系统中读取Velocity模板，测试可以如代码清单5-21中所示。

代码清单5-21　开始编写VelocityTestCase

public class TestLoginTemplate extends VelocityTestCase {

@Override

protected String getWebRoot() {    /*（以下3行）覆盖Web root让测试中的模板路径变短*/

return "./websrc/velocity";

}

@Test

public void previousUsernameIsRetained() throws Exception {

String previousUsername = "Bob";

setAttribute("username", previousUsername);

render("/login.vtl");     // 在这里渲染/websrc/velocity/login.vtl

assertFormFieldValue("j_username", previousUsername);

}

}

我们的新测试类继承自一个虚基类，这个虚基类提供了必要的基础设施和渲染方法（例如setAttribute，render等）以及一些验证渲染结果的方法。我们先来试着渲染模板，然后解析渲染出的HTML，然后再提供些验证方法检验渲染结果的正确性。

代码清单5-22中的代码用于加载及渲染模板，渲染所需的各个属性则需要具体测试方法提供。

代码清单5-22　虚基类提供了渲染Velocity模板的设施

import java.io.*;

import javax.xml.parsers.*;

import javax.xml.xpath.*;

import org.apache.velocity.*;

import org.apache.velocity.app.*;

import org.w3c.dom.*;

import org.junit.*;

public abstract class VelocityTestCase {

private VelocityContext context;

protected Document document;

@Before

public void setUp() throws Exception {

context = new VelocityContext();    // ❶为每个测试创建新的上下文

}

protected String getWebRoot() {

return ".";

}

protected void setAttribute(String name, Object value) {     /*❷（以下3行）测试应将属性植入VelocityContext*/

context.put(name, value);

}

protected void render(String templatePath) throws Exception {

File templateFile = new File(getWebRoot(), templatePath);

String template = readFileContent(templateFile);

String renderedHtml = renderTemplate(template);    // ❸在内存中渲染模板

this.document = parseAsXml(renderedHtml);

}

private String renderTemplate(String template) throws Exception {

VelocityEngine engine = new VelocityEngine();

engine.init();

StringWriter writer = new StringWriter();     /*❸（以下3行）在内存中渲染模板*/

engine.evaluate(context, writer, "test", template);

return writer.toString();    // ❹解析渲染出的HTML，写入DOM树

}

private Document parseAsXml(String html) throws Exception {

// omitted for brevity...

}

private String readFileContent(File file) throws Exception {

// omitted for brevity...

}

}

在代码清单5-22的代码中，每个测试都会❶创建出新的VelocityContext，因此测试之间完全独立，之前运行的测试❷所填充的变量值对当前测试毫无影响。在render方法中，我们创建了新的VelocityEngine，然后❸调用evaluate方法渲染模板，渲染结果写入StringWriter。在模板渲染完成时，我们把渲染出的HTML写入DOM树（DOM tree）以进行验证。

现在只剩下几个断言方法要写了。加上这几个方法，我们就可以运行测试来检测代码的正确性了。

3. 添加断言

现在我们已经有了文档对象（document object），即模板渲染后产生的DOM树。下一步需要实现断言assertFormFieldValue。既然我们已经有了DOM树，而且Java 5的标准库中终于加入了XPath，何不用XPath来验证渲染出HTML的正确性？

代码清单5-23中用标准DOM和XPath API实现了断言方法。

代码清单5-23　用HTTPUnit解析渲染出的HTML文件

import static org.junit.Assert.*;

import javax.xml.parsers.*;

import javax.xml.xpath.*;

import org.w3c.dom.*;

...

public abstract class VelocityTestCase {

...

protected void assertFormFieldValue(String name,

String expectedValue) throws Exception {

String xpath = xpathForField(name);

assertNodeExists(xpath);

String actual = getString(xpath + "/@value");

assertEquals(expectedValue, actual);

}

private String xpathForField(String name) {

return "//form//input[@name='" + name + "']";

}

private void assertNodeExists(String xpath) throws Exception {

assertNotNull("Node doesn't exist: " + xpath,

getNode(xpath));

}

private Node getNode(String xpath) throws Exception {

return (Node) evaluate(xpath, XPathConstants.NODE);

}

private String getString(String xpath) throws Exception {

return (String) evaluate(xpath, XPathConstants.STRING);

}

private Object evaluate(String xpath, QName type)

throws Exception {

XPath engine = XPathFactory.newInstance().newXPath();

return engine.evaluate(xpath, getResponse(), type);

}

}

终于完成了！红条，因为我们想渲染的login.vtl模板不存在。我们终于又回到了那让人心情舒畅的测试—编码—重构周期上了。把代码清单5-24中的Velocity模板保存到login.vtl中，测试就可以通过了。

代码清单5-24　登录页面的Velocity模板

<html>

<body>

<form>

<input type="text" name="j_username" />

<input type="password" name="j_password" />

<input type="submit" name="login" />

</form>

</body>

</html>

现在我们都理解测试驱动Velocity模板的方法了吧？基本上我们需要做两件事：其一，渲染模板；其二，验证渲染结果。Velocity这技术非常简单易用，只需要几分钟即可完成渲染的准备工作。此外，验证HTML文档的方法与其他视图技术一样，所以我们可以重用已有的库。把渲染结果保存到本地文件中，然后用HttpUnit进行测试也是个不错的注意。

不过现在，我们先结束模板方面的话题，转向Web控件等更有趣的技术的讨论吧。在前面的章节中，我们已经见识到了测试驱动“请求—应答”式的MVC框架中的控制器部分和视图部分有多么简单。在本章的技术部分，我们将介绍如何测试驱动基于控件的Web框架。

5.4　在基于控件的Web框架基础上TDD

可以说，测试驱动Servlet和Spring的控制器之所以相对简单，是因为有许多工具支持。在标准Java EE API中或Spring这样的开源框架中，能找到一些包含测试替身的库，提供了很多重要的接口和对象的伪实现。成熟的框架能带给我们很多便利，这是新技术所不具备的。基于控件的框架阵营中（例如Apache Tapestry、Wicket或者Java EE标准的代表——JavaServer Faces）情况又如何呢？

Servlet API或Spring MVC中使用的请求—应答设计，目前在Java EE web开发中占有主导地位，不过这不是开发Web应用的唯一方式。基于控件的框架正在不断增多。这些框架能更好的支持代码重用，也提供了更高层次的抽象。这些特性能使我们开发人员更关注于“要做什么”，而非处理请求参数、把字符串解析成日期对象等技术细节。

在Web之旅结束前，我们来花些时间讨论“基于控件的框架”的相关概念及含义。然后，我们会介绍如何用开源社区提供的最新最棒的工具来测试驱动控件的开发。

5.4.1　剖析典型框架

这些基于控件的框架在架构方面都有一定的模式。从这些模式上我们基本上可以判断出框架是否易于做单元测试，或者适合做TDD。

1. 控件提供了行为

首先，“基于控件”中的“控件”是指UI控件。这些widgets都是普通的Java类，通常会继承框架中的某个类，或者实现特定的接口。框架本身会根据控件的生命周期，处理控件实例与浏览器发来的请求之间的交互。这种方式让测试驱动控件工作变得很简单，因为基本上也是在处理普通Java类，而我们对普通Java类的测试驱动已经相当熟悉了。

2. 模板封装了各种视觉元素

控件通常用模板语言绘制自己。这些控件和模板通常都附有配置文件（通常是XML），配置文件中带有信息，告诉框架控件与模板中的标记间的对应关系，控件初始化时需要哪些资源，以及客户端触发的事件与控件实例和方法的映射关系等。有些框架大量使用了Java 5注释语法，把额外的配置与控件源代码放在同一个源文件中。在Wicket框架中，甚至连页面都是由Java类来表示的，只剩下HTML是非Java代码，当然XML除外。

3. 可测试性方面的差别

我们刚才说过测试驱动控件开发不是问题，其实不用部署到容器而测试页面模板同样可行。基于JSF的开源框架Apache Shale项目，提供了一个库，包含javax.faces接口的测试替身以及许多基类供JUnit测试扩展。这些基类为一些关键的JSF接口提供了伪实现，这对JSF的测试工作至关重要。

Tapestry和JSF都不能完全支持单元测试，我们要写大量代码才能完成想要的测试。不少JSF和Tapestry的开发人员已经接受了现实，屈从容器内（in-container）的测试或功能测试了。

正是由于这个原因，我强烈推荐Wicket，这个框架简直就是为了可测试性而生的。接下来我们一起进入Wicket页面和控件的世界，看看在此环境下如何进行测试驱动。

5.4.2　用测试先行的方法开发Wicket页面

Wicket是种开源的基于控件的Web框架。虽然基于控件并不是什么新概念，不过Wicket的实现方法却很特别。我们先来看看Wicket里面有什么，以及Wicket应用由什么构成吧。

1. 简洁，跳出配置泥潭

首先，Wicket中只有Java或者HTML代码（或者JavaScript，CSS）。开发视图模板时不会用到任何脚本语言，只有HTML。所有的逻辑，包括视图逻辑，都在Java类中。实际上基于Wicket的Web应用本身就是一个Java类，这个类继承自org.apache.wicket.protocol.http.WebApplication。Web页面也是Java类，继承自org.apache.wicket.markup.html.WebPage。

Wicket唯一的配置工作就是在Web.xml中注册WicketFilter，告诉容器你的应用程序的名字，添加filter-mapping，把WicketFilter绑定到应用程序的URL上等，见代码清单5-25。

代码清单5-25　Wicket应用的Web.xml样例

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE web-app PUBLIC

"-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"

"http://java.sun.com/dtd/web-app_2_3.dtd">

<web-app>

<filter>

<filter-name>MyWicketApplication</filter-name>

<filter-class>org.apache.wicket.protocol.http.WicketFilter</filter-class>

<init-param>

<param-name>applicationClassName</param-name>     /*（以下2行）Wicket需要应用类*/

<param-value>com.tddinaction.wicket.MyWicketApp</param-value>

</init-param>

</filter>

<filter-mapping>

<filter-name>MyWicketApplication</filter-name>     /*（以下2行）WicketFilter处理所有的HTTP问题*/

<url-pattern>/*</url-pattern>

</filter-mapping>

</web-app>

以上是构建一个简单的Wicket应用所需的所有配置文件。要深入配置Wicket应用，可以修改应用类，或者在web.xml的init-param中传入applicationFactoryClassName，替代“applicationClass- Name”，见代码清单5-25。

2. 构建主页

下载完JAR文件，构建好目录结构后，要做的第一件事就是创建应用程序类。因为我们要采用测试驱动的方法构造出健康的代码，并保持这种健康状态，所以继续测试先行吧，如代码清单5-26所示。

代码清单5-26　编写应用程序类和主页

import org.apache.wicket.protocol.http.WebApplication;

import org.junit.*;

public class TestApplicationClass {

@Test

public void homePageHasBeenDefined() throws Exception {

WebApplication app = new MyWicketApp();

assertEquals(MyHomePage.class, app.getHomePage());

}

}

这个测试告诉我们两件事。第一，MyWicketApp应该继承Wicket的WebApplication类。第二，MyWicketApp的getHomePage方法应该返回主页类。这个例子也很好地说明了Wicket用类表示页面的方法——只需要传递页面对象，而Wicket会处理剩下的事情。

代码清单5-27中的实现就可以让红条变绿条了，没宇宙空间技术那么复杂吧。

代码清单5-27　实现应用程序类

import org.apache.wicket.protocol.http.WebApplication;

import org.apache.wicket.Page;

public class MyWicketApp extends WebApplication {

public Class<? extends Page> getHomePage() {

return MyHomePage.class;

}

}

应用程序类中的getHomePage用来通知Wicket当用户访问我们的应用程序时，应当显示哪个页面。要使代码通过编译，显然我们需要创建MyHomePage类，如下所示：

import org.apache.wicket.markup.html.WebPage;

public class MyHomePage extends WebPage {

}

就是这样。我们看到的是一个Wicket风格的空白页。这页面确实什么功能也没有。为了让Wicket知道如何显示页面，我们需要创建HTML模板。不过不能直接添加模板，只有手里握着个失败的测试时，我们才有权添加新功能！

3. 用WicketTester做测试

Wicket中包含一个名叫WicketTester的类，这个类是Wicket项目重视可测试性的证据之一。WicketTester类用来在容器外运行Wicket页面和控件。我们为主页写了个小测试来演示如何使用WicketTester。代码清单5-28演示了使用WicketTester的JUnit测试类。

代码清单5-28　我们的第一个WicketTester测试

import org.junit.*;

import org.apache.wicket.util.tester.WicketTester;

public class TestMyHomePage {

@Test

public void homePageHasWelcomeText() throws Exception {

WicketTester tester = new WicketTester();    /*❶（以下2行）为页面创建WicketTester*/

tester.startPage(MyHomePage.class);

tester.assertRenderedPage(MyHomePage.class);    /*❷（以下2行）检查渲染错误*/

tester.assertNoErrorMessage();

tester.assertContains("Welcome to the home page!");    // ❸判断页面内容

}

}

在代码清单5-28的代码中，我们首先创建了个WicketTester的实例，并将其指向我们要测试的页面❶。然后我们验证待测页面能被渲染出来，且没有任何错误信息或重定向动作，最后验证了渲染出的HTML中包含“Welcome to the home page!”字样❷。为通过这个测试，我们必须添加MyHomtPage.html❸，见代码清单5-29。

代码清单5-29　最简单的主页（首页）

<?xml version="1.0" encoding="UTF-8" ?>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"

"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">

<html xmlns:wicket="http://wicket.sourceforge.net/">

<body>

<p>Welcome to the home page!</p>

</body>

</html>

按照习惯，Wicket的视图模板通常与对应页面的Java类存放在同一个目录下。这种方法咋一看来很怪，不过很实用，因为这样我们就不必四处搜寻视图模板了，它就和它的页面类兄弟住在一起。除此之外还有其他方法组织文件，不过需要额外的配置，要么告诉Wicket视图模板都放在哪里了，要么自定义资源定位器（resource locator）。后一种方法显然更麻烦，不过更灵活。

我们刚才都见到了，要测试驱动一个简单的Wicket页面，JUnit加WicketTester就可以胜任。不过大部分应用都不是静态网页，所以最好试着用测试驱动的手法给页面添加新功能。下面我们来用控件把新创建的主页与另一个页面连接起来。

4. 加入控件

前面提到过，Wicket中所有的东西都由Java类表示，控件也一样。在Wicket中，控件可以用来表示网页中的任何动态元素。例如Wicket页面中连向其他页面的链接是控件，有动态内容的标签是控件，包含元素的表单也是控件。

很简单吧？来，先添加个新测试，在主页上驱动出一些控件。在从代码清单5-28演化而来的代码清单5-30中也同样使用了WicketTester。

代码清单5-30　用测试验证控件存在于页面中

import org.junit.*;

import org.apache.wicket.util.tester.WicketTester;

public class TestMyHomePage {

@Test

public void homePageHasLinkToLoginPage() throws Exception {

WicketTester tester = new WicketTester();

tester.startPage(MyHomePage.class);

tester.assertRenderedPage(MyHomePage.class);

tester.assertNoErrorMessage();

tester.assertLabel("welcomeMessage",    /*❶（以下2行）希望找到名为WelcomeMessage的控件*/

"Welcome to the home page!");

tester.assertPageLink("linkToLoginPage", LoginPage.class);    // ❷以及到登录页面的链接

}

}

代码清单5-30中的测试说，❶页面中应该包含一个Label控件，名字叫做welcomeMessage，内容是Welcome to the home page！，❷另外页面中还应该包含叫做loginPage的PageLink控件，链接到LoginPage。我们需要添加LoginPage类以通过编译。

public class LoginPage extends org.apache.wicket.markup.html.WebPage {

}

运行测试，测试结果表明页面中并没有目标控件，来加一个吧。

给页面添加控件有两个步骤：首先，在页面类中添加控件；然后，在HTML模板中添加标记，让Wicket知道如何渲染控件。代码清单5-31中的代码演示了如何在MyHomgPage类中添加Label和PageLink控件。

代码清单5-31　向主页中添加控件

import org.apache.wicket.markup.html.WebPage;

import org.apache.wicket.markup.html.basic.Label;

import org.apache.wicket.markup.html.link.PageLink;

public class MyHomePage extends WebPage {

public MyHomePage() {

add(new Label("welcomeMessage",

"Welcome to the home page!"));

add(new PageLink("linkToLoginPage", LoginPage.class));

}

}

就这样。不算麻烦吧？在代码清单5-31的代码中我们实例化了Label控件和PageLink控件，把LoginPage对象传入控件的构造函数，指定控件所属的容器。这样就可以构造出Wicket的控件树了。MyHomgPage的控件树包含两个子控件，分别为welcome-Message和linkToLoginPage。如果我们运行5-30中的单元测试，我们会得到详细的出错信息，指出下一步需要做的事情。

org.apache.wicket.WicketRuntimeException: The component(s) below failed to

render. A common problem is that you have added a component in code but

forgot to reference it in the markup (thus the component will never be

rendered).

问题就出在这里，我们只把控件添加到了控件树中，却没有在HTML模板中引用这些控件。代码清单5-32中的代码能够解决这个问题。

代码清单5-32　将控件加入HTML中

<?xml version="1.0" encoding="UTF-8" ?>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"

"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">

<html xmlns:wicket="http://wicket.sourceforge.net/">

<body>

<span wicket:id="welcomeMessage">welcome message goes here</span>

Please, <a href="#" wicket:id="linkToLoginPage">log in</a>.

</body>

</html>

从上面这段代码中，你也许可以看出Wicket渲染视图模板的方式。遇见带有属性wicket:id的HTML元素时，Wicket会在控件树中查找对应的控件，并要求控件渲染自己，所以浏览器中显示的不是welcome message goes here而是Welcome to the home page!（或者Java代码中指定的任何值）。

现在我们的页面中已经有控件了，下面来试着在测试中与这些控件交互。毕竟有些控件也是有状态或行为的，能在页面中看见按钮并不代表按钮能以我们期望的方式工作。

5. 与控件交互

用WicketTester验证控件存在于页面中很简单。与控件交互，验证控件行为的正确性，如何？我们先来看个例子。

假设我们有个主页，主页上有个链接连到登录页面。我们来写个测试，验证点击链接后会跳转到登录页面：

@Test

public void interactingWithComponents() throws Exception {

WicketTester tester = new WicketTester();

tester.startPage(MyHomePage.class);

tester.assertRenderedPage(MyHomePage.class);

tester.clickLink("linkToLoginPage");

tester.assertRenderedPage(LoginPage.class);

}

够简单吧？WicketTester提供了一个很方便的clickLink方法来模拟链接的点击。点击页面后，我们可以使用assertRenderedPage方法验证跳转到了正确的页面。

我们只是概览了Wicket框架，不过我们现在应该大致了解如何在基于控件的框架上进行TDD了。要获得此类技术更详细的介绍，请参照以下书籍：JavaServer Faces in Action^1（Kito Mann著；Manning出版社，2004），Tapestry in Action（Howard M. Lewis Ship著；Manning出版社，2004），以及Wicket in Action（Martijn Dashorst与Eelco Hillenius著；Manning出版社，2008）。

1 中文版《JSF实战》已由人民邮电出版社出版。——编者注

5.5　小结

测试驱动Web开发不仅可行，而且能够发挥出TDD所有的优点，同时不会因为测试而使代码变得冗长。

所以，除了模型部分外，MVC中的控制器和视图部分也能够有效地应用TDD进行开发。虽然和写纯Java代码不完全一样，但是也相当接近。只要在测试类的父类中加入些测试运行所必需的功能，我们就可以完美地按照测试—编码—重构的TDD周期进行开发了，不用去考虑太多其他和测试无关的问题。

开发控制器时，我们不用花费精力和奇怪的API打交道，只将注意力集中在实现功能上。在实现过程中，可能会遇到本书第一部分中提到过的问题，这时只要应用第一部分中提供的解决方法就可以了。

虽然JSP和Velocity模板不像控制器那样容易测试，不过还是有办法可以测试驱动视图开发的，也不用部署到容器之中。JspTest框架能让我们在单元测试中迅速的完成JSP的渲染；用内嵌的Jetty web容器也是一种不错的解决方法。要测试驱动Velocity模板，我们基本不用任何外界帮助，因为用Velocity的API渲染页面不依赖于任何容器。

随着JSF及一些开源框架如Apache Tapestry、Wicket等的出现，基于控件的Web框架也逐渐升温。因为这类框架比请求—应答类型的框架更复杂，测试工作也稍显困难。不过由于Wicket框架具有很强的可测试性的意识，测试驱动Web开发就像逛公园一样轻松自在。

我们现在已经介绍完了Java EE web开发的相关问题，在下一章中，我们将会讨论所谓的数据访问层（data access layer），介绍如何进行TDD。

第6章　测试驱动数据访问

资讯是民主社会的流通货币。

——托马斯·杰斐逊

数据无处不在。想想看，你开发的系统有几个不涉及数据持久化的？当然，万事无绝对，不过当今大部分企业应用中都会涉及数据持久化。因此，数据是这类系统开发工作的核心，也是开发人员工作的重点。此外，如果要选出企业级应用最重要的部分，数据一定会当仁不让。虽然很多开发人员都用眼睛观察数据来判断对错，不过这种方法效率低且容易出错，这可不是我们专业开发人员能够忍受的。

在前面的章节中，我们并没有特别关注数据部分，也没有提到过数据的来源。虽然我们曾用TDD实现过数据持久化的功能，不过并没有讨论过持久层本身的设计实现问题。本章将专门讨论数据层的相关问题。

本章结束后，我们将会掌握数据访问及维护代码的测试驱动方法，无论数据是在文件系统中还是在数据库中。我们将会重点讨论一些可测试性好的设计方法和一些有用的工具，这些知识能帮我们顺利地进行TDD。与此同时，我们也会讨论单元测试、集成测试及测试数据管理方法等各种技巧的优劣。

现在开始吧。

6.1　探索问题领域

放箭之前，先瞧瞧靶子。首先，我们要弄清数据访问代码与“普通”代码在测试驱动方法上的区别。

数据访问代码通常会跨越系统架构的很多层，这是它与其他类型的代码最显著的差别。另一个主要差别在于数据访问代码通常会使用第三方API，这给测试工作带来了一定难度。我们需要想办法不让这些第三方的东西总访问磁盘或数据库服务器。

下一步，我们来确定在Java或Java企业应用中，数据库访问代码通常会跨越哪些边界。然后会学习一个帮我们提高数据访问代码可测试性的设计模式。

6.1.1　跨越边界的数据访问

数据访问代码显然会跨越系统边界。应用逻辑会访问持久化逻辑，持久化逻辑会使用Hibernate或EJB3等持久化框架，或直接使用 JDBC（JDBC API通常由JDBC驱动程序提供实现，驱动程序会直接和关系数据库交互）。层级关系见图6-1。

图6-1　系统的逻辑层次划分

提示　在本书的在线章节中，会讲到用EJB 3.0持久层框架，测试驱动数据访问代码的方法。

理想情况下最好只测试驱动我们编写的代码，而不调用下面各层。测试之间应当相互独立，而且运行速度要足够快。在测试中包含太多层会增加耦合性也会延长测试运行时间，同时配置其他层所需的工作量也不小，多一事还不如少一事。而且，这些层开发时或许尚不存在。

在这种情况下，我们该如何测试驱动图6-1中各层呢？开发应用逻辑层（application logic layer）时，可以使用持久化逻辑伪实现。我们可以验证业务逻辑代码正确地调用了持久层方法，传入了正确的参数。那么我们在开发持久层时应该模拟JDBC API（或其他任何正在使用的持久化框架接口），还是该访问数据库？应该使用真实的数据库，还是使用轻量级，速度飞快的，但和真实数据库系统有一定差别的内存数据库？

在本章的后半部分我们将详细讨论这些问题。我们将用不同方法解决同一个问题，客观地对比每种方式的优劣。不过在这之前，我们先来熟悉一个设计模式，它能帮我们把代码恰当地划分到不同层之中。

6.1.2　用DAO模式分层

也许是因为Java EE社区一直很热衷于讨论设计模式和架构蓝图（architecture blueprints），或许是因为模式和蓝图本身就是正确的做事方式，不过无论是什么原因，在过去5年中，我见到的大部分Java EE系统都采用了数据访问对象（Data Access Object，DAO）模式^1或其变体。

1 此模式在Core J2EE Patterns：Best Practices and Design Strategies （Deepak Alur, Dan Malks, and John Crupi; Addison-Wesley, 2003）一书中有详细讲解。

此模式是指每个持久化的领域对象（domain object）都应该有一个DAO接口负责其持久化工作。具体持久化方式有很多种，可以是关系数据库、对象数据库、或是文件系统上的一堆XML文件。对象本身及DAO的调用者并不知道对象从哪里来，到哪里去，以及是否具有持久性。因此，我们可以随意替换持久化的具体实现技术，而上层应用程序对此不会有一丝察觉。见图6-2。

图6-2　DAO模式：由DAO接口创建、获取及持久化的领域对象，对底层的数据库技术一无所知

接下来，我们会再次使用此模式来解释测试驱动数据访问代码应用各种不同技术的过程。为了让你更好地了解DAO接口，并为后面的举例做基础，代码清单6-1展示了一个简单的PersonDao接口持久化Person对象的过程。

代码清单6-1　PersonDao接口对应用代码隐藏了实现细节

public interface PersonDao {

Person find(Integer id);

void save(Person person);

void update(Person person);

void delete(Person person);

List<Person> findAll();

List<Person> findByLastname(String lastname);

}

比起把数据访问代码与应用逻辑代码混在一起，使用DAO模式的好处显而易见。另外使用DAO模式还可以缩减代码量。如果使用了Hibernate或Spring框架的JdbcTemplate等现成的开源持久层框架，能够显著加快开发速度。好的框架能帮你做不少工作，开发人员就可以专心开发持久化逻辑，而不用在持久化技术细节上花太多功夫了。

提示　PersonDao所引用的Person类的源代码等可以在本书配套网站上的源代码中找到。

6.2　用单元测试驱动数据访问

多年来Java开发人员一直使用JDBC访问关系数据库。特别是20世纪90年代的IT业泡沫时期，几乎所有人都摇身一变，成了Java开发人员。在这个时期，许多系统的源代码中随处可见JDBC代码。这带来了很多麻烦，例如想要改变数据库某列的名字，需要找到列名出现的所有地方，一一进行修改才行。这么做显然是错误的，今天的开发人员已经对这种错误有更深刻的认识了。

本节中，我们将会学习Spring框架的JdbcTemplate，还有Hibernate，这两种框架能够显著简化开发工作。另外，我们也会学习如何使用单元测试来驱动这类代码的开发。不过在这之前，我们先来学习Java数据库访问的基础——JDBC API——以及如何测试驱动JDBC数据访问代码。

在开始前，应该先给单元测试一个明确的定义。我发现每个人口中的“单元测试”含义都不尽相同。有些人甚至把一个调用外部系统命令，创建出一个JVM实例，然后运行另外一个Java程序的JUnit测试也称作单元测试。这种测试可以称作开发人员测试（developer test），不过在我看来绝对不是单元测试。引用Michael Feathers对单元测试的定义^1，在以下情况下一个测试不是单元测试：

1 见《修改代码的艺术》，人民邮电出版社，2007年出版。

- 访问了数据；

- 有网络通信；

- 访问了文件系统；

- 不与其他任何单元测试同时运行；

- 必须配置好环境后才能运行。

做这些事情要费一定周折，而且会使测试运行速度变慢——比用测试替身要慢几个量级。因此最好不要在单元测试中频繁的访问数据库，而是使用伪实现。在本节中，我们将会使用前面提到的各种框架和API，结合使用测试替身，驱动数据访问对象的开发。

6.2.1　JDBC API的缺点

JDBC是不少人选择Java平台进行开发的主要原因之一，因为统一的数据库访问方式非常重要。不过，JDBC的API若能再简单些就更好了。JDBC迫使开发人员编写try……catch块来处理checked 异常，确认各种资源都能够释放成功，等等机械式的代码。此外，复杂的JDBC代码很不容易测试。下面我们来试着用JDBC技术测试驱动出代码清单6-1中PersonDao的实现。

我们先来实现PersonDao的findByLastname方法。因为要使用JDBC技术实现DAO，所以需要创建JDBC连接的测试替身。当DAO执行查找操作时，这个假的JDBC连接会返回一个硬编码的值。而DAO该从哪里获取JDBC连接？当然是DataSource。在测试环境下，我们需要给DAO传入一个DataSource的测试替身。而在产品模式下，我们会使用依赖注入给DAO传入真实的DataSource。如下所示：

JdbcPersonDao dao = new JdbcPersonDao();

dao.setDatasource(datasource);

List<Person> people = dao.findByLastname("Smith");

现在，我们首先需要创建一个DataSource对象，避免在单元测试中连接数据库。

1. 创建测试替身

现在还缺DataSource，可以用EasyMock创建一个模拟对象。除去static import声明，用EasyMock创建模拟对象只需要一行代码：

import static org.easymock.EasyMock.*;

...

DataSource datasource = createMock(DataSource.class);

执行findByLastName时，DAO应该会从DataSource获取Connection对象。为此，我们还需要一个Connection接口的测试替身。然后，DAO会调用connection对象来准备（prepare）一个含有last name参数的SQL语句。为此，我们还要准备一个PreparedStatement接口的测试替身。看看一共有多少模拟对象：

DataSource datasource = createMock(DataSource.class);

Connection connection = createMock(Connection.class);

expect(datasource.getConnection()).andReturn(connection);

String sql = "SELECT * FROM people WHERE last_name = ?";

PreparedStatement stmt = createMock(PreparedStatement.class);

expect(connection.prepareStatement(sql)).andReturn(stmt);

stmt.setString(1, "Smith");

已经不少了，但后面还有。接下来，我们会让DAO执行查询语句。因为executeQuery方法会返回一个java.sql.ResultSet对象，所以还要继续创建测试替身。

2. 用static mock创建ResultSet的伪实现

这次，我们不打算用EasyMock了，而会使用MockObjects.com的类库提供的伪实现。如果为每个方法调用都设置期望，会使测试代码量急剧膨胀（特别是对于大结果集），而我们对DAO如何从ResultSet中取出结果毫无兴趣，只关心能否从数据集中取出正确的people列表，所以没有必要使用EasyMock。

下面这段代码配置了MockMultiRowResultSet对象，同时使PreparedStatement对象的executeQuery方法返回配置好的结果集对象。

MockMultiRowResultSet resultset = new MockMultiRowResultSet();

String[] columns = new String[] { "first_name", "last_name" };

resultset.setupColumnNames(columns);

List<Person> smiths = createListOfPeopleWithLastname("Smith");

resultset.setupRows(convertIntoResultSetArray(smiths));

expect(stmt.executeQuery()).andReturn(resultset);

一方面，填给MockMultiRowResultSet对象的数据必须是二维数组，另一方面，要验证DAO的findByLastname方法返回的Person对象的数组的正确性，我们需要有正确数据来作对比。在这种情况下，为了减少数据重复，我们可以创建一个people对象列表，然后将这个列表转化为二维对象数组后传给MockMultiRowResultSet，在对比DAO的输出时也可以直接使用这个列表。

当所有的工作都完成后，DAO应该释放掉所有的JDBC资源：

resultset.setExpectedCloseCalls(1);

stmt.close();

connection.close();

3. 为JDBC代码写测试

代码清单6-2为测试的最终形式，完整地描述出了DAO应当具有的行为。如果一切顺利，数据库中应该包含3条匹配的people数据。

代码清单6-2　测试纯JDBC DAO实现

import static org.junit.Assert.*;

import static org.easymock.EasyMock.*;

import com.mockobjects.sql.*;

import org.junit.*;

import java.sql.*;

import javax.sql.*;

import java.util.*;

public class JdbcPersonDaoTest {

@Test

public void testFindByLastname() throws Exception {

DataSource datasource = createMock(DataSource.class);    /*❶（以下8行）模拟数据库连接*/

Connection connection = createMock(Connection.class);

expect(datasource.getConnection()).andReturn(connection);

String sql = "SELECT * FROM people WHERE last_name = ?";

PreparedStatement stmt =

createMock(PreparedStatement.class);

expect(connection.prepareStatement(sql)).andReturn(stmt);

stmt.setString(1, "Smith");

MockMultiRowResultSet resultset =     /*❷（以下9行）模拟查询结果*/

new MockMultiRowResultSet();

String[] columns = new String[]

{ "first_name", "last_name" };

resultset.setupColumnNames(columns);

List<Person> smiths =

createListOfPeopleWithLastname("Smith");

resultset.setupRows(asResultSetArray(smiths));

expect(stmt.executeQuery()).andReturn(resultset);

resultset.setExpectedCloseCalls(1);     /*❸（以下3行）释放资源*/

stmt.close();

connection.close();

replay(datasource, connection, stmt);

JdbcPersonDao dao = new JdbcPersonDao();     /*❹（以下6行）执行并验证*/

dao.setDatasource(datasource);

List<Person> people = dao.findByLastname("Smith");

assertEquals(smiths, people);

verify(datasource, connection, stmt);

resultset.verify();

}

private List<Person>

createListOfPeopleWithLastname(String lastName) {

List<Person> expected = new ArrayList<Person>();

expected.add(new Person("Alice", lastName));

expected.add(new Person("Billy", lastName));

expected.add(new Person("Clark", lastName));

return expected;

}

private Object[][] asResultSetArray(List<Person> people) {

Object[][] array = new Object[people.size()][2];

for (int i = 0; i < array.length; i++) {

Person person = people.get(i);

array[i] = new Object[] {

person.getFirstName(),

person.getLastName() };

}

return array;

}

}

这个测试有些庞大。首先，我们用EasyMock创建出了DataSource、Connection、PreparedStatement等JDBC接口的模拟对象❶。然后，用MockObjects.com的静态模拟对象创建出了测试替身，供ResultSet使用❷。最后记录释放所有JDBC资源的期望行为❸，把模拟的DataSource注入到JdbcPersonDao中去，接着调用查找方法，把返回的Person对象列表与DataSource中的数据作对比❹。这个测试代码算不上紧凑（尽管我看过比这还糟糕的），但是用JDBC接口只能这样了。

4. 写代码通过测试

代码清单6-3中是findByLastname方法的完整实现。

代码清单6-3　纯JDBC DAO的实现

import javax.sql.*;

import java.sql.*;

import java.util.*;

public class JdbcPersonDao implements PersonDao {

private DataSource datasource;

public void setDatasource(DataSource datasource) {

this.datasource = datasource;

}

public List<Person> findByLastname(String lastname) {

try {

Connection conn = datasource.getConnection();

String sql = "SELECT * FROM people WHERE last_name = ?";

PreparedStatement stmt = conn.prepareStatement(sql);

stmt.setString(1, lastname);

ResultSet rset = stmt.executeQuery();

List<Person> people = new ArrayList<Person>();

while (rset.next()) {

String firstName = rset.getString("first_name");

String lastName = rset.getString("last_name");

people.add(new Person(firstName, lastName));

}

rset.close();

stmt.close();

conn.close();

return people;

} catch (SQLException e) {

throw new RuntimeException(e);

}

}

// Other PersonDao methods not shown

}

目前只有在所有JDBC API调用都不抛出异常时连接才能正常关闭，所以还要继续写测试，验证即使API调用出现问题时连接也会正常关闭。

手工做所有工作当然很麻烦，所以需要选个好的框架替我们处理这些问题。有许多开源项目试图简化Java和Java EE应用程序中的数据访问过程，不过我们在这里只会挑其中两个进行介绍：Spring框架中的JDBC支持框架，和已经把ORM（对象—关系映射，object-relational mapping）带入Java主流的Hibernate框架。

先从Spring框架中的JdbcTemplate开始。

6.2.2　用Spring的JdbcTemplate简化开发

Spring框架中提供了JdbcTemplate类，目的是简化开发过程，让编码工作只集中在最核心的功能上。大多数JDBC代码都有某种固定模式，例如把try-catch结构与条件资源释放代码结合起来使用，确保所有资源都会被释放，等等。JdbcTemplate设计的初衷就是消除这些冗余代码。JdbcTemplate使用了类似于Template Method的设计模式^2，开发人员只需要关注变化的部分，框架会处理其他重复代码。见图6-3。

2 见Design Patterns: Elements of Reusable Object-Oriented Software（Addison-Wesley，1994）。

图6-3　Spring框架提供的模板代码与开发人员提供的DAO实现之间的交互

Spring中的JdbcDaoSupport虚基类实现了许多数据访问功能，数据访问对象可以通过继承此虚基类来使用这些功能。见图6-4。

图6-4　Spring的JdbcDaoSupport提供了JdbcTemplate功能，DAO实现继承自JdbcDaoSupport

图6-4中的JdbcDaoSupport虚基类提供了一个getter方法，用于获取JdbcTemplate的实例。JdbcTemplate类封装了JDBC接口各种繁杂的的细节，提供了抽象层次较高的接口，例如：

List query(String sql, Object[] args, RowMapper rowMapper)

注意，此方法签名上没有使用到Connection、PreparedStatement等任何JDBC对象。我们只需要传入SQL语句、SQL语句中变量值数组和RowMapper即可。RowMapper是Spring定义的接口，用于记录和对象间的转换。RowMapper接口的完整定义如下：

public interface RowMapper {

Object mapRow(ResultSet rs, int rowNumber)

}

RowMapper接口依赖于JDBC的ResultSet接口，不过这种依赖关系隐藏在RowMapper内部并且可以单独测试，因此不会给DAO的测试工作带来任何麻烦。此外，同一个RowMapper可以在所有此表的相关查询上进行重用。

除了query方法外，JdbcTemplate类还提供了许多其他方法。不过我们暂时不会深入介绍这些方法，而是先用这个query方法实现代码清单6-1中PersonDao的findByLastname方法。为此我们需要一个RowMapper把数据库记录转成Person对象，所以就从这点入手吧。

1. 实现RowMapper

在这个例子中，我们假设Person对象只有两个属性：姓和名。这两个属性分别对应于数据库的last_name和first_name两列。代码清单6-4中为相应的测试。

代码清单6-4　测试Person对象的RowMapper实现

import org.springframework.jdbc.core.RowMapper;

import com.mockobjects.sql.MockSingleRowResultSet;

import java.util.*;

import org.junit.*;

import static org.junit.Assert.*;

public class PersonRowMapperTest {

@Test

public void testMappingRow() throws Exception {

Person expected = new Person("John", "Doe");

Map<String, Object> data = new HashMap<String, Object>();    /*❶（以下5行）测试模拟ResultSet对象*/

data.put("first_name", expected.getFirstName());

data.put("last_name", expected.getLastName());

MockSingleRowResultSet rs = new MockSingleRowResultSet();

rs.addExpectedNamedValues(data);

assertEquals(expected,

new PersonRowMapper().mapRow(rs, 1));    // ❷执行映射并对比结果

}

}

在代码清单6-4中，我们首先创建出一个ResultSet对象❶，代表一条数据库记录，然后把RowMapper返回的值与Person实例作对比❷。实现如代码清单6-5所示。

代码清单6-5　简单的PersonRowMapper实现

import java.sql.*;

import org.springframework.jdbc.core.RowMapper;

public class PersonRowMapper implements RowMapper {

public Object mapRow(ResultSet rs, int rowNum)

throws SQLException {

return new Person(rs.getString("first_name"),

rs.getString("last_name"));

}

}

这段代码直接把数据库记录映射成了对象，不过这只是最基本的情形。除此之外还有其他情况需要考虑，例如把一个列的值映射到Person对象的多个属性上，Null值，Boolean类型和数值类型的转换，等等。针对每种情况都需要添加相应的测试及实现。

现在，既然已经有了PersonRowMapper实现，那么就可以继续前进，测试JdbcTemplate DAO的其他功能了。

2. 用JdbcTemplate测试驱动DAO

基于JdbcTemplate的DAO除了要实现PersonDao接口外，还要继承Spring框架的JdbcDaoSupport。JdbcDaoSupport提供了对JdbcTemplate的访问，DAO可以使用JdbcTemp late提供的方法操作数据，而资源清理会由框架来做。

现在我们要测试驱动出findByLastname方法，在实现中会用到query方法。此外我们还需要使用JdbcTemplate类的测试替身，其query方法会返回一个硬编码的Person对象的列表。

完整的测试代码见代码清单6-6。

代码清单6-6　基于JdbcTemplate的PersonDao实现的完整测试

import static org.easymock.classextension.EasyMock.*;

import static org.junit.Assert.*;

import java.util.*;

import org.junit.Test;

import org.springframework.jdbc.core.JdbcTemplate;

public class JdbcTemplatePersonDaoTest {

@Test

public void testFindByLastname() throws Exception {

final String lastName = "Smith";

final List<Person> smiths =

createListOfPeopleNamed(lastName);

JdbcTemplate template = createMock(JdbcTemplate.class);

template.query(      /*❶（以下5行）从query()中返回硬编码的people列表*/

eq("SELECT * FROM employee WHERE last_name = ?"),

aryEq(new Object[] { lastName }),

isA(PersonRowMapper.class));

expectLastCall().andReturn(smiths);

replay(template);

JdbcTemplatePersonDao dao = new JdbcTemplatePersonDao();

dao.setJdbcTemplate(template);     // ❷用模拟JdbcTemplate注入DAO

assertEquals(smiths, dao.findByLastname(lastName));     // ❸DAO应像这样返回列表

verify(template);

}

private List<Person> createListOfPeopleNamed(String lastName) {

List<Person> expectedList = new ArrayList<Person>();

expectedList.add(new Person("Alice", lastName));

expectedList.add(new Person("Billy", lastName));

expectedList.add(new Person("Clark", lastName));

return expectedList;

}

}

代码清单6-6中的测试首先创建了一个JdbcTemplate的模拟对象，当以参数SELECT * FROM employee WHERE last_name = ?调用query方法时，query方法会返回一个硬编码的Person对象列表❶。然后我们把模拟的JdbcTemplate对象注入到DAO中❷，接着调用finder方法，最后验证返回的结果与query方法的查询结果一致❸。

代码清单6-6中的代码用到了EasyMock的自定义参数匹配功能。这是因为EasyMock不支持数组匹配，而我们又正好需要这项功能。你可能对EasyMock的自定义匹配器不大熟悉，所以有必要在这里做个简要介绍，以了解EasyMock是如何处理query调用的。

3. EasyMock及自定义匹配器

在EasyMock中，要录制期望行为，通常都会直接调用方法，然后再调用andReturn方法，告诉EasyMock该方法的返回值。在播放模式下，EasyMock会记录所有方法调用。最后，在EasyMock验证模拟对象的交互行为时，EasyMock会把期望的函数调用与实际函数调用做对比，用对象的equals方法判断参数是否相等。

有时equals方法不能正确对比实际参数与期望参数——比如数组，即使两个数组含有完全一样的对象，数组也不相等。此外，有时候我们并不关心参数的精确值。例如，只希望确认字符串以某个字符开头，或者某个对象属于某个类型等。针对这些情况，EasyMock提供了机制以改变equals默认行为。

在代码清单6-6中，有如下几条语句：

template.query(

eq("SELECT * FROM employee WHERE last_name = ?"),

aryEq(new Object[] { lastName }),

isA(PersonRowMapper.class));

eq、aryEq以及isA等方法都由EasyMock中静态导入（static import）。这些方法目的只有一个：在当前执行环境中注册IArgumentMather接口的实现。eq方法注册的实现会直接调用对象的equals方法来对比两个对象。argEq方法注册的实现会先对比数组的长度，如果长度相等，则遍历数组元素，调用每个元素的equals方法做对比。isA方法所注册的实现用于检查参数是否是指定类的实例。

由于Java语言自身的语义，eq、aryEq以及isA方法会先于query方法执行。所以当模拟对象的query方法被调用时，新的参数匹配器已经注册完成，在稍后的replay模式下，EasyMock会使用这些匹配器。

在EasyMock项目的网站上，可以找到更多关于框架的高级特性。现在，我们来继续PersonDao的实现工作吧。

4. 用JdbcDaoSupport实现DAO

在测试中，我们把JdbcTemplate注入给了DAO，期望DAO会调用JdbcTemplate的query方法，且参数符合一定规则。这基本上就是DAO的全部工作了，JdbcTemplate会完全负责JDBC资源的清理工作。代码清单6-7中为DAO的完整实现。

代码清单6-7　基于JdbcTemplate的PersonDao实现

import java.util.List;

import org.springframework.jdbc.core.RowMapper;

import org.springframework.jdbc.core.support.JdbcDaoSupport;

public class JdbcTemplatePersonDao extends JdbcDaoSupport

implements PersonDao {

public List<Person> findByLastname(String lastname) {

String sql = "SELECT * FROM employee WHERE last_name = ?";

String[] args = new String[] { lastname };

RowMapper mapper = new PersonRowMapper();     // 使用PersonRowMapper将对象进行行转换

return getJdbcTemplate().query(sql, args, mapper);    // 将难题委托给JdbcTemplat

}

}

JdbcTemplate消除了数据访问对象中的所有重复代码，开发人员只需将精力集中在查询逻辑上——这使测试更紧凑，也更容易维护。

JdbcTemplate给数据访问代码的编写和测试带来的便利毋庸置疑，不过我个人更喜欢下面要介绍的Hibernate框架。

6.2.3　用Hibernate轻松地做TDD

虽然Spring的JdbcTemplate及RowMapper简化了JDBC代码的编写工作，不过映射领域对象到数据库列的代码依旧很无聊，最好能省掉这部分工作。给领域对象添加属性时，肯定会修改领域对象的代码，既然领域对象定义已经修改了，那为什么还要重复地改RowMapper来映射新属性呢？如果持久层能够自己处理新属性，岂不是更好？

现代ORM框架，例如Hibernate，已经能够做这类工作了。如果使用Hibernate，我们只需要给字段添加annotation即可！而框架会把领域对象中标有注释的字段映射到数据库表的对应列上。合理的默认行为是关键所在。领域对象的大部分字段都需要持久化，但如果不需要，则可以给此字段标上@javax.persistence.Transient，或者加上transient关键字即可。

这些优秀特性使得Hibernate成了Java领域最流行的持久化框架之一。Hibernate应用广泛，所以最好能掌握其用法，特别是要学会如何测试或者测试驱动基于Hibernate的数据访问对象。下面，我们先来了解一些Hibernate API的主要概念，然后再来看看Hibernate会如何改变单元测试的编写方式，以及如何简化数据访问代码。

1. Hibernate API精要

Hibernate完全封装了JDBC API。不过若有必要，用户可以绕过Hibernate的封装，直接访问底层的JDBC连接。另外，Hibernate还提供了特殊的查询语言HQL（Hibernate查询语言，Hibernate Query Language），如果需要使用底层数据库的特殊功能，那么也可以直接使用SQL。

SessionFactory、Session和Query是Hibernat中最重要的抽象；这些类之间的协作关系见图6-5。

SessionFactory是Hibernate的基础。它封装了DataSource，对外提供接口以供调用，此外，它还提供访问Session的功能。Session基本等同于JDBC连接，区别在于Session会使用JDBC API做持久化及优化工作。有了Session对象，我们就可以直接调用它提供的方法做基本的CRUD操作了，也可以用Session创建出的Query对象进行查询。而Query主要用于构建动态查询。

图6-5　SessionFactory、Session和Query接口是Hibernate API中的主要概念

要用Hibernate实现数据访问对象，这三个接口就够了。那么下一个问题是，既然这些接口的实现都由SessionFactory负责提供，那么如何获得SessionFactory？你可能会想，DAO能够通过依赖注入拿到SessionFactory。不过就算是依赖注入，那也要先有一个对象，才能够做注入。这个问题有两个答案，都涉及了Configuration类。

第一种方式相对常见。首先在类路径中添加一个外部配置文件，此文件可以是属性文件（hibernate.properties）也可以是XML文件（hibernate.cfg.xml）。然后让Configuration类加载配置文件，构建出SessionFactory。SessionFactory的各项属性，如连接池、缓存，等等都会从配置文件中读取。

另外一种方式不太常见。我们可以直接用Java代码创建出Configuration对象，然后从此对象中获取一个动态配置的SessionFactory对象。由于可以在代码中动态配置，所以用JUnit等测试框架构建动态集成测试时，这种方式更方便。而以配置文件的形式管理测试的配置，更容易与产品配置保持一致。

本章主要内容是TDD，不是Hibernate，所以先介绍这么多吧。要构建Hibernate DAO，目前了解的SessionFactory、Session和Query的相关知识已经足够了。而且，为了避免在单元测试中访问数据库，我们打算模拟（mock）所有这些东西。下面来用Hibernate再实现一遍PersonDao吧。

2. 写测试验证期望的交互行为

下面来构建基于Hibernate的DAO吧——API相当简单，模式也很容易理解，基本上就是体力活（这是好事，因为我们可以在测试间共享setup步骤）。

假设现在我们要实现DAO中的findByLastname方法。首先，我们需要SessionFactory及Session。此外，还需要使用Query对象实现查找功能。在测试中先用EasyMock创建出这三个接口的动态模拟对象，见代码清单6-8。

代码清单6-8　测试驱动基于Hibernate的DAO的基础设施

import static org.easymock.EasyMock.*;

import org.junit.*;

import org.hibernate.*;

import org.hibernate.classic.Session;

public class HibernatePersonDaoTest {

private SessionFactory factory;

private Session session;

private Query query;

@Before

public void setUp() {

factory = createMock(SessionFactory.class);

session = createMock(Session.class);

query = createMock(Query.class);

}

}

接下来就可以写测试了。我习惯在测试的前几行代码中定义HQL语句及查询参数。例如，用姓氏找人的测试可以写成：

@Test

public void testFindByLastname() throws Exception {

String hql = "from Person p where p.lastname = :lastname";

String lastname = "Smith";

...

}

此外，查找叫做Smith的人时，可以让模拟对象返回Person列表。下面来添加一群Smiths：

...

String name = "Smith";

List<Person> theSmiths = new ArrayList<Person>();

theSmiths.add(new Person("Alice", name));

theSmiths.add(new Person("Billy", name));

theSmiths.add(new Person("Clark", name));

...

这样就可以了。接下来我们要定义DAO与Hibernate API之间的交互，这是测试工作的核心。

我们来假设当前的架构支持用横切（crosscutting）方式开关回话，就像Servlet过滤器（filter）一样，让开发人员免于开关回话工作。

这样，所有的DAO类都会使用当前Session，而不是各自使用SessionFactory创建出的新Session。接下来，因为已经有了Session对象，DAO应该用Session创建出Query对象，并作为参数传入HQL。最后，给Query对象传入查询参数后，DAO应该让Query对象列出所有匹配的Person对象（然后返回对象列表）。代码清单6-9中是对应的EasyMock代码。

代码清单6-9　为Hibernate DAO设置期望行为

...

expect( factory.getCurrentSession() ).andReturn(session);

expect( session.createQuery(hql) ).andReturn(query);

expect( query.setParameter("lastname", name) ).andReturn(query);

expect( query.list() ).andReturn(people);

...

配置模拟对象，让SessionFactory的getCurrentSession方法返回Session的模拟对象，此模拟对象又会返回Query接口的模拟对象。配置完成后进入回放模式，执行代码。代码清单6-10中是findByLastname方法的完整测试。

代码清单6-10　findByLastname方法的完整测试

import static org.easymock.EasyMock.*;

import static org.junit.Assert.*;

import org.junit.*;

import java.util.*;

import org.hibernate.*;

import org.hibernate.classic.Session;

public class HibernatePersonDaoTest {

private SessionFactory factory;

private Session session;

private Query query;

@Before

public void setUp() {

factory = createMock(SessionFactory.class);

session = createMock(Session.class);

query = createMock(Query.class);

}

@Test

public void testFindByLastname() throws Exception {

String hql = "from Person p where p.lastname = :lastname";

String name = "Smith";

List<Person> theSmiths = new ArrayList<Person>();

theSmiths.add(new Person("Alice", name));

theSmiths.add(new Person("Billy", name));

theSmiths.add(new Person("Clark", name));

expect(factory.getCurrentSession()).andReturn(session);    /*❶（以下5行）记录期望的API调用*/

expect(session.createQuery(hql)).andReturn(query);

expect(query.setParameter("lastname",

name)).andReturn(query);

expect(query.list()).andReturn(theSmiths);

replay(factory, session, query);

HibernatePersonDao dao = new HibernatePersonDao();

dao.setSessionFactory(factory);     // 手动注入SessionFactory

assertEquals(theSmiths, dao.findByLastname(name));     // 期望的询查方法返回硬编码列表

verify(factory, session, query);

}

}

用EasyMock录制Hibernate API调用❶，比用EasyMock录制JDBC调用容易得多。一方面是因为Hibernate接口需要调用的函数并不多，另一方面由于Query接口中的方法大部分都有返回值，所以能使用紧凑的expect(...).andReturn(...)语法。有了依赖注入功能，也很容易让DAO实现使用测试替身。

关于Hibernate API的优点就先说到这里吧，下面来让代码清单6-10中的测试通过。

3. 做实现

有了Hibernate，DAO的查询方法很容易实现，见代码清单6-11。

我们把注入的SessionFactory保存在一个私有变量中❶，调用findByLastname方法时，会访问当前session❷，创建一个query对象❸，并填充该对象❹，然后进行查寻，返回结果列表❹。这就是代码清单6-11中的代码做的所有的事情。

代码清单6-11　PersonDao的Hibernate实现。

import java.util.*;

import org.hibernate.*;

public class HibernatePersonDao implements PersonDao {

private SessionFactory sessionFactory;

public void setSessionFactory(SessionFactory sessionFactory) {

this.sessionFactory = sessionFactory;      // ❶存储注入的SessionFactory

}

public List<Person> findByLastname(String lastname) {

Session session = sessionFactory.getCurrentSession();    // ❷获得对话

String hql = "from Person p where p.lastname = :lastname";

Query query = session.createQuery(hql);    // ❸创建查询

query.setParameter("lastname", lastname);    // ❹植入查询

return query.list();

}

}

实现非常简单——有点太简单了。在测试中，我们甚至没有验证DAO的异常处理功能。在养成忽略异常处理的坏习惯之前，我们来看看如何在基于Hibernate的DAO中处理这些问题吧。

4. 测试异常

我们假设当Hibernate API抛出异常时，DAO应当从findByLastname方法中抛出一个unchecked异常。这个异常中应该包含源异常，作为其产生原因。我们可以用6-12中的测试描述这个行为：

代码清单6-12　测试即使抛出异常，session也会被正确关闭

@Test

public void testFindByLastnameReturnsEmptyListUponException()

throws Exception {

String hql = "from Person p where p.lastname = :lastname";

String name = "Smith";

HibernateException hibernateError = new HibernateException("");

expect(factory.getCurrentSession()).andReturn(session);

expect(session.createQuery(hql)).andReturn(query);

expect(query.setParameter("lastname", name)).andReturn(query);

expect(query.list()).andThrow(hibernateError);    // ❶使list()抛出异常

replay(factory, session, query);

HibernatePersonDao dao = new HibernatePersonDao();

dao.setSessionFactory(factory);

try {       /*（以下6行）list()findByLastname() 将HibernateException打包进RuntimeException*/

dao.findByLastname(name);

fail("should've thrown an exception");

} catch (RuntimeException expected) {

assertSame(hibernateError, expected.getCause());

}

verify(factory, session, query);

}

这次，不让模拟的Query返回people列表了，而是让其抛出HibernateException❶。相应的，在测试中不应该期待返回列表，而是期待抛出包装了源异常HibernateExpception的RuntimeException。

产品代码也要做相应的修改。我们的测试期望Query对象的list方法在失败时会抛出RuntimeException。要通过这个测试有种快捷的办法：用try-finally包住list方法，无论何时都抛出异常，然后再多写几个测试，驱动出正确的实现。不过这个功能很容易实现，所以没有必要这么做，直接实现功能即可。代码清单6-13中为完整实现。

代码清单6-13　实现基本的异常处理

public List<Person> findByLastname(String name) {

try {

Session session = sessionFactory.getCurrentSession();

String hql = "from Person p where p.lastname = :lastname";

Query query = session.createQuery(hql);

query.setParameter("lastname", name);

return query.list();

} catch (HibernateException e) {

throw new RuntimeException(e);

}

}

这段代码有些繁琐，不过健壮性很不错，因为它包括各种异常情况处理，例如在一个事务未执行完毕时数据库连接突然中断等。同样，如果想要DAO方法从SessionFactory中获取新的Session，那么测试中也应该验证，抛出异常时Session对象会关闭，不会让Session对象一直连接着数据库。

用模糊匹配和命名查询减少重复

测试中含有查询语句，使测试代码和产品代码中的数据产生了重复。可以让测试代码引用产品代码中定义的常量，不过这种解决方法也不够彻底。

这个问题的解决方法之一是在测试中使用模糊匹配，并不精确验证查询语句。在进行集成测试时再验证查询语句的正确性。EasyMock等框架可以自定义匹配规则，在自定义的匹配规则中使用正则表达式等技术就可以轻松验证传入的查询语句的正确性。使用这种方法，摸拟对象只检查产品代码是否将正确的参数传给了API。

用命名查询（named query）也可以减少重复。包括Hibernate在内的现代ORM工具都支持外部配置的命名查询。这样，写单元测试时就不用关注查询语句的语法了。当然，查询的正确性依然需要后续的集成测试来验证。

说到数据库连接，我们先从使用测试替身来测试驱动数据访问对象的工作中跳出来，从整体角度考虑问题。迄今为止，所有的测试都使用了测试替身，这些测试替身可以验证查询语句、查询参数是否正确，也可以返回硬编码的查询结果。不过，这些查询在真实数据库环境下能正确运行吗？

答案是：不知道。对使用的工具越熟悉，越有可能写出正确的查询语句，不过在真实测试过之前，还不能完全保证查询一定正确。这就是下面要讨论的——用集成测试而非单元测试驱动数据访问代码。

6.3　编码前写集成测试

目前，我们用单元测试驱动出的数据访问对象，在使用测试替身时能正常工作，这些测试替身都是依照我们对底层数据库的理解，或者什么样的查询可以得到期望的结果而配置的。使用模拟对象，可以有效地测试异常情况，简化测试数据维护。而另一方面，刚才已经提到了，只有在真实的数据库环境中执行数据访问代码，才能确保代码的正确性。

因此，驱动数据访问对象的开发时也应该使用“集成测试”。

6.3.1　什么是集成测试

在集成测试中，数据访问代码会访问真实的数据库。也就是说，集成测试会从持久层一直覆盖到关系数据库（参照图6-1）。就算不覆盖数据库，至少也要覆盖到JDBC驱动。这里的“至少”是什么意思？下面将会解释。

集成测试中，会端到端地测试数据访问组件、持久化框架以及关系数据库。不过在测试中，并不一定要选用与生产环境完全一致的数据库，也可以考虑选用其他数据库。而选用什么替代数据库，取决于开发环境和产品环境允许存在什么样的差别。

1. 产品和测试间的区别

在生产环境中，可能拥有一个应用程序服务器集群，运行着Oracle数据库，而应用程序中会使用集群DataSource，访问集群上的数据库。在生产环境中，持久层会调用持久化框架，持久化框架会访问JDCB连接，JDBC连接会访问集群JDBC驱动，集群JDBC驱动又会访问其他一些东西，然后才会真正地访问数据库。这些特性使得系统具有高可用性和高扩展性，不过这些东西比起硬编码查询结果的测试替身，运行速度还是要慢几个数量级。而在TDD过程中，执行速度至关重要。

并不是所有系统都需要一个重量级的，支持负载均衡的集群。有些系统只需要一个独立地运行在本地的关系数据库。有些系统甚至会部署一个内嵌的关系数据库。尽管不同系统使用的数据库之间区别很大，但只在要某种数据库上进行过集成测试，我们就有理由相信持久化逻辑在其他类型数据库上也不会有问题。

那么，在集成测试中，应该针对数据访问代码的哪些方面进行测试呢？有哪些是单元测试没有覆盖到的？

2. 单元测试中漏掉了什么

测试一定要运行得飞快。就算达不到这种速度，至少也不能差很多。集成测试环境并不一定要与产品环境完全相同。回顾为数据访问代码编写单元测试时所作的基本假设，存在3条最大的风险：

- 关于数据库模式（database schema）和领域模型的假设；

- 关于所使用的查询语句的假设；

- 关于ORM的假设。

持久化API的调用不会有问题，因为在单元测试中已经进行了验证。JDBC驱动也不会有问题，因为这种第三方代码应该能正常工作^1。所以，可以集中讨论上面列出的3条风险。

1 为第三方软件编写测试也是可以的，例如learning test，不过这类测试与测试驱动数据访问代码毫无直接关系。

我们做了许多关于数据库模式和领域模型的假设，且这些假设大多与字段名和列名相关。这种假设有一定的风险，因为数据库访问代码是通过字符串，而不是通过编程语言自身的语法对这些字段或列进行引用的，因此编译器无法进行验证。

同样，在编译器看来，查询语句也只不过是字符串。其正确性取决于我们对查询语句语法的理解，有时候还取决于数据库支持的语法。因此，我们需要透彻理解所使用的工具，不过即便如此，仍旧可能出错。

关于ORM的假设实际上是对上面第1条假设的扩展。例如在为Hibernate DAO编写测试时，我们会配置模拟对象，期望调用查询函数时，会使用特定的查询语句作为参数进行查询，相应地查询函数会返回查寻结果。这样，我们实际上对查询中相关字段的映射做了假设。命名规则的确能够帮助减少很多问题，即便如此，拼写错误仍在所难免，这种错误可能会在产品环境中让客户发现——原来lastname错拼写成了lastName。

所以，我们需要想办法降低这些假设带来的风险。

3. 如何进行弥补

即使在TDD过程中构建出了一整套测试，错误仍旧可能出现。还好，只要有一个标准的关系数据库，所有可能出现的问题都可以一一得到验证。就算此数据库与产品环境中的不一样也没关系。毕竟无论使用什么样的数据库，只要其支持我们使用的SQL功能即可，因为我们只需要针对数据库模式执行我们自己的JDBC代码，而不关心使用的是什么数据库；或者，如果我们在使用Hibernate等ORM框架，则需要在框架支持的数据库上运行持久化代码和框架代码。

如果说“任何数据库”都可以使用，听起来有失精准。所以，下面我们就来讨论可选的数据库及各种备选数据库的优缺点，好理解各种选择在灵活性方面会带来什么限制。另外，我们还会讨论为集成测试选择数据库时应当考虑的各种因素。

6.3.2　选择数据库

为集成测试选择数据库时，有几个因素需要考虑：

- 测试环境与生产环境是否相近；

- 在团队中共享及管理配置是否容易；

- 访问和控制数据库是否容易。

下面从第一个开始讲解。

1. 与生产环境足够相似

虽然JDBC API都符合标准，我们在Java应用程序中访问的数据库也都遵循这个标准，但是仍然存在一些细微差别。就算所有集成测试都通过了，这种差别也可能导致我们的系统与产品环境的数据库不兼容，运转不正常，或者运行不稳定。

最明显的差别在于对标识（identity）及数据库特有的功能方面的支持。例如，只有高端商业数据库（像Oracle和DB2）才支持数据库序列（database sequence），而这种特性在广泛使用的开源数据库中则不常见（注释4：我希望这种情况会有所改变，标准 SQL:2003 已经加入了数据库序列。）。这类问题会给开发人员带来很多麻烦，例如，产品数据库为不支持SEQUENCE对象的MySQL，数据库中使用了自带的AUTO_INCREMENT列类型，而其他数据库基本上都不支持这种类型。此外，即使两个数据库都支持相同的概念，相关的语法也可能有差别。序列（sequence）、标识生成器（identity generator）以及存储过程就是常见的例子。

因为产品数据库及集成测试中使用的数据库的SQL语法要一致，常见的解决办法是把产生主键的过程从数据库端提取出来，放在应用程序的持久化层。此方法可能会导致其他问题，要解决这些问题，又要使用一些专属的数据库特性。在应用程序中产生标识并不是为了绕过数据库带来的问题，因为代码应该能完全控制领域模型的标识信息。

实际上，我们需要在架构复杂性（让一部分应用逻辑流到数据库端）和代码复杂性（实现序列生成器）之间做出取舍。还好，很多持久化框架（包括Hibernate）已经提供了这类功能。Hibernate提供了主键生成器，这个生成器是建立在独立的标识生成算法及序列列与标识列等数据库概念之上的。

关键字的差别是影响数据库之间兼容性的另一个常见原因。例如，现有系统使用的是MySQL，而集成测试却使用了HSQLDB^2。假设有一个订单表，保存了订单的相关item，表中每一行都有一列指向本item所属的订单，另外还有一列表示清单中本item的位置。我猜开发人员有可能会把标识位置的列名叫做position，而不是index或ordinal。虽然position是个好名字，不过它却是HSQLDB的关键字。不过没关系，列名和表名都很容易改，对轻量级数据库的使用没多大影响。

2 http://www.hsqldb.org/

数据库之间还存在一些微小的差别，例如多列UNIQUE约束的语义。虽然绝大部分数据库的多列UNIQUE约束都允许至少其中一列的值为null，但是产品数据库或者集成测试数据库并不一定遵守这个规则。

虽然可能出现上述各个问题，不过在集成测试时使用不同的数据库依旧可行。毕竟提高生产力至关重要，如果能在集成测试中使用更快，更容易配置的数据库，能带来很多好处。这些好处足以让我们考虑修改代码，来保持测试数据库和产品数据库之间的兼容性了。

这里提到的易用性，并不只是针对某一个开发人员，而是针对整个团队。换言之，初始化过程应该很容易在团队内部共享，而且易于维护。

2. 易于共享的配置

配置开发环境以运行测试所需要的工作量是选择集成测试数据库时要考虑的另外一个因素。理想情况下所有的环境应该都自动配置好，不需要任何手动干预。而实际上即使要手动安装数据库之类软件，只要配置过程不复杂，那么也是可以接受的。

若没有共享的配置，那么开发人员就需要自己维护配置，指定数据库服务器监听的端口，以及连接使用的用户名和密码。最好能够用配置管理系统维护这些配置信息，版本与软件自身版本相同，任何新的开发人员都不用手动配置。

维护工作的终极目标是让数据库服务器也从版本控制系统中下载，由build脚本自动安装。这种解决方案对于嵌入式数据库或者极其轻量的数据库（像HSQLDB或McKoi^3等）才可行。稍后，我们将会看到如何在测试套件中嵌入HSQLDB，不过在这之前，先来讨论影响集成测试中数据库选择的第3个因素吧。

3 http://mckoi.com/database/

3. 服务器的访问及控制

架好数据库服务器让集成测试成功地运行，只不过是第一步。我们还需要想办法能在测试失败时迅速找出原因。测试失败原因取决于所采用的数据库服务器、JDBC驱动和持久化框架，有时可以从错误信息中轻易的找到，而有时却需要费时费力的调试方能弄清楚。有时候，找出错误可不那么容易，出现问题时，并不总会遇到异常告诉我们语法无效或者“表中无此列”。要分析出查询结果和期望不一致的原因会很困难，除非我们可以方便地访问数据库以浏览数据。

如果在本地安装一个独立的数据库服务器来响应来自网络的连接请求，那么浏览数据会变得很容易。我们可以随意选用任何图形化或者控制台界面的工具浏览数据，执行SQL语句，等等。而内嵌的数据库就没这么方便了，因为我们通常只能通过Java API读取数据库的内容，这肯定没有用图形界面访问那么方便。因此，两种方法各有利弊。

我个人更偏向使用HSQLDB这样的内嵌数据库，因为它能与测试套件紧密集成，还不用安装。不过用HSQLDB这类数据库的缺点刚才已经说过了，但要解决这类问题还是有办法的，例如可以做个小工具把数据库的数据导出到外部文件中。而且，基本上没有其他东西比HSQLDB的集成测试更快了。

接下来，我们将会详细讲解如何在集成测试套件中使用HSQLDB，以及如何测试驱动数据访问代码。不过别担心，这不是什么太空科技，不会太困难，而且我们会和往常一样小步前进。

6.4　集成测试实战

在本节中，我们将会给本章稍前部分写的基于Hibernate的PersonDao添加更多的功能。添加功能只是个实验，其主要目的在于确定出集成测试所需搭建的架构，以及可能出现的问题。虽然我们现在用的是Hibernate做持久化工作，不过在这个例子中学到的方法，也可以应用在其他技术栈上。

下面，我们会先写一个集成测试，把一个瞬态的对象持久化到数据库中。一旦搭建好测试的轮廓，我们将会试着使用内存数据库，学着给一个干净的测试数据库创建数据库模式。写完并通过测试后，我们会来讨论如何使用事务夹具让每个测试都能用到干净清洁的数据。

内容很多，现在就开始把！

6.4.1　第一个Hibernate集成测试

下一步我们会给Person对象对应的DAO编写集成测试。我们会用Hibernate框架做持久化，但是HibernatePersonDao目前还不存在。我们想让DAO能保存Person对象。在集成测试中持久化一个对象会带来一些问题，因为会往数据库中写入数据。写操作比读操作通常更复杂些，也涉及测试中如何维护数据库状态等问题。

把握好度

访问数据库的集成测试非常必要。不过尽管如此，若持久化操作都比较类似，那么通常没有必要端到端地集成测试所有操作。只让一部分具有代表性的读写操作的测试访问数据库。剩下的测试，使用测试替身就可以了。这样，运行测试的时间也会显著减少。

这是个新起点。第一步该做些什么？用意图编程，编写一个测试来验证，可以将DAO持久化过的Person对象从数据库中读取出来，怎么样？这主意听起来不错，开始吧。

1. 为测试画出轮廓

为了拥有持久化Person对象的功能，我们为测试勾勒出了轮廓，见代码清单6-14。

代码清单6-14　第一个集成测试类的轮廓

public class HibernatePersonDaoIntegrationTest {

@Test

public void persistedObjectExistsInDatabase() throws Exception {

SessionFactory sf = getSessionFactory();     // ❶创建了解Person类的SessionFactory

HibernatePersonDao dao = new HibernatePersonDao();

dao.setSessionFactory(sf);

Person person = new Person("John", "Doe");

dao.save(person);

assertNotNull(person.getId());     // ❷持久化对象接收

Session s = sf.openSession();     /*❸（以下3行）现在已在数据库中*/

Object copy = s.get(Person.class, person.getId());

assertEquals(person, copy);

}

}

首先，我们先创建出一个HibernatePersonDao的实例，然后注入一个HibernateSessionFactory❶。代码中使用的getSessionFactory方法目前还不存在，我们只是在进行意图编程。

下一步是代码清单6-14中测试的“执行”部分。我们创建了一个瞬态Person对象，然后让HibernatePersonDao保存这个对象。接着我们验证保存过的Person对象会有个ID标识❷，通过此ID可以找到保存过的Person对象❸。

在测试中重用Session对象要谨慎

在代码清单6-14中，我们新创建了一个Session对象，用于验证Person对象确实被持久化了。当然，可以不创建新Session对象，而是从SessionFactory中获取当前Session，不过使用新的Session对象会更保险。当涉及事务时，使用同一个Session进行测试可能会漏掉一些问题，因为在同一个Session中，无论事务是否提交过，所有的更改都将“生效”。

要验证DAO是否能用Hibernate API持久化Person对象，这个测试足矣。下一步来试着实现getSessionFactory方法。

2. 为测试配置Hibernate

目前的问题是我们有两套Hibernate配置——一套用于产品环境，另一套用于集成测试环境。我们需要透明地导入测试环境的配置，并且使两套配置不存在多余的重复。

目前我使用这个办法：在类路径中的hibernate.properties配置文件中维护产品环境配置，然后把任何有差异的配置项都放在测试专用的hibernate.test.properties文件中。当两个配置文件中存在重复时，用测试环境的配置覆盖产品环境的配置。这可以帮我们降低“在测试环境部署应用程序后才发现配置问题”的风险——大部分Hibernate相关的数据访问的集成问题，都能够在开发人员环境中发现并解决。

下面，我们来试着实现代码清单6-14中的getSessionFactory方法，以及刚才提到的配置管理方法。

要获得一个SessionFactory，则先要获得一个能够创建SessionFactory的Configuration对象。在代码清单6-15中，我们首先根据类路径中的产品环境配置文件hibernate.properties，创建出一个Configuration对象❶，然后把产品环境配置对象传给另一个方法，在产品配置的基础上加载测试配置❷，覆盖任何已有的配置❸。

代码清单6-15　为测试配置SessionFactory

public class HibernatePersonDaoIntegrationTest {

...

private SessionFactory getSessionFactory() throws Exception {

return createConfiguration().buildSessionFactory();

}

private Configuration createConfiguration() throws Exception {

Configuration cfg = loadProductionConfiguration();    // ❶从类路径中载入产品确认消息

loadTestConfigInto(cfg, "/hibernate.test.properties");    // ❷从文件中载入测试确认消息

return cfg;

}

private Configuration loadProductionConfiguration() {

return new Configuration().configure();     // ❶从类路径中载入产品确认消息

}

private void loadTestConfigInto(Configuration cfg, String path)

throws Exception {

Properties properties = loadPropertiesFrom(path);     // ❷从文件中载入测试确认消息

Enumeration keys = properties.keys();

while (keys.hasMoreElements()) {

String key = (String) keys.nextElement();

String value = properties.getProperty(key);

cfg.setProperty(key, value);    // ❸覆盖单个属性

}

}

private Properties loadPropertiesFrom(String path)

throws Exception {

InputStream stream = getClass().getResourceAsStream(path);    // ❷从文件中载入测试确认消息

assertNotNull("Resource not found: " + path, stream);

Properties props = new Properties();

props.load(stream);

stream.close();

return props;

}

}

为了让Hibernate连接到测试数据库上，我们写了不少代码，不过没关系，这些代码只用写一次。说到连接数据库，现在应该为Hibernate指定测试配置了，否则集成测试可能会去访问缓慢的远程数据库，或者（倒吸一口冷气）产品数据库。

3. 配置内存数据库HSQLDB

接下来的问题是，数据库连接的哪些属性应当从测试环境配置中读取？目前，我们期望集成测试在内存数据库HSQLDB上运行。假设HSQLDB不是产品数据库，那么我们至少需要覆盖SQL dialect以及其他以hibernate.connection为前缀的属性。代码清单6-16列举了一部分相关属性。

代码清单6-16　内存数据库HSQLDB的Hibenate配置

hibernate.dialect=org.hibernate.dialect.HSQLDialect

hibernate.connection.driver_class=org.hsqldb.jdbcDriver

hibernate.connection.url=jdbc:hsqldb:mem:testdb

hibernate.connection.username=sa

hibernate.connection.password=

hibernate.show_sql=true

hibernate.hbm2ddl.auto=create-drop

代码清单6-16中的配置与产品环境的配置并没有太大区别——我们只需要告诉Hibernate应该针对哪种数据库产生SQL语句及数据库的位置（JDBC URL），使用哪个JDBC驱动连接数据库，以及连接所使用的账号。最后一个配置项hibernate.show_sql并不是必需的，不过在测试时会用得上。打开show_sql功能后Hibernate会输出生成的SQL语句，调错时很有用。

hibernate.connection.url的值jdbc:hsqldb:mem:testdb，是指我们期望连接到内存数据库系统HSQLDB中的testdb库。如果这个数据库还不存在，HSQLDB会自动创建。因为我们会从无到有地创建数据库，所以数据库中不会有任何表。在数据库创建完成后，我们必须初始化数据库模式。

最后把create-drop赋给hibernate.hbm2ddl.auto属性后，Hibernate会在SessionFactory创建和关闭时，自动创建和删除数据库。不过这不是创建数据库的唯一方法，下面来看看还有哪些选择。

6.4.2　创建数据库模式

数据库模式也是源代码的一部分。所以数据库模式也应当与系统其他部分源代码一样由版本控制系统保存。

1. SQL脚本

要解决这个问题，传统的做法是把创建模式的数据定义语言（data definition language，DDL）语句保存在一个（或几个）SQL脚本文件中。这样做有两个好处：容易进行版本控制并且容易维护（因为数据库管理员与开发人员都熟悉SQL语句）。

使用外部的SQL语句也有两个缺点：首先，因为需要给集成测试准备模式，所以需要在Java代码中执行这些SQL语句；其次，集成测试中使用的数据库有可能和生产环境的不一样，所以DDL语句也需要同时兼容两个数据库环境，否则就要维护两套不同的模式定义文件了，这简直是自找麻烦。

第一个问题很容易解决，只要写个小工具，从文件中读取出SQL语句，然后传给JDBC执行就可以了。第二个问题有些棘手，不过Hibernate可以帮忙。

2. 让Hibernate解决所有问题

Hibernate的用户们走运了，因为Hibernate可以在创建SessionFactory的同时把模式导入到数据库中。映射文件中包含所有必需信息，开发人员只需要在集成测试配置中添加属性hibernate.hbm2ddl.auto=create-drop。不过由映射文件产生的模式和手工创建的并不完全一样，这点并不很理想。

例如，Hibernate通常会为java.lang.String类型的字段创建VARCHAR(255)列，而我们可能会需要一个不定长的数据类型，不局限于255个字符长度。在过去，这可能会带来些麻烦，因为有些数据库会按照列长的限制直接截断数据。通过在映射文件中定义列的sql-type属性，我们可以改变默认行为。这不一定会给你带来问题，因为测试数据库和产品数据库也许会支持同样的数据类型。

在开始集成测试周期之前，我想先介绍一下增量式DDL脚本。

3. 增量式DDL脚本

如果你曾经见到过产品部署过程出现问题，那么你肯定会见到数据库回滚操作。回滚应用程序代码相对简单——只需要拿走老版本装上新版本就可以了。不过，数据库就不一样了。数据库里包含的不仅仅是模式，还有数据。数据会使情况变得很复杂，因为我们不能直接把数据丢掉，重新创建模式。所以就要使用增量式DDL了。

增量式DDL基本理念是：许多细小的步骤可以创建出大的模式。一次只添加一个列或者一张表，每个步骤都可以回滚。如果我们把创建数据库模式的步骤分解成一系列顺序执行的小脚本，每个脚本都是在当前模式上“打补丁”，那么我们就可以轻易地重新创建出任何版本的模式，而且还可以从新版本恢复到老版本，同时不会丢失任何数据！

Ruby on Rails^1是我见到的第一个支持增量式DDL脚本的框架。不过本书主要内容是Java开发，所以不会讨论太多Rails框架的特性。Java世界中的开源项目dbdeploy^2也实现了类似的功能。

1 http://wiki.rubyonrails.com/rails/pages/UnderstandingMigrations

2 http://dbdeploy.com/

Dbdeploy工具用于维护数据库的变更，这个工具可以从一堆小的SQL脚本中生成完整的SQL脚本。每个补丁文件都会有一个数字前缀，表示其版本。参见图6-6中delta script的目录结构。

在图6-6的例子中，共有4个delta script，dbdeploy会按照脚本前缀依次执行这些脚本。每个脚本都是普通的SQL语句，也可以包含一段撤销修改的语句，dbdeploy工具在恢复数据库旧版本时，会执行撤销修改的SQL语句。代码清单6-17为00002_create_order_items_table.sql中的SQL语句。

图6-6　包含delta change脚本的目录示例，这些脚本用于增量地创建数据库。Src/db/deltas目录中的四个脚本将会按照前缀数字顺序执行

代码清单6-17　带有用于回滚的@UNDO-block的delta脚本示例

CREATE TABLE ORDER_ITEMS (

ID INTEGER NOT NULL,

ORDER_ID INTEGER NOT NULL,

ITEM_DESCRIPTION VARCHAR(100)

)

;

ALTER TABLE ORDER_ITEMS

ADD (CONSTRAINT PK_ORDER_ITEMS)

PRIMARY KEY (ID)

;

ALTER TABLE ORDER_ITEMS

ADD (CONSTRAINT FK_ORDER_ITEMS_ORDER)

FOREIGN KEY (ORDER_ID) REFERENCES ORDER(ID)

;

--//@UNDO      // ❶此行后都是回滚

ALTER TABLE ORDER_ITEMS

DROP CONSTRAINT FK_ORDERS_ORDER_ITEMS

;

DROP TABLE ORDER_ITEMS

;

dbdeploy工具会读取代码清单6-17中的SQL语句，直到遇到标注为❶的字符串--//@UNDO。这个字符串表明“前面的内容用来更新模式，后面的内容用来回滚修改”。

然后，可以配置dbdeploy，让其指向一个数据库和一系列delta script，然后dbdeploy会为数据库产生相应的migration script。不过dbdeploy怎么知道哪个脚本已经执行过了？为了提供这个功能，我们需要添加一张表，专门用于保存数据库的版本号。这张表需要手工创建，不过dbdeploy可以帮我们完成这项工作，所以只要在创建数据库时执行一次就可以了。

Dbdeploy这样的增量式DDL脚本解决方案肯定值得考虑。虽然它只不过是个小工具，不过所带来的好处是毋庸置疑的。但是，为了完成本章的目标（特别是完成了一半的集成测试），我们还是会采用Hibernate，让其在幕后删除和创建数据库模式。现在我们回到集成测试上吧。

6.4.3　实现产品代码

我们先来回顾一下代码清单6-14中的测试所验证的内容。在测试中，我们期望HibernatePersonDao类可以保存Person对象，用SessionFactory把对象持久化到数据库中。代码清单6-18中的代码可以完整实现这个功能。

代码清单6-18　第一个集成测试的轮廓

public class HibernatePersonDao {

private SessionFactory sessionFactory;     // ❶依靠SessionFactory

public void setSessionFactory(SessionFactory sessionFactory) {

this.sessionFactory = sessionFactory;     // ❶依靠SessionFactory

}

public void save(Person person) {

Session session = sessionFactory.getCurrentSession();    // ❷如可用，获取现存会话

Transaction tx = session.beginTransaction();    /*❸（以下3行）用事务持久化Person*/

session.save(person);

tx.commit();

}

}

我们的DAO实现只需要把传入的SessionFactory实例❶保存到一个private字段中，在调用save方法时，使用其提供的持久化功能保存对象。在save方法中，我们❷从SessionFactory中获取了当前Session（如果当前Session还不存在，会创建新的Session），❸创建新的事务，调用Session对象的save(person)方法，然后提交事务。

说到事务，下面将讨论事务夹具及其优点。

6.4.4　用事务夹具保持数据清洁

数据库读操作的测试很简单：先在数据库中保存些数据，然后读操作会读取数据（或者数据的子集），返回相应的对象。而写操作会改变数据库的状态，因此需要验证数据的变化与期望的一致。这类测试有些麻烦，因为在测试中数据会被修改，下一个测试使用的测试数据已经不完全一样了。如果不能保证测试之间数据独立，连读操作的测试都会受到影响。那么，应该如何保持测试前后数据库状态不会发生改变呢？

我们当然可以在每个测试的tearDown方法中实现撤销操作，撤销集成测试对数据的所有改动。不过撤销逻辑很容易变得过于复杂，所以需要用其他方法解决这个问题。或许事务夹具是最好的选择。

事务夹具首先是个夹具，能够提供测试方法用到的数据。另外，它还能提供回滚功能，在测试结束后自动撤销所有修改。我们会用持久化框架和数据库提供的事务机制实现这项功能。我们可以在setUp方法中创建一个事务，而在tearDown方法中回滚事务。代码清单6-19为Hibernate的实现。

代码清单6-19　用事务夹具回滚集成测试中的数据修改

public class HibernatePersonDaoIntegrationTest extends

HibernateIntegrationTestCase {

private SessionFactory sf;

private Transaction tx;

...

@Before

public void setUp() throws Exception {

sf = getSessionFactory();

...

tx = sf.getCurrentSession().beginTransaction();    /*❶（以下2行）在Psetup中开始执行事物*/

tx.begin();

}

@After

public void tearDown() {

tx.rollback();     // ❷在teardown方法中回滚

}

...

}

在每个测试开始时，setUp方法会为当前Session❶创建一个Transaction对象。从这点开始，所有的使用Session对象的数据库操作都属于数据库事务的一部分，在tearDown方法中❷调用Transaction的rollback方法时，所有操作都会撤销。

相对每次都填充数据库，使用事务夹具的优势在于提高执行速度。从外部文件导入数据会耗费大量时间，就算数据量很小，所有测试的总执行时间也不少。如果使用事务夹具，就可以只在测试运行前填充一次数据库，每个测试执行完后都会回滚数据修改即可。

无法测试事务行为是事务夹具的唯一缺点。因为如果在测试中提交了事务，那么tearDown方法中的回滚操作将无法撤销修改。

如果事务夹具能保证数据库的状态不会被测试修改，那么如何往数据库中填充测试数据呢？下面就来讨论这个问题。

6.5　为集成测试填充数据

假设我们在为findByLastname等读操作写集成测试。由于测试中将使用真实数据，所以需要往数据库中填充测试数据。我们可以用一些很经典但又相对原始的方法填充数据，例如在测试前运行INSERT语句。虽然这种方法可行，但是SQL脚本并不算用户友好，而且可移植性也不强。

因此，我们希望能有其他的替代方案。幸运的是，替代方案有许多，例如Hibernate和DbUnit^1。下面就来仔细研究这两种方案。

1 http://dbunit.sf.net

6.5.1　用Hibernate填充对象

如果我们在使用Hibernate ORM框架，那么可以利用Hibernate的持久化功能来填充数据库。从很多方面来讲，填充数据的语言与开发语言一致会带来很多好处。具体示例请见代码清单6-20。

代码清单6-20　用Hibernate API填充数据的集成测试

@Test

public void testFindingAllSmiths() throws Exception {

List<Person> theSmiths = new ArrayList<Person>();     /*❶（以下6行）创建对象*/

theSmiths.add(new Person("Alice", "Smith"));

theSmiths.add(new Person("Billy", "Smith"));

List<Person> allPeople = new ArrayList<Person>();

allPeople.addAll(theSmiths);

allPeople.add(new Person("John", "Doe"));

persist(allPeople);     // ❷用HibernateAPI持久化对象

assertEquals(theSmiths, dao.findByLastname("Smith"));

}

private void persist(List<? extends Object> objects) {    /*❷（以下7行）用HibernateAPI持久化对象*/

Session s = sf.getCurrentSession();

for (Object object : objects) {

s.save(object);

}

s.flush();

}

为了测试HibernatePersonDao的findByLastname方法，我们❶创建了很多Person对象，接着❷用Hibernate将其持久化到数据库中。然后刷新Session，使保存操作立即执行。最后用DAO查找数据库中所有姓为Smith的Person对象。

请注意，在测试中，数据准备部分和测试执行部分紧挨在一起。与此相对，我们也可以在测试中直接调用查找方法，期望返回一个包含Alice Smith和Billy Smith的列表。为什么会有这两个对象呢？因为这个测试的外部SQL文件中包含这两个Smith，测试运行时已经写到数据库中去了。由此可以看出，如果测试数据和测试逻辑分离了，测试失败时会很难找出原因。

注解　在代码清单6-20中，尽管我们在测试方法中进行数据填充，不过使用Hibnerate API，在套件或测试类层面仅初始化一次数据仍旧可行。

尽管使用ORM工具有许多优势，不过用ORM框架的API来填充测试数据也有一定缺陷。例如，若完全依赖ORM或持久化框架，可能会漏掉一些问题，而用纯SQL或其他低级格式（lower-level format）填充数据时，这些问题很快就能发现。而且，并不是所有项目都用Hibernate等ORM工具，因此SQL脚本之外的解决方法也值得研究。另外，还有一个潜在的问题：测试驱动时DAO可能尚不存在，所以根本没法填充数据^2！

2 这里是指，要测试驱动出DAO，需要先填充测试数据，而填充测试数据，又需要使用DAO。——译者注

许多年来，有很多项目一直在使用DbUnit来填充数据。

6.5.2　用DbUnit填充数据

DbUnit是一种用于数据库测试的功能齐全的工具。它可以用多种格式的数据文件填充数据库（其中，两种XML格式的变体应用最为广泛），也可以把数据库内容导入到文件中。此外，DbUnit可以比较两个数据集（例如，外部数据文件和当前数据库内容）间的差别。我们并不会深入讨论DbUnit（此项目的网站上有详细的文档及大量示例），不过可以先来了解如何从外部文件导入测试数据。

1. 初始状态

DbUnit工具提供了DatabaseTestCase基类。这个基类可以用于向目标数据库填入数据。开发人员需要向基类提供数据库连接，另外需要通过一些抽象方法提供数据集。

不过在实际应用中，我们通常会为DbUnit的测试创建自己的虚基类，把数据库连接及加载数据的细节集中在一处，而不是在每个测试中重复（见代码清单6-21）。

代码清单6-21　DbUnit集成测试的虚基类

import java.io.InputStream;

import java.sql.*;

import org.dbunit.database.*;

import org.dbunit.DatabaseTestCase;

import org.dbunit.dataset.IDataSet;

import org.dbunit.dataset.xml.FlatXmlDataSet;

import org.junit.Before;

/**

* Abstract base class for integration tests using DbUnit for

* populating the database to a known state before each test run.

*/

public abstract class DbUnitIntegrationTestCase extends

DatabaseTestCase {

protected Connection getJdbcConnection() throws SQLException {    /*❶（以下3行）创建JDBC连接*/

// obtain a JDBC connection to the database

}

@Override

protected IDatabaseConnection getConnection() throws    /*❷（以下4行）为DbUnit包装连接*/

Exception {

return new DatabaseConnection(getJdbcConnection());

}

@Override

protected IDataSet getDataSet() throws Exception {

String resource = getClass().getSimpleName() +     /*❸（以下4行）从磁盘获取数据集*/

".initial.xml";

InputStream stream =

getClass().getResourceAsStream(resource);

assertNotNull("Resource " + resource + " not found.",

stream);

return new FlatXmlDataSet(stream);     // ❹给DbUnit提供数据

}

}

要使用DatabaseTestCase基类，开发人员需要覆盖两个虚方法。在代码清单6-21中，我们❶首先创建了一个JDBC连接（使用java.sql.DriverManager等），然后用DbUnit特定的DatabaseConnection对象❷包装连接对象。另外，我们还需要给DbUnit提供数据集以填充数据库。数据集可以❸从保存在类路径中的XML文件里读取，然后以纯 XML数据格式进行解析❹。

这样，测试类就可以直接继承DbUnitIntegrationTestCase基类，新建一个XML文件，写入测试所需的数据，然后使用与DbUnit同一个数据库连接执行测试代码（见代码清单6-22）。

代码清单6-22　继承代码清单6-21中虚基类的子类

import java.sql.*

import java.util.*;

import org.apache.commons.dbcp.BasicDataSource;

import org.junit.*;

public class PersonDaoDbunitIntegrationTest

extends DbUnitIntegrationTestCase {

private List<Person> expectedList;     // ❷创建想要的结果

@Before

@Override

public void setUp() throws Exception {

super.setUp();      // ❶调用父类，写入数据库

expectedList = new ArrayList<Person>();     /*❷（以下4行）创建想要的结果*/

expectedList.add(new Person("Al", "Freeman"));

expectedList.add(new Person("Bill", "Brewster"));

expectedList.add(new Person("Juan", "Alvarez"));

}

@Test

public void testFindAll() throws Exception {

JdbcTemplatePersonDao dao = new JdbcTemplatePersonDao();

dao.setDataSource(new BasicDataSource() {      /*❸（以下6行）确认测试的代码*/

@Override

public Connection getConnection() throws SQLException {

return getJdbcConnection();

}

});

assertEquals(expectedList, dao.findAll());     // ❹验证实际结果是否与期望的一致

}

}

首先，❶DbUnit从一个与测试类同名的XML文件中读出数据，写入数据库。然后我们❷创建出了一个名为expectedList的Person对象列表，接着❸让我们的数据访问代码访问同一个数据库，最后❹验证findAll方法返回的列表与期望的一致。在这里，我们假设DbUnit会向数据库写入Al Freeman、Bill Brewster和Juan Alvarez几条数据，只要在类路径中放入了PersonDaoDbUnitIntegrationTest.initial.xml，这个假设必定成立。下面来看看数据文件中包括什么吧。

2. 定义数据集

代码清单6-23演示了如何用DbUnit的纯 XML格式向employees表中填充Al、Bill和Juan等3个员工信息，向salaries表填充他们的薪水信息。

代码清单6-23　纯XML格式的样本数据

<?xml version='1.0' encoding='UTF-8'?>

<dataset>

<employees employee_uid="1" start_date="2005-01-01"

ssn="000-29-2030"

first_name="Al" last_name="Freeman" />

<employees employee_uid='2' start_date="2005-04-04"

ssn="000-90-0000"

first_name="Bill" last_name="Brewster" />

<employees employee_uid='3' start_date="2006-06-03"

ssn="000-67-0000"

first_name="Juan" last_name="Alvarez" />

<salaries employee_uid="1" salary="45000" />

<salaries employee_uid="2" salary="50000" />

<salaries employee_uid="3" salary="47000" />

</dataset>

在纯XML中，每行数据都是dataset根元素的直接子元素。元素的名称就是对应的数据库表名，元素的每个属性都是一个列名—列值对。通过读取JDBC中提供的数据类型信息，DbUnit可以把简单类型转化成正确类型然后写入数据库。例如，若列类型是DATE，那么start_data列的属性值会被正确地解析成java.sql.Date对象。

参照代码清单6-23中的格式把测试数据写成XML，可以使维护工作变得更简单，因为在改变数据库时不需要改动测试数据。此外，XML格式比SQL语句更易读。以外部文件定义测试数据的方式（如代码清单6-22和代码清单6-23所示）的主要缺点在于，测试数据会在多处重复。使用DbUnit提供的数据集断言，可以免去一些测试写操作时遇到的麻烦。下面就来了解一下这项功能。

3. DbUnit与数据集

我们可以有效地利用DbUnit的功能，把数据库的内容导出为一个IDataSet对象，便于对比两个数据集。比起在XML文档和Java源代码之间来回切换，这种方式更加自然，因为数据定义的格式与验证时作对比的格式一致。

要做到这点，需要3个步骤。第一步是导出数据库的当前内容。这一步很简单，只要从DbUnit中拿到填入数据时使用的数据库连接，然后用其创建IDataSet对象即可。

IDataSet fullDatabase = getConnection().createDataSet();

String[] tables = { "apples", "oranges" };

IDataSet justSomeTables = getConnection().createDataSet(tables);

在第二步中，我们需要获取另一个IDataSet对象来做对比。做一些小的重构后，我们可以重用基类中的代码。

@Override

protected IDataSet getDataSet() throws Exception {

return getDataSet("initial");

}

protected IDataSet getDataSet(String name) throws Exception {

String resource = getClass().getSimpleName() + "." +

name + ".xml";

InputStream stream = getClass().getResourceAsStream(resource);

assertNotNull("Resource " + resource + " not found.", stream);

return new FlatXmlDataSet(stream);

}

把数据集文件的逻辑名称提取成函数参数后，我们可以在测试中使用重载后的getDataSet(String)方法从磁盘上任意文件中读取数据，将读取的数据转化成IDataSet对象。

现在我们已经可以得到两个数据集对象，不过要进行对比，还需要有对比方法。DbUnit提供了很多方便的断言类，类中定义了如下两种验证方法：

void assertEquals(IDataSet expected, IDataSet actual);

void assertEquals(ITable expected, ITable actual);

前一种方法会对比两个数据集，而后一种会对比两张表。如果只对某一个表的内容感兴趣，那么可以使用后一种方法。通过对比单张表，我们可以只准备感兴趣的那一部分测试数据。要把数据对比工作限制于相关的表，可以让DbUnit只导出相关表而非所有表的数据。

有了这两种断言方法以及读取数据库和文件内容的方法，我们就可以写出代码清单6-24中的测试了。

代码清单6-24　用DbUnit对比数据集

@Test

public void testSavePerson() throws Exception {

JdbcTemplatePersonDao dao = new JdbcTemplatePersonDao();

dao.setDataSource(new BasicDataSource() {

@Override

public Connection getConnection() throws SQLException {

return getJdbcConnection();

}

});

Person person = new Person("John", "Doe");

dao.save(person);

IDataSet expectedData = getDataSet("afterSavePerson");     // ❶从磁盘读取期望的后述状态

String[] tables = new String[] { "employee", "salary" };     /*❷（以下2行）从数据库读取状态*/

IDataSet actualData = getConnection().createDataSet(tables);

Assertion.assertEquals(expectedData, actualData);     // ❸要求DbUnit处理麻烦问题

}

执行完数据访问代码后，我们❶从文件中读取出employee及salary表的期望数据，然后❷让DbUnit从数据库中读取employee及salary表的当前内容，最后❸对比两个数据集。很简单吧？

DbUnit的相关知识目前已经足够了。有了这个工具，开发工作会变得更轻松。不过，是否使用DbUnit，当然还是由你自己决定。目前为止，我们已经见到了许多数据访问对象的单元测试和集成测试技术，不过还没有讨论过如何在开发过程中使用这些技术。在这方面，一个主要的问题是，我们应该用单元测试还是集成测试来驱动数据访问代码的开发？

6.6　使用单元测试还是集成测试

在传统软件开发过程中，我们需要先进行单元测试，然后是集成测试，最后是系统级测试。不过TDD不同于传统开发过程，一开始并没有任何可以测试的代码，直到完成第一个测试后。单元测试使用测试替身，而集成测试会真实地访问数据库，从编写产品代码的角度来看，这两种测试的差别并不大。不过，这两种测试的用途不同，使用这两种测试做开发时的节奏也有所不同。

下面我们先来讨论，使用集成测试时TDD周期会是什么样子。然后再来讨论，如何在整个开发过程中混合使用集成测试和单元测试驱动开发工作。

6.6.1　在TDD周期中使用集成测试

也许你已经注意到了，做集成测试前需要先做些准备工作，构建一定基础设施。这些准备工作会花费一定时间，如果第一次使用相关工具，那么需要的时间会更长。一旦初步构建完成，这些基础设施会通过不断的重构而逐渐演化和改进。

用集成测试进行TDD，和基于单元测试的TDD并无区别，只是速度慢一些。不过若集成测试中使用了DbUnit，那么会更有趣。只要集成测试中有了使用DbUnit的基础设施，那么TDD周期基本上会遵循以下过程。

1. 添加一个测试方法。

2. 运行测试，会提示缺少数据文件。

3. 编辑数据文件。

4. 运行测试，会提示缺少表或者列，等等。

5. 改变数据库模式。

6. 运行测试，会提示功能尚未完成。

7. 实现功能。

8. 运行测试，测试通过。

这个过程和使用单元测试的TDD周期区别不大，不过每个步骤所花费的时间会更长，所以TDD周期也会变得更长。另外，值得注意的是，因为可能需要改变数据库模式，我们可能需要对代码及数据库进行重构^1，然后才能添加新功能。

1 Scott Ambler与Pramodkumar Sadalage合著的Refactoring Databases是介绍演化数据库模式的主要材料。

根据我的经验，只要合理使用工具，是可以用集成测试驱动数据访问代码的。这里提到的工具是指：一个能实现所需基础设施的持久化框架、一个能方便添加新测试的框架、还有一个运行速度够快的数据库。不过这并不是指我们只能用一种方法解决问题。

6.6.2　两全其美

前面曾经提到，集成测试可以验证数据库访问代码是否能与数据库正常协作。如果使用单元测试，那么只能间接地验证代码。测试的正确性取决于开发人员对持久化框架查询语言的理解程度以及对框架API的熟练程度，等等。

集成测试最大的问题是执行速度缓慢。即使只运行少量测试，速度上的劣势也很明显。如果执行所有测试，那么可能会用去10分钟或者更多的时间，这样，用测试对代码进行全面地健康检查前就提交代码的可能性会逐渐增加。

无法模拟特定情形是集成测试的另一个缺点。相比之下，如果用单元测试，那么准备数据会更容易。例如，要模拟在特定时刻数据库连接突然失败，集成测试就力不从心了，而测试替身可以很容易模拟此类情形。此外，比起填充数据库，再让数据访问对象读取数据库再返回领域对象，让测试替身返回一些领域对象要容易得多。

了解两种方式的利弊后，没有必要再作出什么非此即彼的选择了。我们需要合理地利用这两种方法，使其各自的优点都得以发挥。在测试驱动数据访问代码时，虽然有必要统一测试的类型，不过根据不同情况应用不同类型的的测试也是合理的。

在总结本章内容之前，我们来简单介绍一下尚未提及的重要数据访问方式——文件系统。

6.7　文件系统访问

还记得6.2节中Michael Feathers的关于单元测试的定义吗？此定义提到：单元测试不会访问文件系统；单元测试之间能够并行运行；运行单元测试前不需要设置环境。给单元测试定下这么多规则是为了提高执行速度和可维护性。切记，单元测试的执行速度要快，可维护性要好。那么，单元测试中对文件系统的访问该如何处理？

简言之，我们需要尽量避免访问文件系统，而是用java.io.Writer及java.io.Reader取代。毕竟大部分时间我们感兴趣的是文件内容，而不是文件本身，所以streams完全可以胜任。

不过还是那个问题，如何测试包装File API的那部分代码呢？在本节中，我们将会进行详细讲解。先从我过去的项目中遇到的一个问题开始吧。

6.7.1　项目中实际遇到的一个问题

在我最近的项目中有个需求：在后台通过网络从服务器上下载更新。后台下载模块应该能把下载的更新保存在临时文件中，然后进行完整性检查，最后会覆盖老版本所有的包。我们需要引入ContentStorage接口，这样ContentDownload就可以使用这个接口写文件或者在系统的内容目录结构中移动文件了。

在单元测试中使用伪ContentStorage可以避免访问文件系统，也不用配置存储路径，同时也不用在测试执行后清理遗留文件。只需要在一小部分测试里使用ContentStorage的真实实现即可。这有效地减少了进行I/O操作的缓慢测试的数量，也不必在每个测试后都清理文件系统中留下的垃圾文件了。

意识到可以改造文件访问操作，使其更加测试友好，是提高可测试性的第一步。在下一节中，我们将会讲到提高文件访问代码可测试性的很多实用技巧。

6.7.2　提高文件访问可测试性的实践

遵循好的设计原则会使设计的可测试性更佳。不过涉及读写文件等简单操作时，所有的设计理念仿佛都从脑海里消失了。出于某种原因，我们Java开发人员在使用java.io.File*API时，通常都不会多加思考，在代码中四处使用java.io.File*API。好吧，也许只有我才这样，不过我更愿意相信不止我一个人有这种习惯。下面要介绍的设计技巧能够简化文件访问操作，使文件访问相关的测试驱动工作尽可能简单。

1. 用接口隔离文件访问

虽然Java文件API是好东西，不过在代码中四处调用文件API就不好了。当我们需要在遗留代码基础上添加或者修改功能，且此功能会进行文件访问时，问题就会浮现。如果你和我一样走运，你可能会需要在测试中准备一整套目录结构，以执行需要改动的代码。用接口封装文件访问，通常能减少测试中的初始化工作。

2. 使用流（stream）而非文件

假设要对比两个文件，那么实现代码中会很自然地引用两个java.io.File对象。因为要对比的是两个文件，所以这表面看起来很合理。不过，实际上，这样做并不那么合理。因为对比操作并不关心什么是文件，而只关心要对比的内容。如果对比代码需要使用FileInputStream来读取文件内容，那么File对象必须指向文件系统中真实的文件。这样，比起直接读取内容，读取文件的方案需要写更多代码。

使用流对象，而非文件句柄能很好地解决这个问题。

3. 使用自定义的文件对象

大部分情况下，我们都可以传递流对象，而不是文件对象。而有时候我们不仅需要文件内容，还需要其他信息，例如文件的位置、大小等。不过就算遇到这种情况，也不能直接传递文件对象，因为这么做还会带来可测试性问题。

这时候，可以引入自定义的文件接口。这个接口可以是java.io.File API的一个封装，提供对文件元数据（例如路径等）及文件内容的访问。有了这个接口，文件访问代码会更容易测试，因为完全可以使用模拟文件，而不用把临时文件和目录弄得乱七八糟。

Jakarta Commons VFS^1（virtual file system，虚拟文件系统）项目封装了Java文件系统API。有了这套类库，我们就可以在测试中使用纯虚拟的内存文件系统，而在产品环境中使用真实的文件系统了。

1 http://jakarta.apache.org/commons/vfs/

4. 使用专用的临时文件夹

如果需要在文件系统中创建真实的物理文件，那么则应该使用一个专用的临时文件夹来存放所有的输出文件及动态产生的输入文件。把所有的测试文件都放在同一个文件夹内，在测试清理步骤中，只要删除整个文件夹就可以了。

5. 测试前清理而非测试后

说到数据清理，比起在测试完成后清理，有时候在测试前清理更加合理。因为测试中可能产生大量数据，而要验证如此多的数据的正确性，有时需要用一些工具（例如diff工具）进行手动对比。所以，不能让测试后的清理动作毁掉所有数据证据。

虽然上面介绍的的这些技巧不是银弹，不过确实能简化文件访问相关功能的开发工作。下面我们来总结本章内容，然后继续其他有趣的话题吧。

6.8　小结

在本章中，我们了解了如何测试驱动数据访问代码。这工作比想象的要容易得多。我们从两个角度阐述了这个问题——分别使用了单元测试和集成测试驱动产品代码的开发。

首先，我们讨论了为何数据访问代码与普通的应用程序代码或业务逻辑代码间存在一定差别，为何其会跨越许多层。然后我们了解了DAO模式，以及如何用这种模式解决代码臃肿的现实问题。

然后，我们尝试用3种不同的持久化技术实现一个简单的DAO类。这3种技术分别为：纯JDBC API、Spring框架提供的JdbcTemplate以及Hibernate API。因为使用3种技术实现同一种功能，我们很容易看出框架的可测试性风格对TDD过程的影响。

在学会用单元测试驱动数据访问代码后，我们转向了另一种方式：直接连接到HSQLDB内存数据库进行集成测试。我们讨论了在集成测试中使用轻量级数据库可能带来的问题，也讨论了在版本控制系统中维护各项配置的必要性。

我们学习了如何搭建集成测试的基础架构，好让集成测试编写工作变得飞快，讨论了用SQL脚本及Hibernate API来创建数据库模式，亦讨论了如何用事务夹具清理测试数据。我们也介绍了用DbUnit填充数据库，以及用外部XML数据文件中的内容进行结果验证。

在尝试测试驱动数据访问代码的两种不同方法后，我们发现两种方法各自的优势，并决定针对具体情况而使用不同的方法。

在本章结束时，我们简要讨论了文件访问相关内容，以及如何设计代码以简化文件系统的相关操作。

在下一章中，我们将会集中讨论底层平台，也会涉及测试驱动过程中可能遇到的一些不可预测的因素，例如事件相关的功能及并行编程（concurrent programming）。

第7章　测试驱动不可预测功能

马这种动物不可预测，但在预知范围内。

——洛瑞塔·盖奇，科罗拉多州立大学兽医学院年龄最大的学生

迄今为止，我们已经接触到了数据访问代码、Web组件和普通业务逻辑的测试驱动方法。这些方法基本上与底层平台关系不大，而且执行结果完全可以预测。但并不是所有的代码都像这样。在本章中，我们将会专门介绍一些行为不可预测的功能的测试驱动方法，这些功能包括：

- 时间相关功能

- 多线程编程

我们会先从相对简单的时间相关功能开始介绍，然后讨论多线程并发编程给测试以及TDD带来的挑战。在本章的结束部分，我们会学习java.util.concurrent包提供的基本的同步对象。

7.1　测试驱动时间相关功能

许多开发人员在编写测试时都遇到过时间相关的问题，例如代码的执行或输出结果与当前时间相关。时间相关的测试之所以很麻烦，是因为我们没法控制时间。相应的，要解决这个问题，就需要把时间变为可控的。下面，我们来举例说明。

7.1.1　例子：日志和时间戳

假设我们要给Java EE应用服务器添加日志组件。这个组件会接受一个HttpServletRequest对象作为参数，然后产生一条遵循common log格式的日志记录。日志记录类似于：

1.2.3.4 - bob [09/Jun/2006:20:55:59 +0300]

"GET /page.html HTTP/1.1" 200 5678

或者是：

host - user [timestamp] "request line" status bytes

日志中大部分信息都可以从HttpServletRequest对象中读取出来，除了日志记录的最后两个数字，HTTP状态码和请求内容长度。所以，日志组件除了接受HttpServletRequest对象参数外，还需要这两个数字作为参数。

代码清单7-1中为日志组件简单的测试。

代码清单7-1　幼稚的日志输出测试

import static org.easymock.EasyMock.*;

import static org.junit.Assert.*;

import javax.servlet.http.HttpServletRequest;

import org.junit.Test;

public class TestHttpRequestLogFormatter {

@Test

public void testCommonLogFormat() throws Exception {

String expected =

"1.2.3.4 - bob [09/Jun/2006:20:55:59 +0300] "

+ "\"GET /ctx/resource HTTP/1.1\" 200 2326";

HttpServletRequest request =

createMock(HttpServletRequest.class);

expect(request.getRemoteAddr()).andReturn("1.2.3.4");

expect(request.getRemoteUser()).andReturn("bob");

expect(request.getMethod()).andReturn("GET");

expect(request.getRequestURI()).andReturn("/ctx/resource");

expect(request.getProtocol()).andReturn("HTTP/1.1");

replay(request);

HttpRequestLogFormatter formatter =

new HttpRequestLogFormatter();

assertEquals(expected, formatter.format(request, 200,

2326));

}

}

为什么说这个测试很幼稚呢？这是因为日志信息里面包含了时间戳信息，而时间戳信息并不是参数，因此这个组件会获取系统当前时间然后产生出时间戳。所以，代码清单7-1中的测试基本不可能通过。我们需要想个办法绕过对当前系统时间的依赖。

有个简单的办法解决问题：把时间作为参数传到formatter中。不过这不是长久之计，因为我们总要在某个地方获取系统当前时间。此外，把时间作为参数一级级的在方法间传递，实在不是好的编码风格。所以，还是想想别的办法吧。

另外一个稍微好一些的方法是把时间相关的代码抽取到一个函数中，这样在测试时就可以将其覆盖。针对日志组件，这个方法还不错，不过我们可以考虑得更远一些。

在企业级应用中，日志并不是和时间相关的唯一组件。从整体考虑，我们需要的是一个通用的解决方法，而不是给任何时间相关的类都添加一个方法来封装时间操作，就算这个方法只有一行对System#currentTimeMillis的调用也不行。

我们来想想如何抽象出系统时间，以方便进行虚设。

7.1.2　抽象出系统时间

在Java平台下，获取当前时间有很多种方法。可以通过System#currentTimeMillis，也可以直接创建java.util.Date对象。从java.util.Calendar实例也能够得到当前时间。我们可以试着不直接使用这些方法，而是从自己的SystemTime类中获取当前时间，怎么样？

要重构我们当前的代码，使用系统时间抽象，最简单的办法就是把目前的静态方法调用（System#current-TimeMillis及Calendar#getInstance）和实例创建（new Date()），替换成另一个类的静态方法调用，这个静态方法再去调用如下代码：

long time = SystemTime.asMillis();

Calendar calendar = SystemTime.asCalendar();

Date date = SystemTime.asDate();

为了虚设系统时间，我们需要重新配置SystemTime类获取时间的方式。因为我们需要多个时间源，所以有必要引入TimeSource接口，如下所示：

public interface TimeSource {

long millis();

}

在默认情况下，SystemTime类使用的TimeSource会直接调用System#currentTime Millis。在单元测试中，我们会换用假的TimeSource实现。下面来看看如何做实现（当然，还有测试）。

代码清单7-2中为一个简单的测试，验证SystemTime会默认返回System#currentTime Millis所返回的值。

代码清单7-2　验证SystemTime会获取当前真实时间的测试

import static org.junit.Assert.*;

import org.junit.*;

public class TestSystemTimeAbstraction {

@Test

public void clockReturnsValidTimeInMilliseconds() throws

Exception {

long before = System.currentTimeMillis();     /*（以下4行）用System.currentTime Millis()获取System Time的真实值*/

long clock = SystemTime.asMillis();

long after = System.currentTimeMillis();

assertBetween(before, clock, after);

}

private void assertBetween(long before, long actual,

long after) {

assertTrue("Should've returned something between " + before

+ " and " + after + " (instead of " + actual + ")",

before <= actual && actual <= after);

}

}

代码清单7-2中的测试说，SystemTime类应该直接使用System类获取时间。这正好是代码清单7-3中所实现的。

代码清单7-3　直接调用System.currentTimeMillis()

public class SystemTime {

public static long asMillis() {

return System.currentTimeMillis();

}

}

刚才只不过迈出了第一步。我们还要为SystemTime类配置其他的TimeSource。该写另一个测试了，见代码清单7-4。

代码清单7-4　验证SystemTime会使用另外配置的TimeSource

import static org.junit.Assert.*;

import org.junit.*;

public class TestSystemTimeAbstraction {

@After     /*❶（以下4行）每次测试后重置默认的TimeSource*/

public void resetTimeSource() {

SystemTime.reset();

}

...

@Test

public void clockReturnsFakedTimeInMilliseconds()

throws Exception {

final long fakeTime = 123456790L;

SystemTime.setTimeSource(new TimeSource() {     /*❷（以下5行）以固定时间交换TimeSource*/

public long millis() {

return fakeTime;

}

});

long clock = SystemTime.asMillis();

assertEquals("Should return fake time",

fakeTime, clock);     // ❸SystemTime应使用我们的TimeSourcce

}

}

在代码清单7-4中，我们添加了tearDown方法，用于重置SystemTime类，让其继续使用默认的TimeSource❶。如果不重置，那么下面的测试都会继续使用假的TimeSource，这样可能会导致测试失败。要想控制返回的时间，只需要在代码中注入自定义的TimeSource实现，然后让这个实现返回固定时间就可以了❷。这样，SystemTime就可以返回一个任意时间，而非真实系统时间了❸。

实现见代码清单7-5。

代码清单7-5　可配置的SystemTime实现

import java.util.Date;

public class SystemTime {

private static final TimeSource defaultSrc =

new TimeSource() {       /*❶（以下5行）将默认的TimeSource委托给系统时针*/

public long millis() {

return System.currentTimeMillis();

}

};

private static TimeSource source = null;

public static long asMillis() {

return getTimeSource().millis();     // ❷SystemTime请求有效的TimeSource实现

}

public static Date asDate() {

return new Date(asMillis());

}

public static void reset() {

setTimeSource(null);

}

public static void setTimeSource(TimeSource source) {

SystemTime.source = source;

}

private static TimeSource getTimeSource() {

return (source != null ? source : defaultSrc);     // ❸如合适，使用确认的TimeSource

}

}

SystemTime类在一个final static字段中保存着TimeSource的默认实现❶。当查询当前时间时❷，SystemTime会检查是否有新TimeSource实现，如果没有，才使用默认TimeSource❸。

这是一个简单的Strategy模式应用，若所有的地方都使用SystemTime类而非标准API，就会有很大的灵活性。使用TimeSource接口而不是为SystemTime类设置一个固定时间，或让其返回一个硬编码时间，这在许多复杂的模式中非常有用。比如，我们可以通过配置TimeSource实现单元测试中时间的改变。

现在，让我们回到日志组件的开发工作上来，看看如何用新的SystemTime测试驱动开发。

7.1.3　用虚设的系统时间测试日志输出

有两处代码需要修改：一是产品代码中获取当前时间的方式，二是测试代码中对日志组件输出的验证方式。这两处修改都不麻烦，所以直接改吧。改写后的日志formatter测试如代码清单7-6所示。

代码清单7-6　使用虚设系统时间的HTTP请求日志formatter的测试

public class TestHttpRequestLogFormatter {

@After        /*❶（以下4行）别有遗漏*/

public void tearDown() {

SystemTime.reset();

}

@Test

public void testCommonLogFormat() throws Exception {

final long time = SystemTime.asMillis();        /*❷（以下6行）确认固定时间*/

SystemTime.setTimeSource(new TimeSource() {

public long millis() {

return time;

}

});

DateFormat dateFormat =          /*❸（以下6行）在固定时间基础上构建想要的输出*/

HttpRequestLogFormatter.dateFormat;

String timestamp =

dateFormat.format(SystemTime.asDate());

String expected = "1.2.3.4 - bob [" + timestamp

+ "] \"GET /ctx/resource HTTP/1.1\" 200 2326";

HttpServletRequest request =

createMock(HttpServletRequest.class);

expect(request.getRemoteAddr()).andReturn("1.2.3.4");

expect(request.getRemoteUser()).andReturn("bob");

expect(request.getMethod()).andReturn("GET");

expect(request.getRequestURI()).andReturn("/ctx/resource");

expect(request.getProtocol()).andReturn("HTTP/1.1");

replay(request);

HttpRequestLogFormatter formatter =

new HttpRequestLogFormatter();

assertEquals(expected, formatter.format(request, 200,

2326));

}

}

我们要做的第一件事情，依旧是保证测试执行前后系统状态一致❶，例如恢复使用真实的 TimeSource等，因为接下来的测试可能必须使用真实的系统时间。在测试中，我们往SystemTime中注入了一个TimeSource❷，以返回固定时间。这样我们就可以安心地用固定时间产生时间戳了❸。

通过这些小修改，我们的测试在时间方面更健壮了。

1. 采用抽象系统时间API

在产品代码中使用SystemTime类也很简单，见代码清单7-7。

代码清单7-7　在HTTP请求的日志formatter中使用SystemTime

import java.text.*;

import javax.servlet.http.HttpServletRequest;

public class HttpRequestLogFormatter {

public static DateFormat dateFormat =

new SimpleDateFormat("dd/MMM/yyyy:HH:mm:ss Z");

public String format(HttpServletRequest request,

int httpStatusCode,

int contentLength) {

StringBuffer line = new StringBuffer();

line.append(request.getRemoteAddr());

line.append(" - ");

line.append(request.getRemoteUser());

line.append(" [");

line.append(dateFormat.format(SystemTime.asDate()));    // ❶从System Time获取当前日期和时间

line.append("] \"").append(request.getMethod());

line.append(" ").append(request.getRequestURI());

line.append(" ").append(request.getProtocol());

line.append("\" ").append(httpStatusCode);

line.append(" ").append(contentLength);

return line.toString();

}

}

我们只要把产品代码的System#currentTimeMillis调用替换成SystemTime#asDate，用它来产生时间戳即可。很简单吧？同样，把遗留代码的调用全部替换成SystemTime也不会太麻烦。

在讨论下个问题之前，也许有必要看看我们的日志formatter测试：在测试中，我们居然引用了产品代码中定义的DataFormat对象！

2. 测试时间戳格式

代码清单7-6中的测试用了产品代码中定义的DateFormat来得到时间戳的字符串表示。这好像有点儿怪。因为无论这个DateFormat对象采用什么格式，测试都可以通过。

其实我们也可以在测试代码中指定时间戳的精确格式。不过，这会迫使我们第一步就要做出正确的实现。另外，这样还会在产品代码和测试代码间引入重复。我们可以暂且放过这段小的重复代码，也可以将其重构成一个常量，或者添加一个单独的测试验证时间戳的格式（见代码清单7-8）。

代码清单7-8　确定精确的日期格式

public class TestHttpRequestLogFormatter {

...

@Test

public void testTimestampFormat() throws Exception {

String date = "\\d{2}/\\w{3}/\\d{4}";

String time = "\\d{2}:\\d{2}:\\d{2}";

String timezone = "(-|\\+)\\d{4}";

String regex = date + ":" + time + " " + timezone;

DateFormat dateFormat = HttpRequestLogFormatter.dateFormat;

String timestamp = dateFormat.format(new Date());

assertTrue("DateFormat should be \"dd/mon/yyyy:HH:mm:ss Z\"",

timestamp.matches(regex));

}

}

代码清单7-8中用正则表达式验证时间戳的格式。虽然不是精确对比，不过把这种方法和前面的测试结合使用，就可以保证代码正确性了^1。

1 不建议使用这种方法。测试应该面向具体，不要通过复杂计算来验证执行结果。如果验证逻辑太复杂，测试失败时，无法判断是测试写错了还是实现代码写错了。——译者注

其实也可以用正则表达式验证整个日志输出。不过这种方法会忽略时间戳的值，只验证其格式，需要其他测试验证时间戳的值。我们也可以固定时间值，用DateFormat将时间转换为字符串表示，然后验证日志内容正确。这个改动就当你的一次练习吧。

下面我们将会进入另一种不可预测的编程领域。在这个领域中，所执行的任务和任务执行的时间都是不确定的。

7.2　测试驱动多线程代码

所谓“并发”是指事情会同时发生。在Java中，并发只是表面现象，所有的事情依旧会顺序发生，不过由于线程调度器（thread scheduler）会频繁地切换当前运行的线程，所以貌似是在同时发生。而在每个线程中，代码还是按照顺序执行。

因此，我们学到的测试驱动顺序程序的知识在并行编程中仍旧有用，尽管有时我们还需要掌握更多细微的技巧去处理并行编程中遇到的难题。

总的来说，可以先测试驱动出顺序执行的部分，然后再考虑并行部分。把顺序执行逻辑隔离在类或者接口之后，不仅对普通的测试有好处，对并行方面的测试也有好处。不过需要注意，代码中的任何同步操作都会对相邻线程的并发性产生影响。

下面我们将会深入介绍并行行为的测试方法。不过在那之前，我们先来讨论可预测的顺序编程以及不可预测的并行编程间的差别。

7.2.1　该测什么

在执行顺序代码时，我们能确定地知道程序执行顺序及逻辑，而并行的多线程代码不是这样。代码执行顺序不再是确定的了，因为线程间会产生交互，还会因为访问同一个对象而产生竞争。

因此顺序代码和并行代码的测试方法也有区别。测试驱动并行代码时，我们不仅要关注顺序行为，还要关注并行行为，因此出错的可能性更大。

下面来进一步研究一下这两个方面。

1. 更大的出错空间

并行编程出错的可能性更大，各种情形也更难模拟。因此比起顺序编程，并行编程更容易出错。此外，测试代码还可能会影响待测代码的并行性，导致Heisenbugs现象^1。

1 会在测试或调试时消失的bug。

由于可能出现的错误太多，在测试中穷举所有情况并不现实。因此需要结合Java并行编程知识和所开发系统的特征，设计出一些关键场景进行模拟测试。

因为这种人工分析的方式可能会漏掉很多错误，所以我们应当使用重复、长时间的测试来保证系统的并发行为确实不会出现问题。

说到不同类型的错误，并行程序中哪些行为是顺序编程中所没有的呢？

2. 不同类型的并行行为

在面向并行性的单元测试中，我们可以把各种行为划分为两类：

- 好的

- 不好的

我们期望系统能够具有某些并行的行为，但不希望具有另一些行为。我们可以把“好的”定义成希望系统具有的行为，例如能够异步调用服务，而不是在收到请求时直接同步调用。我们把“不好的”定义为系统需要避免的各种情形。计算机科学文献描述了几个多线程代码中常见的问题，其中最常见的为死锁（deadlock）和饥饿（starvation）。

死锁是操作系统的一种状态，当一个或多个进程等待系统资源，而系统资源又同时被此进程本身或者其他进程占用，就形成了死锁。而饥饿是指因为某种原因，线程调度器不给线程分配CPU资源，因此线程没有机会运行。死锁和饥饿都会使程序无法正常运行。

以上各种情况是我们应当避免的，什么样的行为是我们所期望的呢？这些行为是：

- 在线程间共享对象或资源（线程安全）

- 方法阻塞

- 开始及停止线程

- 异步执行

- 线程间同步

要给系统添加上述行为，可以使用测试驱动的方法，这也是下面要介绍的内容。

先从对象的线程安全性开始吧。

7.2.2　线程安全

线程安全是指即使多个线程执行同一段代码或者访问同一个资源，执行语义也不会发生改变。虽然我们开发人员可能认为源代码会一行接一行的执行，但实际上编译器会把干净整齐的代码拆成更小的字节码指令。在多线程的情况下，指令会乱序执行！

Java确实提供了控制多线程执行的方法，不过，我们如何能保证所谓线程安全的代码，真的是线程安全的呢？还好，我们有办法。

1. 局限性

首先，我们要保证测试不会影响代码的并行性，因此在测试中应当避免使用“钩子”来控制产品代码的执行过程，因为这种同步操作会影响产品代码的并行特征。我们应该尽量把握住待测代码的本质特征来进行测试。

在没有可以安全访问的常量时，测试线程安全常用的一种方法是智能强力测试法。所谓智能是指专门优化测试，使其能最大程度暴露出代码中可能存在的问题。通常这类测试都会创建多个线程执行待测代码，并且利用barriers及latches等同步对象最大化并发数目。如果不用barriers及latches，那么线程启动的开销，会让并行执行变为顺序执行，每个线程在前一个线程结束后才开始执行。

下面来看一个例子，演示强力测试和Java 5中的同步对象。

2. 线程安全性测试的例子

假设我们正在开发一个线程安全的计数器。在多个线程同时增加计数时，结果也应该是正确的。

我们先从基本功能开始，下面这个测试保证在单线程情况下执行结果正确。

@Test

public void testBasicFunctionality() throws Exception {

Counter counter = new Counter();

assertEquals(0, counter.value());

counter.increment();

assertEquals(1, counter.value());

counter.increment();

assertEquals(2, counter.value());

}

代码清单7-9中的实现可以通过测试。

代码清单7-9　在单线程情况下正确的实现

public class Counter {

private int counter;

public void increment() {

counter++;

}

public int value() {

return counter;

}

}

那么，我们如何能保证在多线程同时访问的情况下，我们的Counter类依旧能够正常工作呢？当然是写测试了！在测试中，用多个线程同时访问Counter实例，验证不会漏计数。

代码清单7-10中是测试的实现方式之一。

代码清单7-10　启动多个线程增加计数

@Test

public void testForThreadSafety() throws Exception {

final Counter codeUnderTest = new Counter();

final int numberOfThreads = 20;

final int incrementsPerThread = 100;

Runnable runnable = new Runnable() {

public void run() {

for (int i = 0; i < incrementsPerThread; i++) {

codeUnderTest.increment();

}

}

};

for (int i = 0; i < numberOfThreads; i++) {    /*❶（以下3行）启动线程*/

new Thread(runnable).start();

}

Thread.sleep(500);     /*❷（以下3行）等待线程结束并验证Counter的数值*/

assertEquals(numberOfThreads * incrementsPerThread,

codeUnderTest.value());

}

在测试7-10中，我们启动了多个线程同时访问Counter对象❶，然后验证计数值与increment方法的调用次数一致❷。

这个测试大约只有一半的几率会失败，虽然Counter摆明了不是线程安全的（因为++操作符不是原子操作，也没有任何其他同步机制）。这个测试可以改进的方面还有许多。

3. 最大化并发性

代码清单7-10中的测试常常不失败，这是因为各个线程恰巧为顺序执行，而非并行执行。在这种情况下，我们可以延长执行时间，在第一个线程结束前，让其他线程有足够的时间启动。在这个例子中，可以增加每个线程调用Counter的次数来延长执行时间。

除此之外，我们还可以使用barrier等同步对象来保证所有的线程都启动之后才会执行计数操作。这样还能够避免调用Thread#sleep，因为这方法徒增加测试执行时间，还不能保证等待之后所有线程都能结束执行。

代码清单7-11中为改进后的测试。每个线程调用计数器的次数都增加了10倍，此外使用了CyclicBarrier^2以避免线程启动的额外开销给并行性带来的影响。

2 在本章稍后部分将会讨论CyclicBarrier和他的朋友们。

代码清单7-11　改进后的测试

@Test

public void

testForThreadSafetyUsingCyclicBarrierToMaximizeConcurrency()

throws Exception {

final Counter codeUnderTest = new Counter();

final int numberOfThreads = 20;

final int incrementsPerThread = 1000;

CyclicBarrier entryBarrier =      /*❶（以下4行）为正创建的线程创建barrier并加上当前线程*/

new CyclicBarrier(numberOfThreads + 1);

CyclicBarrier exitBarrier =

new CyclicBarrier(numberOfThreads + 1);

Runnable runnable = new Runnable() {

public void run() {

for (int i = 0; i < incrementsPerThread; i++) {

codeUnderTest.increment();

}

}

};

for (int i = 0; i < numberOfThreads; i++) {

new SynchedThread(runnable, entryBarrier,      /*❷（以下2行）定制线程在barrier处等待*/

exitBarrier).start();

}

assertEquals(0, codeUnderTest.value());

entryBarrier.await();     // ❸线程开始执行

exitBarrier.await();      // ❹等待所有线程结束

assertEquals(numThreads * incrementsPerThread,

codeUnderTest.value());

}

代码清单7-11中引入了两个CyclicBarrier同步对象❶。CyclicBarrier就像是个水闸，等在闸前的线程足够多时，才会开闸放线程。我们自己创建了一个Thread类❷，在其内部使用这些同步对象。在执行测试逻辑前，线程会在入口barrier处等待，而执行结束后，线程会在出口barrier处等待其他线程，见代码清单7-12所示。

因为所有的线程都在入口barrier处等待，所以我们可以在测试中调用入口barrier❸，在所有的线程都启动完毕后再调用计数器——不让线程启动开销给测试带来任何影响——然后再调用出口barrier来阻塞测试方法，直到所有线程都完成工作后才继续❹。

代码清单7-12　同步执行的自定义线程类

import java.util.concurrent.CyclicBarrier;

public class SynchedThread extends Thread {

private CyclicBarrier entryBarrier;

private CyclicBarrier exitBarrier;

public SynchedThread(Runnable runnable,

CyclicBarrier entryBarrier,

CyclicBarrier exitBarrier) {

super(runnable);

this.entryBarrier = entryBarrier;

this.exitBarrier = exitBarrier;

}

@Override

public void run() {

try {

entryBarrier.await();    // 等待其他线程

super.run();    // 执行

exitBarrier.await();    // 暗示完成

} catch (Exception e) {

throw new RuntimeException(e);

}

}

}

代码清单7-12中改进过的测试已经能够更加稳定地失败了，这说明某些计数操作被Counter类忽略了，因为其并不是线程安全的。这时我们必须要在increment方法上标记synchronized关键字了。

虽然线程安全性的测试比基本程序逻辑测试更加复杂，不过也不是不能实现。虽然因为线程调度的不可控性，多线程测试不是完全可靠，但如果能深入了解待测问题，提高期望线程调度顺序发生的概率，还是能将多线程测试的不稳定性带来的负面影响降到最低的。

除了线程安全性外，阻塞操作的测试也值得研究。

7.2.3　阻塞操作

有时候，我们希望某些方法在完成全部操作前都处于阻塞状态。例如，事务接口上的commit函数应当在事务完全提交后才返回，继续下面的操作。总的来说，我们期望一个方法一直处于阻塞状态，直到某个特定的事件发生。

这给测试带来一系列的问题：我们如何知道方法阻塞了？在确认方法确实处于阻塞状态后，又如何让方法继续执行？下面先来看个例子，然后再想办法解决这些问题。

1. 从黑市买票

假使明天是你夫人的生日，你答应过她一起去看Knicks game，不过你把这事儿给忘了，没买票。等想起来时，票都已经卖完了。现在只能从黑市买票了，但是运气不佳，一直没买到。这种情况相当于调用了阻塞的BlackMarket#buyTicket方法。

因为待测方法会阻塞，所以有必要另起一个线程调用这个方法。此外，我们还需要在线程中知道方法执行是否成功，也要知道方法是否真的阻塞了。方法阻塞后，我们还需要中断调用线程验证执行已经完毕。见代码清单7-13。

代码清单7-13　测试验证线程受阻且已经执行完毕

@Test

public void testBlockingBehavior() throws Exception {

final AtomicBoolean blocked = new AtomicBoolean(true);

Thread buyer = new Thread() {

@Override

public void run() {

try {

new BlackMarket().buyTicket();     // ❶在另一个线程中调用阻塞方法

blocked.set(false);    // ❷如方法可行，设置标记

} catch (InterruptedException expected) {

}

}

};

buyer.start();      /*❸（以下4行）线程开始，等待，中止*/

Thread.sleep(1000);

buyer.interrupt();

buyer.join(1000);

assertFalse("Thread didn't interrupt!", buyer.isAlive());    /*❹（以下2行）验证*/

assertTrue("Method didn't block!", blocked.get());

}

代码清单7-13中，我们创建了新的线程，调用了BlackMarket的buyTicker方法❶。这个方法应该一直阻塞，直到有人调用sellTicket方法。如果这个方法没有阻塞，那么我们会设置一个标记❷，测试会失败。我们在buyTicket方法处检查InterruptedException异常，因为接下来我们会中止线程，让方法从阻塞状态中恢复。

一旦创建好这个线程，我们就可以让其运行❸，等待1秒钟后，让线程调用阻塞方法。接着中止了线程，等待线程执行完毕。最后验证线程确实执行完毕了，方法也确实阻塞了❹。

这个测试可能有些复杂了，我们只是测试了一个方法是否是阻塞的而已。不过这至少证明方法是可行的，而且只要做个小的重构把共用部分提取到基类中，就可以重用了。这就当作你的一次练习吧。

继续下面的内容前，我们先来看看线程API中一些需要注意的问题。

2. 不要依赖于线程状态

如果熟悉Java线程API，你可能会知道Thread类有个getState方法，似乎正好用来测试线程阻塞。不过Thread#getState并不可靠。JVM规范里面并没有要求线程何时必须进入等待（或阻塞）状态。此外，线程也可能从Object#wait中假恢复过来，一个阻塞的线程也可能临时切换到运行状态。所以还是忘了Thread#getState吧，想想其他办法。

7.2.4　启动及中止线程

多线程系统肯定会涉及到线程的启停。有的系统启停线程十分频繁，有的少一些。有的软件架构会向应用开发人员隐藏背后的线程，而有的会暴露所有细节。假使我们需要写代码启动及停止线程，来看个例子如何用Java5 中的java.util.concurrent 包提供的新API完成这项工作。

1. 用工厂启动线程

在Java 5之前的版本中，我们只能直接创建Thread实例启动线程。Java 5中引入了ThreadFactory接口（如代码清单7-14所示）来创建Thread对象。

代码清单7-14　java.util.concurrent.ThreadFactory

public interface ThreadFactory {

public Thread newThread(Runnable runnable);

}

ThreadFactory接口实际上隔离了线程创建代码和Thread的具体实现。有了ThreadFactory接口，再加上依赖注入，测试线程的启停将变得轻而易举。下面看个例子。

2. 用ThreadFactory及自定义线程

假设我们要开发一个能随意启停的Server类。当Server开始时，后台会启动一个线程来监听网络连接请求，当Server停止时后台线程也应该停止。

我们打算给Server类一个自定义的ThreadFactory，用其创建Thread对象，这样我们在测试中就可以进行检测了。我们首先需要一个自定义的Thread类提供必需信息，代码清单7-15中为Thread类的实现。

代码清单7-15　提供了同步点的自定义线程类

import java.util.concurrent.*;

import static junit.framework.Assert.*;

public class StartStopSynchronizedThread extends Thread {

private CountDownLatch threadStarted;

private CountDownLatch threadStopped;

public StartStopSynchronizedThread(Runnable task) {

super(task);

threadStarted = new CountDownLatch(1);

threadStopped = new CountDownLatch(1);

}

@Override

public void run() {

threadStarted.countDown();     // ❶Latch对象等待线程启动及停止

super.run();

threadStopped.countDown();      // ❶Latch对象等待线程启动及停止

}

public void waitForStarted(long timeout, TimeUnit unit)     /*❷（以下10行）在线程开始和结束前提供阻塞方法*/

throws InterruptedException {

assertTrue("Thread not started within timeout.",

threadStarted.await(timeout, unit));

}

public void waitForStopped(int timeout, TimeUnit unit)

throws InterruptedException {

assertTrue("Thread not stopped within timeout.",

threadStopped.await(timeout, unit));

}

}

代码清单7-15中是一个简单的类❶，包装了Thread类的run方法，在其前后各放置了一个叫做latch的同步对象，这些latch用于等待线程的启动及停止。只有当此线程完成启动或停止执行后，其他线程才可以利用waitForStarted及waitForStopped❷方法等待线程的启动及停止。

现在我们可以试着用这些方法测试驱动出Server类的start及stop方法了。代码清单7-16中为样例代码。

代码清单7-16　验证线程启动及停止的测试

import java.util.concurrent.ThreadFactory;

import org.junit.*;

public class TestServerStartsAndStopsThread {

private StartStopSynchronizedThread thread;     // ❶创建自定义线程，并留作后用

@Test

public void testStartingAndStoppingServerThread() throws

Exception {

ThreadFactory threadFactory = new ThreadFactory() {

public Thread newThread(Runnable task) {

thread = new StartStopSynchronizedThread(task);     /*❶（以下2行）创建自定义线程，并留作后用*/

return thread;

}

};

Server server = new Server();

server.setThreadFactory(threadFactory);    // ❷带自定义Thread Factory的确认服务

server.start();

thread.waitForStarted(1, TimeUnit.SECONDS);    // ❸使用自定义的latch验证线程是否开始/结束

server.stop();

thread.waitForStopped(1, TimeUnit.SECONDS);     // ❸使用自定义的latch验证线程是否开始/结束

}

}

代码清单7-16中的测试可以分解成几部分：首先创建一个自定义的ThreadFactory，这个ThreadFactory会返回一个自定义的线程类的实例。创建出ThreadFactory后，将实例传给Server，让Server使用我们自定义的ThreadFactory，而非java.util.concurrent.Executors中的系统默认的ThreadFactory。

在测试的余下部分，我们首先启动了Server，然后再用自定义线程类的latch等待线程启动（或者超时，测试失败）。当启动latch触发时，我们会停止Server，然后用停止latch等待后台线程停止执行。

7.2.5　异步执行

我们已经看到了如何测试现成的启动及停止。不过，在测试中并没有验证线程启动后做了些什么。既然启动线程是有原因的，那么验证线程启动后所做的工作也是有必要的。那么，该怎么测试呢？

1. 用typed Runnables 分解问题

方法之一是使用typedRunnable实现，验证待测代码给我们那自定义的ThreadFactory传入了正确类型的Runnable实现。接下来我们就可以单独测试Runnable实现了。这样做是为了把问题分解成独立的两部分，分别完成后整个问题也就解决了。不过这种解决方法看起来有些奇怪，而且在Java并行编程中并不算常见，因此我们来尝试另外一种更常见（但并不一定更好）的测试方法：等待结果。

2. 等待结果

要验证异步调用结果，我见过的所有程序员基本上都选择等待。等足够长时间，然后验证异步执行的结果。见代码清单7-17。

代码清单7-17　等待异步执行结果的测试

LongLastingCalculation calc = new LongLastingCalculation();

calc.start();

Thread.sleep(2000);     // 计算需要1秒，等待需要2秒；需要确认

assertEquals(42, calc.getResult());

要等多长时间，显然取决于待测代码，而且每个测试的必要等待时长都不一样。因为不想让测试随机性的失败，所以我们通常会等待足够长时间，让异步操作完成工作。在这个例子中，我们假设2秒钟足以让异步调用完成计算。如果实际计算时间只需要1秒，那么每次运行这个测试我们都会浪费1秒钟。

理想情况下我们可以注册某个回调函数，当异步调用结束时这个函数会被调用，但并不总能这样（例如我们要测试的某个外部API）。如果不能采用回调，那么我们仍旧可以采用polling的方法进行优化。代码清单7-18中是名叫RetriedAssert的工具类^3，RetriedAssert能让我们在一个循环中执行自定义的验证方法，直到验证通过或者超时循环才会停止。

3 这个类由Kevin Bourrillion编写，在JUnit Yahoo! Group里可以随意下载，在这里改写成了JUnit 4版本。

代码清单7-18　重试验证方法，直到验证失败或者超时

/**

* This class allows you to assert a condition that may not be

* true right at the moment, but should become true within a

* specified time frame. To use it, simply replace calls like:

*

* assert(someCondition);

*

* With:

*

* new RetriedAssert(5000, 250) { // timeout, interval

*   public void run() throws Exception {

*     assert(someCondition);

*   }

* }.start();

*

* The start() and run() methods were named after those in

* java.lang.Thread, whose function they mimic.

*

* This class was written by Model N, Inc. You may use it and

* modify it any way you wish--but please leave this message intact.

*

* @author Kevin Bourrillion (kevinb@modeln.com)

*/

public abstract class RetriedAssert {

private int _timeOutMs;

private int _intervalMs;

protected RetriedAssert(int timeOutMs, int intervalMs) {

_timeOutMs = timeOutMs;

_intervalMs = intervalMs;

}

public final void start() throws Exception {

long stopAt = System.currentTimeMillis() + _timeOutMs;    /*❶（以下2行）超时前一直循环*/

while (System.currentTimeMillis() < stopAt) {

try {

run();     /*❷（以下2行）测试通过后验证并返回*/

return;

} catch (AssertionError ignoreAndRetry) { }

try {

Thread.sleep(_intervalMs);     // ❸再次验证前等待一段时间

} catch (InterruptedException ie) {}

}

// All tries have failed so far. Try one last time,

// now letting any failure pass out to the caller.

run();

}

public abstract void run() throws Exception;

}

RetriedAssert的基本想法是：❶执行一个循环，直到超时。❷在循环中我们可以做验证，❸等待一段时间，然后再进行验证，直到通过测试或者超时。如果验证失败，测试将失败。

代码清单7-19演示了如何用这种工具类验证长时间执行的异步调用，同时不浪费额外的等待时间。

代码清单7-19　使用RetriedAssert

@Test

public void testByRetryingTheAssertOften() throws Exception {

final LongLastingCalculation calc =

new LongLastingCalculation();

calc.start();

new RetriedAssert(2000, 100) {      // ❶指定超时时长

@Override      /*❷（以下4行）覆盖抽象的run()方法*/

public void run() {

assertEquals(42, calc.getResult());

}

}.start();

}

实际上RetriedAssert并不是什么高级货。我们❶只需要指定超时时长，指定每次验证之间的时间间隔，然后❷再提供验证逻辑就可以了。假设代码清单7-19中的LongLastingCalculation需要1秒的执行时间，那么这个测试基本上会在1100毫秒内完成。如果计算任务需要2秒，那么验证方法则需要2秒多即可完成。

注意，如果执行结果不正确（例如返回了13，而不是42），测试不会立即失败，而是要等到RetriedAssert超时。不过，比以前的方法还是好点。

迄今为止，我们讨论了用线程异步工作的测试技巧和工具。下面来讲如何测试多个线程间的协作吧。

7.2.6　线程同步

完成一项大的任务通常需要各方面的通力合作。例如建造一所度假屋，需要木工、管道工、粉刷匠等各种人参与。粉刷匠在粉刷之前，必须要等其他工人把墙做好，否则没得刷。在软件领域也是一样，各个线程之间时常需要同步。那么，我们该如何测试线程同步呢？

大多数情况下，我们可以直接让线程执行，然后验证执行结果。把这种方法和分治法相结合，就可以确保功能正确了。毕竟，多线程也不过是由单个线程组成的，在同步点之前，单个线程的运行都是独立的。只要多花些功夫，再找测试替身帮忙，我们完全可以保证单个线程行为正确，也可以验证大部分可能出错的情形。

多线程合作最终可以分解成一组简单的同步问题——只要我们清楚工具的使用方法，每一部分都很容易测试。在总结本章前，先来看看Java 5里提供的同步对象吧。

7.3　标准同步对象

在多线程编程里，如果每个线程间完全独立，那么确实很理想，不过线程间时常需要同步。多线程是编程方面最困难的领域之一，因此最好能学着使用Java 5的java.util.concurrent包里提供的各种线程间通信的抽象（abstraction），而不要在底层的锁（lock）或者wait/notify方法基础上重新发明各种同步工具。

在本章前面的内容中，我们已经使用过了一些标准同步对象。现在，我们会对所有的同步对象进行简单介绍，遇到问题时就知道该用什么工具解决了。

先从最简单的信号量开始介绍。

7.3.1　信号量

信号量（Semaphore）实际上是一个许可的有限集。线程从信号量中获得许可，然后才能做自己的工作。当工作完成后，线程会把许可交回给信号量，让其他线程使用。

计数信号量可能是使用最广泛的一种了。这种信号量内有多个许可，可以限制特定资源最大的并发访问个数。

比如我们要写一个p2p工具，支持多文件同时下载。每个文件开始下载前先要从信号量中获得许可。如果下载完成或者取消，那么许可将会被释放回信号量中，这时队列中的下载请求就可以获得请求开始下载了。这是技术信号量使用的典型场景之一。此外，还有一种只包含一个许可的信号量。这种信号量实际上蜕化成了经典的同步锁，一次只让一个线程访问关键的共享资源。

当简单的信号量不能满足需求时，我们可以用其他更复杂的同步方式，例如latch。

7.3.2　latche

latch就像一个坝，所有到达latch的线程都必须等待latch释放才能通过。latch一经释放，就会一直处于开启状态。也就是说latch是一次性的同步点。

java.util.concurrent包中包含了CountDownLatch类。当有一定数量的线程调用计数方法后，CountDownLatch会自动释放。我们已经见到多处代码清单中用到了CountDownLatch（例如在代码清单7-15中），其主要用来阻塞主线程，直到并发执行的线程到达某个执行点后主线程才继续执行。

7.3.3　barrier

barrier可以说是特殊的latche。它和latch的主要区别在于，latch 是一次性的,而CyclicBarrier可以重复使用。此外，latch可以在所有线程都经过前即开始释放，而barrier通常需要所有的线程都在barrier处等待，然后才一起释放。

我们可以用barrier让5个线程给我们表演实境节目。这5个线程每个调用barrier对象的await方法等待其他线程，所有线程都就绪后同时开始执行。代表竞赛各方的线程都到达barrier后，barrier开始释放，5个线程同时执行任务，执行完后又在barrier处等待——等其他线程准备就绪后再开始下一轮竞赛。

除了CyclicBarrier外，java.util.concurrent包还提供了Exchanger。这种特殊的barrier专门用来让线程交换数据。拥有两个独立的线程并从一个线程读取数据，然后写入另一个线程的I/O组件，是学习使用Exchanger的标准示例。从本质上说，当读取线程填充完其缓冲区，而写入线程写完原有数据时，它们会通过Barrier来交换缓冲区。

说到交换数据，下面还有一个标准同步工具要介绍——Futures。

7.3.4　future

future，由Future接口表示，由Future Task类实现。本质上，它是一个可以取消的异步任务，这个任务会产生一个结果。有了它以后，无须编写相应的同步代码，只要在调用线程和在后台计算结果的线程之间共享future即可。调用线程可以调用Future的get，进入阻塞状态直到结果返回（或者在通过重载方法配置超时的情况下超时）。

信号量、latch、barrier和future这4种同步对象能帮我们更容易地构建多线程应用程序。技多不压身，掌握这些同步对象的使用对测试驱动多线程应用程序肯定是有益无害的。

现在来对本章内容做个总结吧。

7.4　小结

本章主要内容是测试驱动不可预测型代码。时间相关的功能和多线程是其中最重要的两种类型。

我们首先研究了一个日志组件的例子，这个组件的功能是记录带有时间戳的日志信息。我们试着用许多方法测试组件的行为，包括对日志信息的时间戳部分进行模糊匹配，也试过隔离出时间相关的代码部分，然后再测试中重写以方便测试。

除了这些较为简单直观的方法外，我们还讨论了如何把时间这种不可预测的值包装在接口之后，然后将其替换成我们可控的值。用自定制的类替代系统时间类，使很多复杂的功能变得很容易测试。

解决完棘手的时间问题后，我们转而研究了多线程程序。我们首先特别讨论了多线程程序和单线程顺序执行的程序间的本质区别。

本章的余下部分讨论了如何测试驱动各种不同的多线程行为。我们讨论了如何测试多线程访问共享资源时的线程安全性。我们还讨论了如何测试线程的阻塞状态（以及如何消除阻塞状态），同时讨论了如何测试线程的启动及停止。最后我们讨论了如何测试异步方法调用以及如何把线程间的同步操作分解成一个个简单问题逐个测试。

为了能更好地处理多线程并行编程中遇到的问题，本章最后简要介绍了Java标准API中提供的同步对象。

总之，并行测试会与代码实现细节相关，比顺序执行的代码更难测试。不过我们也看到了，用测试先行的方法构建多线程应用仍旧可行。而且就是因为并行编程的复杂性，才使得测试先行的开发方法变得更有必要。此外，要保证代码的可测试性，写代码时测试先行是最好的办法。

第8章　测试驱动Swing代码

设计并不仅仅是指产品的外观和给人的印象，还包括产品的性能。

——乔布斯

我开发的大部分系统都是后台应用，这些应用没有用户界面。这类系统很容易做自动化测试，因为总有些“技术界面”可以做自动化操作。不过若是有了图形化的用户界面（GUI），事情就没这么简单了。GUI不仅仅是屏幕上会动的控件，还必须简单易用，同时还要美观。

在本章中，我们将会探索Java Swing开发的TDD方法。首先讨论应该在Swing用户界面上测试些什么。做这种讨论的原因是，对于GUI中的某些部分，做测试自动化是没有意义的，所以也没有必要用TDD。在确定GUI测试的问题领域后，我们会介绍一些设计模式，用来帮助测试驱动GUI代码。

先确定问题，再了解Swing GUI的组织结构、Swing代码的测试工具以及开源的Abbot。最后我们会用JUnit和Abbot库，从零开始测试驱动出Swing代码。

准备好了吗？

8.1　Swing UI中该测试什么

在图形界面中该测试些什么？这个问题可不好回答，除非你能接受“看情况”这种一般人接受不了的答案。虽然没有一个通用的准则让Swing开发人员遵循，但我们还是可以依据类别和特性把Swing代码分成几类，每一类有各自的测试方法。

我们可以考虑Swing GUI的以下几个方面：

- 内部基础设施及实用程序

- 渲染及样式

- 交互

下面，我们将分别讨论这3种类型及其测试。

先从内部基础设施开始吧。

8.1.1　内部基础设施及实用程序

虽说UI主要用来显示，但这并不表明所有的代码都和显示有关。部分UI代码与后台系统、批处理系统或其他并不那么“可视化”的代码并无本质区别。

Swing用户界面需要对用户输入的数据进行验证、类型转换、格式化字符串显示，等等。这些工作和Swing API完全没关系，测试驱动也很容易。

但是，如果使用了Swing API，那么就不一样了。这正是我们需要讨论的：渲染及布局。

8.1.2　渲染及布局

Swing API是专门用来绘制和组织GUI控件的。如果要显示一个按钮，那么就把按钮控件放到布局上。如果要在很小的一块区域中显示很多东西，则需要一个带滚动条的容器。如果要用图形显示数据库中的数据，那么就需要创建一个画布控件，在这个控件上画点连线。这些都是Swing GUI的可视部分。现在我们的问题是，从渲染和布局角度看，该测些什么？

提示　不要测试外观和布局。

总的来说，我不测布局。如果要做一个包含两个按钮的面板，我不会写测试验证第一个按钮距容器顶部3个像素，距容器左端5个像素。以像素精度测试布局会降低开发速度，在提高代码内部质量方面却没什么帮助。这部分验证工作，我希望留在目测阶段进行，因为无论如何我们都是要看看GUI长什么样子的。

我们有时会测试界面的大体布局。例如可能有这样的测试“如果面板C显示出来了，那么按钮A应该在按钮B的上面”或者“按钮A和B在屏幕上应该有同样的宽度”。这类测试虽然不是很精确，但比较健壮，因为控件位置的微小调整不会让测试失败（例如，把空间宽度从200像素调到192像素）。

不过有时候也需要以像素级精度进行测试，例如画布控件。我们可以写一些像素级别的测试验证小圆圈，画在点的周围，或者验证线是蓝色还是绿色取决于它是否有一个端点位于X轴的下方，等等。

写像素级别的测试时，要掌握度。例如在编写plotter控件时，大部分测试应该覆盖内部模型，就是plotting数据。只要一小部分测试覆盖简单数据集在画布上的显示结果即可。而且如果不必要，就不要验证像素。

工具代码（Utility code）、渲染及布局并不是Swing GUI的全部。Utility代码只不过是独立的工具和函数而已，而渲染和布局也只不过是一些只读的东西。我们还没有讨论到如何给GUI添加行为——用户输入或后台线程引发的控件间的交互行为。

8.1.3　交互

按钮、下拉列表框及其他GUI 控件，如果用户不用来和应用程序交互，那么一点用处都没有。这些控件就是要接受用户输入，改变系统状态的。有时候，控件需要和其他GUI控件交互，例如根据其他控件的状态过滤下拉列表框的可选值。

比起实用程序代码这种和Swing API完全没关系的代码，控件间的交互行为并不好测试。不过也不是需要拿到博士学位才能做好。只要熟悉Swing API，就能够测到我们想测的交互行为。例如，要模拟或者验证控件输入，首先要知道Swing控件如何处理用户输入才可以。

要演示这种交互行为的测试方法，最好能用一个例子，本章后面会有这样一个例子。在这之前，我们先来熟悉一些用户界面的设计模式，用于隔离GUI控件的外在表现和内部行为。

8.2　可测试UI代码的模式

要说UI模式，不能不提模型—视图—控制器（MVC）架构。它起源于Smalltalk-80，对GUI程序构建方式的影响持续至今。长话短说，图8-1中刻画了MVC 3个部分间的关系。

图8-1　MVC三元组及各部分的关系。控制器根据视图的输入来操作模型；模型只关注于领域逻辑，完全不知道视图和控制器的存在；视图会监听模型的变更，也会显示模型的数据

在MVC中，控制器用来响应视图中的控件所捕获的用户输入，相应地改变模型，然后通知视图进行更新。模型主要用来封装领域逻辑，如果用了Observer模式，领域模型发生变化时要通知视图。View负责，把模型上的数据显示出来。

虽然和经典的MVC模式不完全一样，Java Swing的设计与MVC十分相似。在Java Swing中，视图和控制器合并在了一起，因为“视图和控制器本身就是紧耦合的”。

这种合并也使得Swing UI可测试性受到了影响，因为没法隔离出视图来独立测试控制器。重复一遍Martin Fowler的话，可视对象总比不可视的难测些。大多数情况下我们都可以（也应该）在一个简单容器窗口中实例化控件，然后模拟用户交互行为来测试控制器和视图的逻辑。

不过这种办法并不总是可行，而且可能会带来其他麻烦，例如让测试变得很慢等。所以，如果可以，我们还是希望能分开测试控制器逻辑和表现层。幸好，MVC的一些兄弟能帮到我们。

下面我们会介绍3个相互联系的模式。这些模式能帮助UI代码的组织以提高可测试性，让富客户端的TDD变得更容易。这些模式是：

- 经典MVP（Model-View-Presenter）

- Supervising Controller

- Passive View或The Humble Dialog Box

所有这些变体基本反映的是同一个主题——把逻辑从视图中移出去，让它离视图越远越好，这样就能解决测试问题了。实际上，可以把后两个模式当作经典MVP模式的变体。

下面先简要介绍经典MVP模式，然后再看Supervising Controller和Passive View（Humble Dialog Box）各自的特点。

8.2.1　经典MVP

经典MVP模式最初由Taligent公司于1996年提出。在最初的设计中，视图中的控件会把接收到的用户输入直接转给presenter对象，由其进行事件处理。视图监听模型的变化以更新自己的状态（Observer模式）。

这样，视图只管渲染模型，没有其他任何逻辑，因此利用presenter或模型的测试替身做测试也很容易。同样，若presenter不依赖于Swing API，那么只要给presenter传入事件对象，验证模型做了相应改变即可。

在具体实践中通常会用MVP的两个变体，Martin Fowler（许多设计及架构相关书的作者）对此有文章详细描述。这两种变体是Supervising Controller及Passive View，下面来进行介绍。

8.2.2　Supervising Controller

Supervising Controller模式是MVP模式的一个变体，其中presenter（或controller）负责处理所有用户输入（视图把所有用户输入都直接转给presenter），视图—模型间的数据同步用binding机制来处理。Binding机制与observer模式类似，模型对象发生变动时会通知监听者，即presenters，presenter会继而通知视图控件从模型对象中读取数据，更新视图。

在这种presenter和视图对象的组织方式下，视图层基本没有逻辑。再强调一遍，这点很重要，因为视图层的代码比普通Java代码更加难测。

为了讲得更清楚，下面来看一个例子。假使我们要为一个单元测试框架开发一个图形界面。这种界面上一定要个绿条，否则不圆满，所以我们这里加了一个。图8-2中为绿条UI的基于Supervising Controller的设计图。

图8-2　绿条UI的基于Supervising Controller的设计图

在图8-2中，首先，一定要一个领域模型，我们称之为GreenBarModel。其表示上次测试执行的结果，也是我们绿条控件需要显示的模型。GreenBarSupervisingController会监听GreenBarModel的变化，然后通知GreenBarView根据测试结果来更新颜色。GreenBarSupervisingController也会监听视图来处理用户输入（在本例中，就是点击Run按钮）。

代码清单8-1中为这个简单的presenter的实现。

代码清单8-1　Supervising Controller模式中presenter的实现

public class GreenBarSupervisingController {

private final GreenBarModel model;

private final GreenBarView view;

public GreenBarController(GreenBarView view,

GreenBarModel model) {

this.view = view;

this.model = model;

view.addRunButtonListener(new RunButtonListener() {     /*❶（以下6行）注册用户输入*/

public void onRunButtonClick() {

model.runTests();       // ❷在模型对象上运行

updateView();      // ❸通知视图更新

}

});

updateView();

}

private void updateView() {     /*❸（以下3行）通知视图更新*/

view.update(model);

}

}

从代码清单8-1中可见，presenter会把事件处理逻辑❶挂到从视图传来的各种用户输入上，这些处理逻辑会修改模型对象❷，然后再触发视图更新❸。因为GreenBarView只是个接口，GreenBarModel也是个普通Java对象，所以presenter十分容易测试。Presenter丝毫没有用到javax.swing——这一切全在视图中。

既然提到视图了，我们来看看Supervising Controller风格的GreenBarView：

public interface GreenBarView {

public void addRunButtonListener(RunButtonListener listener);

void update(GreenBarModel model);

}

接口相当简单。一个方法用来注册新的事件处理逻辑，另一个用来通知视图用GreenBarModel模型中的数据更新视图显示。相应的Swing实现也很容易测试。

- 初始化View类，注册一个监听器，模拟点击Run按钮，然后验证监听器收到了事件。

- 初始化View类，将模型传给它做更新用，然后验证控件的新值正确。

一会我们将讨论如何实现这些测试。不过现在，先集中注意到presenter及整个模式上吧。

注意到了吗？代码清单8-1中，presenter把整个模型都传给了视图。在这个例子中，这么做没什么问题。而在实际应用中，presenter会以更细的粒度通知视图进行局部更新。这是在耦合性和性能之间做权衡。（比起这个简单的例子，这种考虑在复杂的UI上体现得更明显。）

粗粒度的全屏更新可能会导致严重的性能及可用性问题，如果只更新需要更新的那部分，则会好得多。而从另一个角度看，如果做细粒度的更新，那么控制器和视图间的耦合会更紧密，这样也不好。在实际操作时，我建议先从粗粒度的更新开始，因为这方法更自然些。如果性能问题逐渐显现出来了，再把更新动作拆分开。

刚才介绍的就是MVP模式的Supervising Controller变体。下面来介绍它的另一个变体Passive View模式，它能让视图变得更加薄（也更容易测）。

8.2.3　Passive View

Passive View模式（也叫Humble Dialog Box，请参照Michael Feathers同名文章）是另外一种MVP模式的变体。Passive View中的presenter除了要处理用户输入外，还要负责更新视图。视图只需要接受用户输入然后转给presenter即可。如果视图需要更新，那presenter会告诉视图具体哪些部分要发生变化。也就是说，视图和模型之间不再有任何联系了。

比起Supervising Controller，Passive View的视图层逻辑更少了。视图对象除了被动地显示外，什么都不做，如图8-3所示。

图8-3 Passive View (The Humble Dialog Box)中的视图层非常薄，把尽可能多的视图逻辑封装到presenter中

在图8-3中的Passive View设计中，我们当然还要有一个GreenBarModel。这时GreenBarPresenter会监听GreenBarModel对象的变化，根据模型中的数据来决定显示何种颜色，然后再告诉GreenBarView更新显示条的颜色。和Supervising Controller一样，GreenBarPresenter也需要在视图上注册事件处理逻辑来响应用户输入。

代码清单8-2中为图8-3的实现方法之一。

代码清单8-2　在Passive View模式中presenter的实现

public class GreenBarPresenter {

private final GreenBarModel model;

private final GreenBarView view;

public GreenBarPresenter(final GreenBarModel model,

final GreenBarView view) {

this.model = model;

this.view = view;

view.addRunButtonListener(new RunButtonListener() {     /*❶（以下6行）注册输入*/

public void onRunButtonClick() {

model.runTests();    // ❷运行模型

updateView();       // ❸更新视图

}

});

updateView();

}

private void updateView() {

Color newColor = (model.numberOfFailures() == 0)    /*❹（以下4行）presenter中的显示逻辑*/

? Colors.testsPassed()

: Colors.testsFailed();

view.setBarColor(newColor);

}

}

代码清单8-2中的代码和代码清单8-1中的区别不大。我们一样要在视图上注册事件处理逻辑响应用户输入❶，也要相应地修改模型❷，更新视图❸。所不同的是presenter会主动找出需要更新的内容❹，而不是直接通知视图去自我更新。

在Passive View中，视图完全不知道模型的存在——它只知道有人告诉它要用给定的颜色重绘显示条，如下代码所示：

public interface GreenBarView {

public void addRunButtonListener(RunButtonListener listener);

public void setBarColor(Color color);

}

对比一下视图的实现方式就能看出，Passive View模式把逻辑从视图更彻底地移到了presenter中。这样视图只需要接受用户输入，将输入转给presenter，然后再显示presenter让它显示的东西就行了。这样视图就更容易测试了。

- 初始化视图类，注册监听器，模拟点击Run按钮，然后验证监听器接收到了事件。

- 初始化视图类，给视图传些显示数据，然后验证相应组件包含了新值。

关于模式的讨论现在已经足够了。虽然这些MVP模式能够分解问题，让单元测试和TDD更容易。不过需要注意（对于在遗留代码上工作的Swing开发人员，这是个好消息），就算没有按照模型、视图和控制器分割，Swing代码一样可以单元测试。

要做这种测试，必须能够访问Swing应用的控件树。有些开源的软件能够帮到我们，下面对这些工具做个介绍。

8.3　测试视图控件的工具

在上一节中，我们说要让Swing应用的视图层尽可能薄，理想情况下只用测试界面捕捉了用户输入，控件能正确显示内容。不过究竟要怎么做这种测试呢？这时就需要工具帮忙了。

8.3.1　为什么要用工具

为什么需要工具呢？为了回答这个问题，我们先回到刚才那个视图接口，如下所示：

public interface GreenBarView {

public void addRunButtonListener(RunButtonListener listener);

public void setBarColor(Color color);

}

假设我们打算用javax.swing.JPanel实现视图。我们该做些什么呢？

UI上应该有两个控件：执行测试的按钮，还有一个当绿条用的带色的框。视图单元测试最初的代码如代码清单8-3所示。

代码清单8-3　对视图功能实现的测试

public class TestGreenBarViewImpl {

private GreenBarView view;

private boolean listenerReceivedClick;      // ❶查验收到的事件

@Before

public void setUp() {

view = new GreenBarViewImpl();

view.addRunButtonListener(new RunButtonListener() {     /*❶（以下5行）查验收到的事件*/

public void onRunButtonClick() {

listenerReceivedClick = true;

}

});

}

@Test

public void viewShouldDisplayTheBarInTheGivenColor()

throws Exception {

view.setBarColor(Color.GREEN);      // ❷模仿presenter

// How to verify that the bar really is green?

}

@Test

public void viewShouldDelegateGesturesToListener()

throws Exception {

// How to simulate a click on the "Run" button?

// How to know whether the button is there at all?

assertTrue(listenerReceivedClick);     // ❶查验收到的事件

}

}

从代码清单8-3中可以看出，虽然两处功能都测到了，但是测得不够完整。注册事件监听逻辑很容易❶，不过却没法模拟用户输入，甚至连按钮是否存在都不知道。让presenter告诉视图做显示很容易❷，但却没法验证视图显示的是否正确。

想想这个问题。实际上，我们需要访问Run按钮来触发点击事件；还需要访问显示条读取颜色。虽然可以把这些暴露成公有属性或方法直接读取，不过我们并不想这么做。除了在封装等方面的考虑外，仅仅为了测试而暴露如此多的实现细节得不偿失。

还好，我们有Swing控件树和强大的Java反射API。只要有对top-level容器（例如JFrame）的引用，就可以用反射API递归地搜索子容器，直到找到目标控件为止。

听起来挺麻烦，所以有人专门为此制作了工具。下面我们来讨论这种工具该有的功能，然后会比较两个常用的工具，接着再对其中一种进行详细讲解。

8.3.2　TDD友好的工具

测试驱动Swing控件的工具该有什么功能？首先必须能从控件树中找到特定控件进行操作。至于查找方法，则可以有多种形式。

大部分时间我们可以用控件的名字或ID来找到这个控件；而有时候，可能用控件在控件树的相对位置进行查找。有时候可能要找到拥有某个标题的弹出窗口上唯一的按钮，而有时候需要根据控件的类型（class）查找。工具支持的方法当然越多越好。

那么在操控控件方面要有什么功能？我们肯定需要点击按钮或者改变输入框的内容，但还有别的需求。有时候，除了简单的改变内容外还需要其他功能，例如一个字一个字的进行输入，验证输入框行为正确^1。我们也可能需要模拟持续时间，如果用户在某个控件上持续按某键，那么控件可能会加速运行。还有鼠标，需要模拟的动作更多。

1 例如find as type。——译者注

很显然一个完整的Swing测试自动化库必须拥有很多功能。目前，有两个库正好满足我们的需要。这两个库为Abbot和Jemmy，它们都是开源的且已被广泛应用。下面我们先介绍第2个工具，这里并不打算做详细介绍，目的是大致了解API的用法。

1. Jemmy

Jemmy（http://jemmy.netbeans.org/）由NetBeans团队开发，原本是为了内部使用，不过后来开放了源代码，让整个Swing开发社区受益。Jemmy提供的API能够访问到Java Swing内部。实现原理是挂接到Swing事件队列上，监听事件，通过事件确定单个控件的位置。只要拿到了控件的引用，就可以用Jemmy的API模拟键盘输入和鼠标动作或者通过控件查询前面的单元测试中输入的数据。

代码清单8-4的代码演示了如何用Jemmy实现代码清单8-3中的测试。

代码清单8-4　用Jemmy实现对视图功能的测试

import org.netbeans.jemmy.operators.JButtonOperator;

import org.netbeans.jemmy.operators.JComponentOperator;

import org.netbeans.jemmy.operators.JFrameOperator;

public class TestGreenBarViewWithJemmy {

private int buttonClicks;

private GreenBarViewImpl view;

private JFrameOperator frameOp;

@Before

public void setUp() {

view = new GreenBarViewImpl();

buttonClicks = 0;

view.addRunButtonListener(new RunButtonListener() {

public void onRunButtonClick() {

buttonClicks++;

}

});

showFrame(view);      // ❶显示JFrame中的组件

}

@After

public void tearDown() {

frameOp.dispose();     // ❷处理frame

}

private void showFrame(Component component) {     /*❶（以下7行）显示JFrame中的组件*/

JFrame frame = new JFrame();

frame.getContentPane().add(component);

frame.pack();

frame.setVisible(true);

frameOp = new JFrameOperator(frame);

}

@Test

public void viewShouldDisplayTheBarInTheGivenColor()

throws Exception {

Color desiredColor = Color.GREEN;

view.setBarColor(desiredColor);

JComponentOperator barOp = new JComponentOperator(     /*❸（以下2行）使用operator和chooser定位组件*/

frameOp, new NameBasedChooser("bar"));

assertEquals(desiredColor, barOp.getBackground());

assertEquals(desiredColor, barOp.getForeground());

}

@Test

public void viewShouldDelegateGesturesToListener()

throws Exception {

JButtonOperator buttonOp = new JButtonOperator(      /*❸（以下2行）使用operator和chooser定位组件*/

frameOp, new NameBasedChooser("run_button"));

assertEquals(0, this.buttonClicks);

buttonOp.doClick();       // 用operator处理用户输入

assertEquals(1, this.buttonClicks);

}

}

在代码清单8-4中，测试类的setup方法创建了待测的视图控件❶，将其放到JFrame中显示出来。因为setup中创建了这些JFrames，所以需要在teardown方法中将其销毁❷。这种共同的底层基础代码是每个测试中都需要的，所以在写第2个Jemmy测试时要将这段代码提取到公共基类中。

那么，该如何找到想操控的控件呢？Jemmy中的component operator和component chooser可以帮忙。参照代码清单8-4，在两个测试方法中我们都创建了operator对象，传入JFrameOperator和NameBasedChooser作为参数❸。这段代码通知JButtonOperator的构造函数在给定容器（JFrame）中搜索控件，然后返回结果。在这里使用了NameBasedChooser，它会以Component# getName返回的控件名作为搜索条件。

ComponentChooser是API的关键部分之一，在代码清单8-4中，我们使用了NameBasedChooser。NameBasedChooser并不是Jemmy提供的，而是我们自己对接口做的实现，见代码清单8-5。

代码清单8-5　Jemmy实现的基于名字的ComponentChooser

import org.netbeans.jemmy.ComponentChooser;

public class NameBasedChooser implements ComponentChooser {

private final String name;

public NameBasedChooser(String name) {

this.name = name;

}

public boolean checkComponent(Component c) {

return name.equals(c.getName());     // 用名字区分需要的组件

}

public String getDescription() {

return "Component named '" + name + "'";

}

}

从代码清单8-5可以看出，ComponentChooser代码不多。ComponentChooser 定义了checkComponent方法，在这个方法中会检查候选控件的名称，判断是否是我们要找的。ComponentChooser接口还有另外一个方法getDescription，这个方法只是为了提供额外的信息——默认情况下Jemmy会把getDescription方法返回的信息输出到控制台上。如果在指定时间内Jemmy没有找到目标控件，则会把此信息作为错误报告。

像代码清单8-5中的基本控件查找功能应该是由Jemmy提供。我们通常用到的不同的ComponentChooser也不难实现。Jemmy虽然不错，但也可以看看其他工具。

Jemmy我已经用了挺长时间了，已经很习惯了——它很容易使用，而且一旦有了基础设施，工作起来基本不会遇到什么问题（其他工具可不一定有这么好用）。即便如此，看看其他选择也是可以的，例如下面我们要介绍的Abbot。

2. Abbot

Abbot（http://abbot.sourceforge.net）不是一个新工具了。不过它的开发工作最近又活跃了起来（比如新增了对Eclipse Standard Widget Toolkit [SWT]的支持）。Abbot和Jemmy有些区别，Jemmy会监听事件队列，而Abbot利用的是底层操作系统事件。这样Abbot会更接近真东西，虽然从我的使用经验上来看区别不明显。Jemmy和Abbot更重要的区别在于，Abbot为单元测试提供了更好的支持。在代码清单8-6中我们重写了代码清单8-4的测试，一定程度上可以看出区别。

代码清单8-6　用Abbot实现的视图测试

import junit.extensions.abbot.ComponentTestFixture;

import abbot.finder.matchers.NameMatcher;

import abbot.tester.ComponentTester;

public class TestGreenBarViewWithAbbot extends

ComponentTestFixture {

private GreenBarViewImpl view;

private int buttonClicks;

@Override

protected void setUp() throws Exception {

super.setUp();

view = new GreenBarViewImpl();

buttonClicks = 0;

view.addRunButtonListener(new RunButtonListener() {

public void onRunButtonClick() {

buttonClicks++;

}

});

showFrame(view);     // ❶让Abbot把待测控件放到frame上

}

public void testViewShouldDisplayTheBarInTheGivenColor()

throws Exception {

Color desiredColor = Color.GREEN;

view.setBarColor(desiredColor);

Component bar = componentNamed("bar");     // ❷按名称定位组件

assertEquals(desiredColor, bar.getBackground());

assertEquals(desiredColor, bar.getForeground());

}

public void testViewShouldDelegateGesturesToListener()

throws Exception {

Component button = componentNamed("run_button");    // ❷按名称定位组件

assertEquals(0, buttonClicks);

new ComponentTester().actionClick(button);     // ❸用ComponentTester仿真用户动作

assertEquals(1, buttonClicks);

}

private Component componentNamed(String name)     /*❷（以下4行）按名称定位组件*/

throws Exception {

return getFinder().find(view, new NameMatcher(name));

}

}

在代码清单8-6中，我们可以了解到Abbot的主要功能。开始使用的时候，先在setup方法中创建和配置视图控件，让Abbot把待测控件放到JFrame上❶，为下面的测试做好准备。在接下来的两个测试中，我们可以看到Abbot用查找器查找控件❷。在两个测试中我们都用了名称matcher来查找Run按钮和绿条。最后在testViewShouldDelegateGesturesToListener中，我们用Abbot提供的ComponentTester模拟点击Run按钮❸。

你可能注意到了，Jemmy只提供了访问Swing控件的方法，而Abbot还提供了一些其他东西，例如供其他测试类继承的基类，这样能消除一些底层基础代码。

在这些基类中，ComponentTestFixture是最好用的，代码清单8-6的代码也用了它。除了提供了控件查找器和showFrame方法外，ComponentTestFixture还会捕捉事件分发线程抛出的异常，然后重新抛出异常让测试失败——如果让你自己写这些代码可就不好了。

总的来说，Abbot功能更强，而且开发工作还在继续。但要在这两种工具间进行选择，还是要看个人喜好。

上面我们提到了利用Passive View或Supervising Controller模式，把视图做得越薄越好。我们也看了两个测试视图用的开源工具。有了这些工具，就可以直接操控控件了。不过，我们还没有写多少视图部分的代码。下面我们来从头测试驱动一个视图，把前面讲到的技巧全用上，包括开源工具Abbot和Jemmy。

8.4　测试驱动视图组件

在本章的结束部分，我们会测试驱动出一个视图控件。确切一点是什么类型的视图控件呢？这个问题问的不错，测试驱动出一个plot map视图控件。什么是plot map？见图8-4。

图8-4　plot map控件的UI草图

我们来逐个讲解UI上的控件。首先，有一个画布控件，上面可以画一系列的点，点之间用线连接。UI的左下角有两个输入框，用来输入点的X和Y坐标。点击ADD按钮就能添加新点了。除此之外，在画布上点击某个点，此点会被移除。

下面就用TDD来开发这个界面了。首先我们会搭出MVP的基本结构，然后添加控件。先为视图添加文本框和按钮，接着添加行为。然后再添加绘图功能，让控件正确响应用户输入。

下面来分析内部实现。

8.4.1　着手设计

现在我们对要用的控件及各个控件的功能有了大致的了解。现在要着手实现了。

从技术细节分析，我们的控件应该会连到一个模型对象上。这个模型对象中有一系列的点，用X和Y坐标表示。模型的名字叫PlotMapModel，先假设已经实现过了。

我们的视图应该实现PlotMapView接口，如下所示：

public interface PlotMapView {

void registerAdditionListener(PlotAdditionListener listener);

void registerRemovalListener(PlotRemovalListener listener);

void drawPlotMap(PlotMapModel model);

}

从接口上可知，视图应该把两类用户输入转给监听器（registered listeners），即presenter。这两种输入为添加点和移除点。Presenter会用drawPlotMap方法通知视图做更新。

两个监听器接口也很直观，如下所示：

import java.awt.Point;

public interface PlotAdditionListener {

void plotWasAdded(Point plot);

}

public interface PlotRemovalListener {

void plotWasRemoved(Point plot);

}

当添加或移除事件到达presenter后，presenter会对PlotMapModel做相应的修改，然后调用PlotMapView的drawPlotMap来同步视图。

代码清单8-7中为PlotMapModel的实现。

代码清单8-7　简单的模型类

import java.util.*;

import java.awt.Point;

public class PlotMapModel {

private List<Point> plots = new ArrayList<Point>();

public void add(Point plot) {

plots.add(plot);

}

public void remove(Point plot) {

plots.remove(plot);

}

public List<Point> points() {

return plots;

}

}

在代码清单8-7中，add和remove方法由presenter调用，points方法是视图绘图的数据源。

现在我们清楚该有什么了，主要有以下3个功能。

- 添加点——_加一对输入框和一个按钮，当点击按钮时会触发期望的事件。

- 绘制点——_视图能在画布上画出给定的点。

- 移除点——_假设画布上已经绘有一些点，点击某个点时，点会被移除。

那我们先从列表中第一条开始，在画布上添加点吧。

8.4.2　添加及操作标准控件

我们先不管那些严格的UI开发规则，先加那两个输入框和Add按钮吧。

在代码清单8-8中，我们用Abbot搭建了测试框架。

代码清单8-8　视图功能测试的基本框架

import junit.extensions.abbot.ComponentTestFixture;

import abbot.finder.matchers.NameMatcher;

import abbot.tester.ComponentTester;

import java.awt.Point;

import java.awt.Component;

public class TestPlotMapViewImpl extends ComponentTestFixture

implements PlotAdditionListener {     // 测试类充当监听器

private Point addedPoint;     /*（以下4行）将添加的点留待测试确认*/

public void plotWasAdded(Point plot) {

addedPoint = plot;

}

public void setUp() throws Exception {

super.setUp();

addedPoint = null;

PlotMapViewImpl view = new PlotMapViewImpl();

view.registerAdditionListener(this);

showFrame(view);

}

}

这个测试类和刚才讨论Abbot时见到的那个差不多，最大的区别在于这次用了SelfShunt模式，让测试类本身充当测试替身，即PlotAdditionListener。代码中有一点需要注意：只要测试方法开始执行，那么一定会有一个PlotMapViewImpl在屏幕上供测试用。

下面接着写测试。我们为注册的监听器加了个测试，见代码清单8-9。如果我们往输入框里面填入数字，点击按钮，那么监听器应该会收到添加时间，也能够获取坐标点。

代码清单8-9　输入坐标及点击Add按钮的测试

public class TestPlotMapViewImpl extends ComponentTestFixture

implements PlotAdditionListener {

...

private ComponentTester tester = new ComponentTester();

public void testAdditionEventGetsTriggered() throws Exception {

Point point = new Point(3, 5);

typeIntoTextField("x_coord_textfield", "" + point.x);

typeIntoTextField("y_coord_textfield", "" + point.y);

tester.actionClick(namedComponent("add_button"));

assertEquals(point, addedPoint);

}

private void typeIntoTextField(String name, String value)

throws Exception {

tester.actionKeyString(namedComponent(name), value);

}

private Component namedComponent(String name) throws Exception {

return getFinder().find(new NameMatcher(name));

}

}

先给PlotMapViewImpl做初步的实现。这时候测试还是会失败，因为Abbot找不到名为x_coord_textfield的控件。把这些控件一个个加上去，实现代码会如代码清单8-10所示。

代码清单8-10　添加各个必要的控件后视图的实现

import javax.swing.*;

public class PlotMapViewImpl extends JPanel implements

PlotMapView {

public PlotMapViewImpl() {

add(createTextField("x_coord_textfield"));

add(createTextField("y_coord_textfield"));

JButton addButton = new JButton();

addButton.setName("add_button");

add(addButton);

}

private JTextField createTextField(String name) {

JTextField field = new JTextField();

field.setName(name);

return field;

}

public void registerAdditionListener(PlotAdditionListener

listener) {

}

public void registerRemovalListener(PlotRemovalListener

listener) {

}

public void drawPlotMap(PlotMapModel model) {

}

}

现在PlotMapViewImpl会创建3个控件，然后将它们加到布局中。这对当前测试来说足够了。现在测试之所以会失败，是因为点击按钮后什么事情也没有发生——这其实是正确的行为，因为控件背后还没有挂接任何事件。现在就来修复测试吧。

代码清单8-11中的视图实现能够让测试通过。

代码清单8-11　能通过我们第一个测试的视图实现

import javax.swing.*;

import java.awt.event.*;

import java.awt.Point;

public class PlotMapViewImpl extends JPanel implements PlotMapView {

private PlotAdditionListener additionListener;     // 视图用监听器处理添加的事件

private JTextField xCoordField, yCoordField;

private JButton addButton;

public PlotMapViewImpl() {

createWidgets();

add(xCoordField);

add(yCoordField);

add(addButton);

}

private JButton createWidgets() {

xCoordField = createTextField("x_coord_textfield");

yCoordField = createTextField("y_coord_textfield");

addButton = new JButton();

addButton.setName("add_button");

addButton.addActionListener(new ActionListener() {

public void actionPerformed(ActionEvent e) {

int x = valueAsInt(xCoordField);

int y = valueAsInt(yCoordField);

additionListener.plotWasAdded(new Point(x, y));    // 点击按钮，让注册的监听器处理触发事件

}

});

return addButton;

}

private int valueAsInt(JTextField field) {

return Integer.parseInt(field.getText());

}

private JTextField createTextField(String name) {

JTextField field = new JTextField();

field.setName(name);

return field;

}

public void registerAdditionListener(PlotAdditionListener

listener) {

this.additionListener = listener;     // 用于处理添加的事件的注册监听器

}

public void registerRemovalListener(PlotRemovalListener

listener) {

}

public void drawPlotMap(PlotMapModel model) {

}

}

在代码清单8-11中，视图类的构造函数做了两件事情：创建出子控件，将其加到屏幕中，这倒没什么特别的。更值得注意的是通过在按钮的ActionListener中提取文本字段的数据，让Add按钮来触发在PlotAdditionListener上注册的事件。需要注意的是代码清单8-11的代码假设只会有一个监听器存在（事实也确实是这样）。

在为Add按钮实现ActionListener时，我发现用户也许会给坐标输入框填入非数字的值。这种行为应当避免，我先将其加到任务列表里吧。我想或许可以让Add按钮只在两个坐标值都有效时才能点击。点击之后，两个坐标输入框的值应该清空。这个任务就交给你了，我们现在来做另一件事：绘图。

8.4.3　绘图

从控件库里东拖西拽地画出个界面和从头造一个控件完全不是一回事。造控件意味着要自己绘制控件在屏幕上显示的效果，这正是我们接下来要做的事情：在画布上绘出plot map。

我们先抬起头来看看。现在PlotMapViewImpl已经有了一些输入框和一个能触发监听器添加事件的按钮。不过视图类在绘制PlotMapModel时还什么都没做。整个控件完成后，我们应该能够看到一个画着点，点之间有直线连接的画布。绘图一直都不是我的强项，如果想一步做完基本不可能，所以还是分治（divide and conquer）吧。

1. 分解问题

我现在在想，或许我们应该把绘图的责任都划分到一个单独的画布类里去，只要PlotMapViewImpl把绘图的请求转发给画布对象就可以了。也就是说不把画布相关的细节和其他控件混在一起，而是让PlotMapViewImpl包含一个PlotMapCanvas接口的引用，这个接口目前可以清除掉。这样把画布封装成一段独立的代码让测试也更容易了。

画布这部分还没动手呢，现在来认真实现视图和画布间的交互吧。代码清单8-12中的测试描述了视图和画布控件应该有的交互。

代码清单8-12　测试视图与画布控件的交互

public class TestPlotMapViewImpl extends ComponentTestFixture

implements PlotAdditionListener {

...

private PlotMapCanvasStub canvas;     // ❶对画布使用测试替身

public void setUp() throws Exception {

...

canvas = new PlotMapCanvasStub();     // ❶对画布使用测试替身

view = new PlotMapViewImpl() {

@Override        /*❷（以下4行）视图用此方法创建画布*/

protected PlotMapCanvas createCanvas() {

return canvas;

}

};

}

public void

testViewPassesModelToSeparateCanvasObjectForDrawing()

throws Exception {

Point p1 = new Point(1, 3);

Point p2 = new Point(2, 1);

Point p3 = new Point(5, 4);

PlotMapModel model = new PlotMapModel();

model.add(p1);

model.add(p2);

model.add(p3);

view.drawPlotMap(model);

assertEquals(Arrays.asList(p1, p2, p3),

canvas.plottedPoints);

}

}

在代码清单8-12中，我们用测试替身PlotMapCanvasStub来捕捉交互❶。通过覆盖protected的工厂方法，测试时就能够用测试替身而非真实对象了❷。Fake画布要能记录下来需要画的数据（见代码清单8-13），这样，在测试中就可以拿记录下的数据和期望的数据做对比了。

代码清单8-13　PlotMapCanvas 的测试替身

public class PlotMapCanvasStub implements PlotMapCanvas {

public List<Point> plottedPoints = new ArrayList<Point>();

public void plot(Point point) {

plottedPoints.add(point);

}

}

现在，测试直指缺失的功能——把绘图的任务交给视图的画布控件去做。先挑简单的做，代码清单8-14的代码正好让测试通过。

代码清单8-14　让视图把模型传给独立的画布对象

public class PlotMapViewImpl extends JPanel implements PlotMapView {

...

protected PlotMapCanvas createCanvas() {    /*（以下3行）需要做代码编译*/

return null;

}

public void drawPlotMap(PlotMapModel model) {

for (Point point : model.points()) {     /*（以下3行）要求画布画出模型的点*/

createCanvas().plot(point);

}

}

}

敏锐的读者们可能已经发现了代码清单8-14中的一些有趣的东西。首先，createCanvas方法返回了null——目前这样足够了。在产品代码中一定是要创建画布对象的，但是现在还不用。

其次，每画一个点createCanvas都会被调用一次。这样显然是不对的（测试之所以能通过，是因为createCanvas方法每次都返回同一个对象），我的目的是让测试显式地说明所有的元素都该画在同一个画布上。

代码清单8-15添加了一个测试指出了这一点。

代码清单8-15　显式指定一个视图只应创建一个画布

public void testViewCreatesItsCanvasJustOnce() throws Exception {

final MutableInt canvasesCreated = new MutableInt ();    // ❶计数加

view = new PlotMapViewImpl() {

@Override

protected PlotMapCanvas createCanvas() {

canvasesCreated.increment();    // ❶计数加

return new PlotMapCanvasStub();

}

};

PlotMapModel model = new PlotMapModel();

model.add(new Point(1, 1));

view.drawPlotMap(model);

view.drawPlotMap(model);

assertEquals(1, canvasesCreated.intValue());    // ❷只会创建一个画布

}

每次调用createCanvas时都增加计数器对象的计数❶，然后再调用几次视图的drawPlotMap方法，我们可以验证视图只会创建一个画布❷。

在实现画布前，还有什么要做的？至少那个createCanvas方法不能继续返回null了，应该返回真实的对象。另外在drawPlotMap方法中也有要改的，现在每次画新图时没有擦除旧图！我们应该给PlotMapCanvas加个clear方法，PlotMapViewImpl在重画模型时应该先调用它。这些工作我们就不一起做了，你自己去搞定吧，然后我们一起实现画布对象！

2. 测试尺寸

通常，做一件事情可以有很多入手点。开发一个有绘图功能的控件PlotMapViewImpl，或许可以从画布尺寸入手。首先，我们先给画布固定尺寸。代码清单8-16中的代码把画布尺寸定为200×100像素大小。

代码清单8-16　测试画布尺寸

public class TestPlotMapCanvasImpl {

@Test

public void testDimensionsShouldBeAutomaticallySet()

throws Exception {

Component canvas = new PlotMapCanvasImpl();

assertEquals(new Dimension(200, 100), canvas.getSize());

}

}

简单干净，实现代码也一样，见代码清单8-17。

代码清单8-17　第一个典型的画布类

public class PlotMapCanvasImpl

extends Component implements PlotMapCanvas {

public PlotMapCanvasImpl() {

setSize(200, 100);

}

public void plot(Point point) {

}

}

代码清单8-17中的代码也没什么特别的。在构造函数中，我们为控件设置了尺寸，另外还提供了接口方法的默认实现。在测试中我们通过java.awt.Component来引用画布组件，所以现在就直接从这个类继承了。

测试通过了，但不应该通过，为什么？因为设置了Component的尺寸并不代表控件显示出来就一定是这个大小。这就是Swing知识派上用场的时候了，如果没有这些知识，那么只有在运行程序时才能用肉眼发现这个问题。对于控件的真实尺寸，我们可以通过对比控件的尺寸和边界来发现问题（见代码清单8-18）。这个测试说明视图尺寸根本不是固定的。

代码清单8-18　对比尺寸和边界

public class TestPlotMapCanvasImpl extends ComponentTestFixture {    // 使用Abbot的基类渲染控件

@Test

public void testDimensionsShouldBeAutomaticallySet()

throws Exception {

Component canvas = new PlotMapCanvasImpl();

showFrame(canvas);     // 使用Abbot的基类渲染控件

assertEquals(new Dimension(200, 100), canvas.getSize());

assertEquals(canvas.getSize(),

canvas.getBounds().getSize());

}

}

要解决这个问题，最简单的办法就是换掉java.awt.Component，改从java.awt.Canvas继承。这个改动太小了，新代码就不列在这里了。

下步该做什么功能了？画布的背景色应该是最容易确定的了，我喜欢白色的。

3. 确定颜色

我们期望控件能有个整洁的白色背景。这应该不难，对吧？代码清单8-19中的测试说明这确实不难。

代码清单8-19　为控件背景色添加另一个测试

public class TestPlotMapCanvasImpl extends ComponentTestFixture {

private Component canvas;

@Override

protected void setUp() throws Exception {

super.setUp();

canvas = new PlotMapCanvasImpl();

}

@Test

public void testDimensionsShouldBeAutomaticallySet()

throws Exception {

showFrame(canvas);

assertEquals(new Dimension(200, 100), canvas.getSize());

assertEquals(canvas.getSize(),

canvas.getBounds().getSize());

}

@Test

public void testBackgroundColorIsWhite() throws Exception {

assertEquals(Color.WHITE, canvas.getBackground());

}

}

从代码中可以看到一处重构，我们为画布控件添加了一个field，会在setup方法中给其赋值。让测试通过依旧很容易，见代码清单8-20。

代码清单8-20　在控件的构造函数中设置背景

public class PlotMapCanvasImpl

extends Canvas implements PlotMapCanvas {

...

public PlotMapCanvasImpl() {

setSize(200, 100);

setBackground(Color.WHITE);

}

...

}

比起这个简单的实现，我们更感兴趣的是如何验证plots被正确地画在画布上了，因此要测试真实的图形输出。

4. 测试图形输出

测试图形输出有些麻烦。虽然有许多种方法，但并不是每种都是好方法。我们可以给控件传入一个java.awt.Graphics测试替身，然后验证交互正确。这样可以，但是却暴露了实现细节。这虽然是单元测试，测试和实现有紧耦合还算正常，但是针对这种非海量图像数据，只需要检测几个像素的情形，还有更好的办法。

刚才提到的办法是：把控件渲染到缓存上，从缓存上提取出一幅位图raster，然后在此基础上进行测试。听起来很麻烦，不过确实可行。在考虑像素级的测试前，先想办法把控件渲染到缓存上吧。

要做到这点有多种办法，代码清单8-21是我所常用的。

代码清单8-21　从Swing控件中提取位图的工具类

import java.awt.Color;

import java.awt.Component;

import java.awt.Graphics2D;

import java.awt.image.BufferedImage;

import java.awt.image.Raster;

public class Bitmap {

public static Raster of(Component c) throws Exception {

BufferedImage image = new BufferedImage(c.getWidth(),

c.getHeight(), BufferedImage.TYPE_INT_RGB);

Graphics2D graphics = image.createGraphics();

graphics.setColor(Color.WHITE);

graphics.fillRect(0, 0, c.getWidth(), c.getHeight());

c.paint(graphics);     // ❶让控件在缓冲上绘制自己

graphics.dispose();

return image.getRaster();     // ❷从缓冲提取raster

}

}

解决问题的关键在于创建一个内存缓冲（java.awt.image.BufferedImage）❶，控件可以在此缓冲上绘制自己。绘制完后，我们就可以从图片中提取出位图raster进行测试了❷。

现在，费不了多少功夫我们就能写出测试验证所画点在正确的坐标上了。代码清单8-22中正是这样一个测试——这个测试验证了我们画点的那个像素周围像素的颜色。

代码清单8-22　验证画布绘图内容的测试

public class TestPlotMapCanvasImpl extends ComponentTestFixture {

...

@Test

public void testPlotIsDrawnOnScreen() throws Exception {

canvas.plot(new Point(2, 2));

Raster raster = Bitmap.of(canvas);     // ❶捕捉raster

Pixel.in(raster).at(2, 2).shouldBe(Color.BLACK);     /*❷（以下2行）验证raster中某个像素的颜色*/

Pixel.in(raster).around(2, 2).shouldBe(Color.WHITE);

}

}

在代码清单8-22中，我们用了很多工具类让测试变得很紧凑。首先，我们用代码清单8-21中见过的Bitmap类捕捉了控件的位图❷，然后转化成raster对象。然后用了另外一个未介绍过的工具类来验证raster中某个像素的颜色❶。在坐标[2,2]处画点后，我们期望除此点外，其他像素都是白色。

有了这个测试，我们终于可以动手实现PlotMapCanvasImpl的paint方法了，见代码清单8-23。

代码清单8-23　绘图的粗略实现

public class PlotMapCanvasImpl

extends Canvas implements PlotMapCanvas {

private List<Point> plots = new ArrayList<Point>();

...

@Override

public void paint(Graphics g) {

g.setColor(Color.BLACK);     // ❶用黑色线

for (Point p : plots) {      /*❷（以下3行）给每个点的像素着色*/

g.drawLine(p.x, p.y, p.x, p.y);

}

}

}

测试通过了，点也画在屏幕上了。我们画了一条黑色的线，线的起点和终点都是同一个点❶。之所以要画线而非点，因为Graphics貌似没有直接给像素着色的功能。另外，如果你对Swing的API还不熟，注意要把颜色设为黑色，这样Graphics才知道drawLine方法该用什么颜色绘图❷。

现在我们还需要画线——连接画布中各个点，开始干吧。

5. 测试模式

已经做到哪了？我们已经在屏幕上画出点了，下一步需要用线连接这些点。那么，我们该如何检测画布对象画出的点之间的连线呢？

我们当然可以和代码清单8-22中检测那2个点一样，检查142个点的颜色，验证两个坐标点间的直线上每个像素都是黑色。这个测试的缺点在于，测试方法会过长。我们当然不期望测试方法有几百行，测试应当简单些。我至少能想到两种方法比检查直线所有的142个像素要简单：

- 把线变短

- 写算法找出连线

如果能设计出足够简单的情形——例如一条非常短的线——那么我们就可以在测试中直接检测特定像素了。如果要设计出一个算法找出两点间的连线，那么我们实际是在花钱和时间实现一项功能：给定起始点和结束点，判断两点间连线上的像素是否被着色了。因为算法可能会变得很冗长，而要给出一个简单的测试用例又相对容易，所以我们还是考虑上面第一种方法吧。代码清单8-24中的测试验证了画布会在两点间连一条直线。

代码清单8-24　测试点之间有连线

public class TestPlotMapCanvasImpl extends ComponentTestFixture {

...

@Test

public void testPlotsShouldBeConnected() throws Exception {

canvas.plot(new Point(2, 9));

canvas.plot(new Point(5, 6));

Raster raster = Bitmap.of(canvas);

Pixel.in(raster).at(2, 9).shouldBe(Color.BLACK);     /*（以下4行）人工检验计算的坐标*/

Pixel.in(raster).at(3, 8).shouldBe(Color.BLACK);

Pixel.in(raster).at(4, 7).shouldBe(Color.BLACK);

Pixel.in(raster).at(5, 6).shouldBe(Color.BLACK);

}

}

在代码清单8-24中，我们还是使用了Pixel工具检查单个像素的颜色。在这里，我们硬编码了点[2,9]和[5,6]之间的像素，为[3,8]和[4,7]。测试正确地失败了，抱怨说中间的像素不是黑色的，现在我们就可以连线了，见代码清单8-25。

代码清单8-25　画点之间的连线

public class PlotMapCanvasImpl

extends Canvas implements PlotMapCanvas {

...

@Override

public void paint(Graphics g) {

g.setColor(Color.BLACK);

Point previous = null;

for (Point current : plots) {

if (previous == null) {     /*❶（以下3行）第一个点前面没有点*/

previous = current;

}

g.drawLine(previous.x, previous.y,     /*❷（以下2行）在前一个点和当前点之间画一条线*/

current.x, current.y);

previous = current;    // ❸为下一个循环做准备

}

}

}

代码清单8-25中的代码用画点的drawLine方法给点之间连线。这次我们在一个循环中给previous点和current点之间依次连线❷。第一个点前没有点，所以previous点和current点是同一个点❶。而余下的各点都有previous点，因为每次画线后都会更新previous点的值❸。

画图部分基本完成了，我们来处理代码清单里的第3个。也是最后一个问题：响应用户操作（即鼠标点击操作），移除画布上的点。

8.4.4　给点添加行为

我们期望画布能够捕获鼠标点击动作，然后把事件通知给容器（即PlotMapViewImpl）。说的详细些，我们期望在鼠标点击坐标点后，画布能够响应点击事件。那么该怎么做？

首先需要某种回调机制，这样视图就可以为鼠标点击事件注册处理逻辑了。为此，我们创建了PointEventListener接口：

public interface PointEventListener {

void onPointEvent(Point point);

}

视图应该能为PlotMapCanvas注册PointEventListener。为此我们可以为PlotMapCanvas接口添加addRemoveListener方法，如下所示：

public interface PlotMapCanvas {

void plot(Point point);

void addRemoveListener(PointEventListener listener);

}

必需的接口都有了，下面来写个测试吧。

1. 模拟及响应鼠标点击

我们期望控件能响应鼠标点击。为了验证功能的正确性，需要模拟鼠标点击。幸好画图类所继承的java.awt.Component类带有dispatchEvent方法。这个方法能模拟许多鼠标事件，包括我们需要的左击。

代码清单8-26为下一个测试。测试中模拟了鼠标点击，注册了一个测试替身来监听移除事件。

代码清单8-26　测试用户操作的处理

public class TestPlotMapCanvasImpl extends ComponentTestFixture {

private PlotMapCanvasImpl canvas;

...

@Test

public void

testPlotsShouldReactToClicksByTriggeringRemoveEvents()

throws Exception {

final List<Point> removedPoints = new ArrayList<Point>();

canvas.addRemoveListener(new PointEventListener() {    /*❶（以下5行）注册移除监听器*/

public void onPointEvent(Point point) {

removedPoints.add(point);

}

});

Point point = new Point(5, 20);

canvas.plot(point);

canvas.dispatchEvent(new MouseEvent(canvas,     /*❷（以下5行）模拟鼠标点击动作*/

MouseEvent.MOUSE_CLICKED,

System.currentTimeMillis(),

MouseEvent.BUTTON1_DOWN_MASK,

point.x, point.y, 1, false));

assertTrue(removedPoints.contains(point));

}

}

代码清单8-26中的测试会模拟鼠标点击画布上的一个点，验证监听器能收到正确的事件。测试中首先为PointEventListener接口创建了一个匿名实现❶，这个实现会把收到的Point参数记录在List中。注册完伪监听器后，测试用dispatchEvent方法模拟了一个鼠标点击动作❷。

测试失败了，这在意料之中。我们需要为画布的addRemoveListener添加实现。代码清单8-27中为PlotMapCanvasImpl的实现。

代码清单8-27　画布把鼠标点击事件传给了监听器

public class PlotMapCanvasImpl

extends Canvas implements PlotMapCanvas {

...

public void addRemoveListener(final PointEventListener

listener) {

addMouseListener(new MouseAdapter() {

public void mouseClicked(MouseEvent e) {

listener.onPointEvent(e.getPoint());

}

});

}

}

在代码清单8-27中，画布注册了一个匿名MouseAdapter实现，把鼠标点击转化成了对PointEventListener的调用。

因为目前画布会把每个点绘成单个像素，而这个点以后或许会被修改，因此我们可以把MouseEvent中的点作为参数传入。

在写MouseAdapter的实现时还发现了一个问题。目前所有的鼠标点击都能触发移除事件，实际上应该只有点击在点上，才会触发移除事件。

2. 让验证更精确些

代码清单8-28中的代码修正了行为，而且经过重构后，条理也更清晰了。

代码清单8-28　测试只有特定的鼠标点击操作才会移除点

public class TestPlotMapCanvasImpl extends ComponentTestFixture {

...

private List<Point> removedPoints;     // ❶为移除事件注册伪监听器

@Override

protected void setUp() throws Exception {

super.setUp();

removedPoints = new ArrayList<Point>();

canvas = new PlotMapCanvasImpl();

canvas.setRemoveListener(new PointEventListener() {    /*❶（以下5行）为移除事件注册伪监听器*/

public void onPointEvent(Point point) {

removedPoints.add(point);

}

});

}

...

@Test

public void testClickOnPlottedPointShouldTriggerRemoveEvent()

throws Exception {

Point point = new Point(5, 20);

canvas.plot(point);       /*❷（以下3行）点击点触发移除事件*/

simulateMouseClickAt(point.x, point.y);

assertTrue(removedPoints.contains(point));

}

@Test

public void testClickOnNonPlottedPointShouldBeIgnored()

throws Exception {

canvas.plot(new Point(100, 50));     /*❸（以下3行）忽略对未画点的点击*/

simulateMouseClickAt(20, 30);

assertTrue(removedPoints.isEmpty());

}

private void simulateMouseClickAt(int x, int y) {

canvas.dispatchEvent(new MouseEvent(canvas,

MouseEvent.MOUSE_CLICKED,

System.currentTimeMillis(),

MouseEvent.BUTTON1_DOWN_MASK, x, y, 1, false));

}

}

和代码清单8-26中的代码一样，我们给PlotMapCanvasImpl注册了一个假的PointEventListener❶。不过这次不仅要测试“点击点”时会触发移除事件❷，还要测试“点击非点”时，不会引发移除事件❸。

好的，测试失败了，因为还没有把当前点和PlotMapCanvasImpl中保存的已绘制的点作对比，判断点是否被绘制过。代码清单8-29中为我们的实现，还算简洁。

代码清单8-29　将鼠标点击的点与保存的已绘制的点作对比

public class PlotMapCanvasImpl

extends Canvas implements PlotMapCanvas {

private List<Point> plots;

...

public void setRemoveListener(final PointEventListener

listener) {

addMouseListener(new MouseAdapter() {

public void mouseClicked(MouseEvent e) {

Point point = e.getPoint();

if (plots.contains(point)) {     // 只处理画点

listener.onPointEvent(point);

}

}

});

}

}

为避免鼠标“点击非点”时触发移除事件，代码清单8-29并没有改动太多代码。我们检查了MouseEvent中带的Point对象，看是否与已经绘制过的点相符。

我们的画布对象有了画点、连线以及给视图传回移除事件的基本功能。虽然还有许多有趣的东西能做（例如把已绘制的点画成一个小精灵，而不是一个小像素），不过我们不会继续做这个例子了，是因为剩下的工作还够做很多小时，而且我相信你对组织和测试驱动Swing控件已经有不少自己的想法了。

下面对本章作个简短的总结，然后开始第三部分吧。第三部分中会从验收测试驱动开发的新高度介绍TDD！

8.5　小结

在本章中，我们学到了如何测试及测试驱动Swing代码。开始我们讨论了如何测试内部基础代码，工具及控件间的交互和控件自身逻辑。因为渲染和布局总是要通过视觉来验证，而且测试成本很高，所以通常都不测。

为了让需要测试的部分更好测些，我们学习了几个设计模式。MVP模式的两个变体Supervising Controller和Passive View把大部分的逻辑都移出了视图层，放在了更好测的其他部分中。

有了这些模式，我们已经可以测到大部分的逻辑了，不过视图中还会剩下些逻辑。为了完成这部分逻辑的测试，我们了解了测试可视化Swing控件的技术及工具。我们试用了两款工具——Jemmy和Abbot，发现这两个工具都很好用，选择哪个纯属个人喜好。

最后，我们测试驱动出了一个简单的视图控件的可视化部分。这个控件包含标准控件，例如文本框和按钮，也包括自定义的图形。我们看到了如何把由模型变化，或者用户操作引起的控件内的交互行为一步步添加到实现中。我们也看到了如何方便简单地测试自己绘制的控件。秘诀在于把问题分解成小部分，逐个解决。

至此，本书第二部分就结束了。在第三部分中，我们不再关注细节的、面向代码的TDD了，而会转向研究面向产品和整个团队的ATDD（验收测试驱动开发）。

第三部分　基于ATDD构建产品

在掌握了使用测试驱动开发的技术测试各种Java代码之后，第三部分将把注意力转到测试驱动开发的另一个层面——验收测试驱动开发上。第9章除介绍ATDD外还会讲几个用户案例，以及什么是验收测试和如何做验收测试。另外，还将讨论一些相关的工具。第10章讲述运用开源的Fit框架，以方便的列表格式来实现可执行的验收测试。第11章将学习把可执行的验收测试运用到被测试系统的不同策略。

学习完第11章之后，你会掌握测试驱动开发应用的正确方法和各种策略。然而，真正采用这种技术恐怕必须所有团队成员同心同德才行。第12章为此给出了建议和技巧。毕竟，学以致用嘛。

本部分内容

- 第9章　解析验收测试驱动开发

- 第10章　用Fit创建验收测试

- 第11章　执行验收测试的策略

- 第12章　TDD应用

第9章　解析验收测试驱动开发

在航空领域中，如果一个设计没有解决“这东西该如何测试”这个问题，那么连校阅步骤都通不过。

——Glen B. Alleman，Lewis & Fowler公司的策略咨询主管

在前几章中，我们学习了TDD在开发方面的相关知识，了解了TDD的基本过程，也学习了在一系列Java技术上应用TDD时所需的技术。在本章中，我们会将TDD的核心理念带到整个产品开发过程中去。

TDD能帮开发人员产出高质量的代码。这些代码的可维护性好，稳定性更佳。但是，我们的客户对买代码并没有兴趣。他们买软件的原因是想提高生产效率，赚更多的钱，维护及提高运营效率，占领更多的市场份额，等等。这才是我们开发软件的根本原因——满足业务及市场的需要。验收测试驱动开发（Acceptance TDD，ATDD）就是用来帮助开发人员开发出满足业务需求的高质量的软件的（就像TDD帮助提高软件技术方面的质量一样）。

ATDD能够协调软件项目的开发，帮我们开发出客户真正想要的功能，而不只是半成品。在本章中，我们会学到什么是ATDD，为什么要用它，神秘的验收测试究竟是什么，以及如何整合ATDD和TDD。

ATDD和TDD听起来很像，对吧。两者之间有什么差别？当然，ATDD前面多了个字母（A代表Acceptance，验收），但是除了表相，两种技术有什么本质区别？验收测试到底是什么？怎么用测试在功能实现之前验收？在验收测试和ATDD世界的旅程中，我们将会找到答案。

首先介绍用户故事（user story），一种轻量和极其灵活的需求管理方式。在做开发之前，我们必须先了解期望的系统行为，这正是用户故事的作用。然后讨论什么是验收测试以及验收测试的特性。到那时，我们会了解到需求（即用户故事）是什么样子，以及如何用它完成工作。我们也会了解到验收测试及ATDD的整个过程。

ATDD是一种团队行为及过程，这是ATDD的本质特征之一。因此，我们还会讨论和团队相关的话题，例如各个角色，以及谁可以充当这些角色。我们也会讨论这么做的原因，以揭示ATDD带来的一系列好处。最后，我们会讨论ATDD中究竟会测到什么东西，以及可供使用的工具。

下面来介绍用户故事这种易于使用的、用于收集需求的工具吧。

9.1　用户故事介绍

用户故事是一种很简单的记录需求的方式。经典形式的用户故事只是一个短句，记录谁要做什么及其原因。在实际应用中，大部分故事都只记录谁要做什么，以及这么做的动机。故事之所以只有一句话（或者有时只有一两个词向开发人员和客户传达信息)，是因为故事并不是用来记述需求的。用户故事主要用来表示需求，是将来客户和开发人员间一次谈话的“占位符”。

9.1.1　故事的格式

许多人建议用户故事应当遵循固定格式，例如“作为（某个角色），我想（某项功能），因此（所得到的好处）”。不过包括我在内的其他一些人持有不同意见，用户故事只要保留一定程度的细节，能让用户看明白即可。这并不是说这种固定格式不好，实际上它很好，只不过一种格式不能满足所有的需求而已。当然其他人对这种格式也可能有不同的意见。

用户故事的物理形式

可能因为用户故事很简短，许多人都把故事写在索引卡或者粘帖条上，用白板管理这些卡。这种方法不错，因为有利于沟通，而且项目进度一目了然。因此，越来越多的“分布式开发”项目也开始用这样的故事卡在本地管理需求了。毕竟，分发电子格式的白板更方便早期的项目中索引卡的应用，使3C[Card（卡片），Conversation（对话），Confirmation（确认）]这种缩写符号广为应用。

9.1.2　讲故事的力量

一位名叫Hannah Arendt的德国政治学家曾说过“讲故事能传达信息，却不会像下定义一样容易出错”1。这句话恰如其分地说明了用户故事为何能清晰传达信息，且不会涉及过多冗杂的细节。

1 Hannah Arendt，Men in Dark Times（Harvest Books, 1970）。

每个故事中都会包含其他故事

就像计算一个问题有多种方法一样，写一个用户故事也有多种方式。确实，在把一个故事确定为技术故事前，先仔细研究一下这个故事很有必要。写故事时，总是有办法把故事背后的缘由表达出来的。如果你没法确认故事的价值，多试几次吧。

用户故事算是讲故事的一种形式，是传达知识的有效媒介。首先，人们都喜欢听故事。讲故事的人都很会抓住我们的注意力，比结构严谨的文档强多了，这并不是因为是在“听”故事。一个既有主线又有氛围的故事，比一个个没有关联的句子有趣多了。

下面来看几个例子，体会一下用户故事的这些好处。

9.1.3　用户故事的例子

为了更清楚地了解用户故事，下面列出了几种我常写的用户故事：

- 技术支持人员在打电话时会在屏幕上看到客户的历史记录；

- 系统会阻止用户同时开启多个应用程序实例；

- 应用程序用HTTP代理服务器做认证。

这些用户故事能清楚地表述出功能，由此客户可以给故事排优先级，开发人员也可以粗略地估计工作量。这种形式不会让开发人员过早关注于实现，也不会引入过多细节。

第一个技术支持的故事并没有描述界面样子。第二个故事没有提到桌面快捷方式和扫描进程列表等。它们关心客户价值，而不是具体实现方式。第三个故事有些不同。显然这属于技术用户故事，和业务功能没关系。不过，它确实也是有价值的，同时也能清晰地表达需求。此外，虽然很难定量，一些技术故事可能会降低整体成本，由此为客户创造价值。

用户故事就讨论到这里吧。如果想深入了解用户故事在需求收集和计划方面的应用，请参照Mike Cohn的User Stories Applied（Addison-Wesley, 2004）。

我们刚才曾提到，用户故事的格式并不重要，只要能清晰地向各方传达必要的信息（例如谁，什么及为什么）就可以。实际上，正如一种故事格式不能满足所有需要，要做ATDD，也并不是一定要使用用户故事作为需求管理和计划的工具，只不过这种格式和ATDD天然吻合而已。

现在已经知道什么是故事了，下面讨论什么是验收测试。

9.2　验收测试

验收测试是系统行为和功能的规范。它说明了对于给定故事，系统在特定情况下该如何运作以及对输入该有何种输出。验收测试有一些特征，不过在讨论这些特征前，我们先看个例子。

9.2.1　故事的样例测试

我们以下面这个故事做例子，看看验收测试是什么样。故事如图9-1所示。

图9-1　写在故事卡上的用户故事的例子

这项功能是指，如果客户支持系统中接到电话，那么系统应该能获取并显示客户的历史记录。为此我可以列出图9-2中的测试。

图9-2　故事的测试样例，写在图9-1中故事卡的背后

这3个测试列出了需要满足的条件，它能告诉我们，从用户的角度来看系统是否能正常工作。这些测试和系统的具体实现方式毫无关系。

现在，让我们看看验收测试的本质特征是什么，从故事的所有权和作者开始。

9.2.2　验收测试的特征

现在，你大概会推断出验收测试通常很短，形式上也不很严格。验收测试还有很多特征，下面我们就来讨论其中最常见的特征。

- 所有权属于客户。

- 由客户、开发人员和测试人员一起编写。

- 侧重于“什么”，而非“如何”。

- 用问题领域的语言描述。

- 简洁、准确、无歧义。

下面来逐条解释。

1. 所有权属于客户

验收测试应该属于用户，因为验收测试是用来指定用户故事的验收标准的，而客户，即领域专家，最有权制定这个标准。因此客户是最适合写验收测试的人。

让客户写验收测试，还能避免开发人员写测试常带来的问题：开发人员常会侧重于技术实现方面，而非关注于功能本身。毕竟验收测试是功能的规范，而非技术细节的测试（虽然偶尔也是）。

2. 一起编写

虽然验收测试的所有权属于客户，但并不是只有客户才能写。特别是刚接触用户故事和验收测试时，互相帮助尤为重要，这样就不会有人因为缺乏了解而拒绝使用这些工具和技术了，因此每个人都能参与进来。一起编写测试还能鼓励沟通，在客户和开发人员合作写故事的验收测试时，沟通是必不可少的。

有了客户这领域专家，开发人员这技术专家，以及测试人员这既是领域专家又是技术专家的角色，各方面都覆盖到了。当然，有时候客户会单独写验收测试，或许是因为有会议要参加，或是没有足够时间讨论故事和故事的测试。

开发人员和测试人员有时也会在没有客户参与的情况下写验收测试。这时候，我们需要保证将来会把这些测试再拿出来讨论。而且即使backlog里面的故事的测试不够理想，天也不会塌下来的，这些问题早晚能浮现出来并能够解决的。这就是用户故事这种简单格式的优美之处！

好的验收测试的另外一个特征也和客户参与编写有关：测试编写的侧重点及角度。

3. 侧重于“什么”而非“如何”

用户故事之所以能提早及频繁地给客户提供价值，其中一个很重要的原因是其侧重描述客户价值的根源，而非价值的提供方式。用户故事会尽可能地传达需求，即“什么”及“原因”，而对具体实现，即“如何”，几乎只字不提。大部分情况下，客户都不会关心价值的具体提供方式。1

1 其实他们也不应该关心这些。客户之所以喜欢指挥我们，部分是由于我们这个行业的业绩不佳。现在是改变这种印象的时候了，我们要证明给客户看，只要能持续得到对我们工作的反馈，我们是可以交付客户需要的产品的。

下面来用个例子更好地说明“什么”、“为什么”及“如何”之间的差别。图9-3中的验收测试过分关注了细节，忽略了根本问题，即客户的需求。所有3个测试都侧重描述用户界面，强调实现，但这并不是我们想要的。这么做还把“这里究竟在测什么”这种重要信息藏在了技术细节背后。

图9-3　过分关注细节的验收测试

相反，我们需要让测试关注问题本身，把解决方法留到实际开发时间让开发人员们再去讨论。图9-4重写了图9-3中的测试，重写后保留了有用的信息，删去了无用的细节。

图9-4　图9-3中测试的缩减版

注意，读完这3行，开发人员一样可以了解与图9-3中那冗长的面向解决方案的测试所提供的相同信息。这两种方式，哪种更容易理解？文字的多少及重点所在很能影响测试这种工具的效率，这点很重要。

在文字方面，不仅仅该关注数量及表达重点，也需要注意所使用的语言。

4. 使用领域语言

验收测试的重要特点之一在于其使用客户的领域语言，而非只有编程人员才懂得的“火星语”。要让客户参与编写验收测试，这点至关重要。这点同样也有利于判断测试是否正确和充分。在测试中使用太多技术语言，会让需求bug溜入产品中，因为客户看不懂那些“火星语”，而开发人员又常沉迷在技术中，忽视了真正的问题。

如果在测试中使用领域语言，我们也不会被无谓地绑定在实现上，这点很关键，因为我们需要能有效地重构系统。用了领域语言，重构时对现有测试的修改也会很容易，有时甚至什么都不用改。

5. 简洁、准确且无歧义

与用领域语言写验收测试的原因一样，我们期望测试简洁和准确。我们编写的每一个验收测试都是为了验证用户故事的某个方面或某个情形的。测试应当很整齐，容易理解，方便转成可执行的测试。含义越清晰，越能避免发生错误。

我们可以把故事写成列表那样的简单的备忘录形式，也可以写一个完整的句子描述期望的行为。无论哪种形式，目的都是提供足够的信息提醒我们有重要的事情要讨论及测试，而非事先记录下繁杂的细节。卡片（Card）、对话（Conversation）及确认（Confirmation）是组成用户故事的3个C。这3个C也可以用在验收测试上。

还记得客户支持系统中的验收测试吗？回顾图9-2中的测试，你认为它们足够简洁及准确吗？也许是，也许不是。我认为测试中有些内容还是可以删去的，删去后依然能够表达原意。图9-5修改了图9-2中的测试。

注意到简洁性方面的差异了吗？开发人员仍然知道该测什么，因为他们可以向客户询问细节，例如不存在的账户号码该显示什么信息，或者什么数字应该被忽略等。图9-5中的测试比图9-2中的更准确，因为删去了实现过程中可能发生变化的细节。

图9-5　修改后的图9-2中的验收测试

显然，有些人喜欢在验收测试里写更多的细节。当然，无论你是否希望多写点字，或者你喜欢把图标和UI设计作为用户故事和验收测试的一部分，都依你和你的团队来定。

只要你记着保持验收测试简洁准确，只要不写出以后可能造成误会的模糊的东西，就没问题。特别要注意不要写显而易见，或者对估计故事大小影响不大的细节。

验收测试的最后一项特点更侧重于测试自动化，而非书写形式。

9.2.3　实现验收测试

验收测试的另一项特点是，实现验收测试的语言或许和实现系统所用语言不一样。两种语言是否一致，取决于所测系统的整体架构。例如，一些编程语言在互操作方面比其他语言更容易。同样，要通过HTTP为Web应用写自动化测试，任何语言都可以；但是如果为嵌入式系统写测试，那么只能用实现系统的语言了。

之所以要用与编写系统不一样的语言实现自动化测试，是因为写自动化测试对语言的要求与编写系统所要求的常常有很大差别。例如实现实时系统，通常要用Native C代码，但是比起用脚本语言，用C实现实时系统的测试通常会相当冗余，缓慢，还可能有很多错误。

表达验收测试理想的语法可能是声明式的表格结构，例如电子表格 ，或者近似于一系列用英语写成的高抽象层次的操作。如果期望客户与开发人员合作写验收测试，那么功能齐全的语言，例如Java、C/C++或C# 就不行了。“能完成工作的最好的工具”并不仅指技术上最先进的，因为开发人还要与客户合作。

现在我们了解了验收测试，也知道了谁该编写测试。下面来看看如何用验收测试驱动开发过程，看看ATDD究竟是什么样。

9.3　理解过程

TDD让开发人员能小步地演化软件，总能确保软件工作正确。这是因为开发人员用自动化单元测试来表述期望的行为。ATDD并不会在技术层面上进行保证，而是在功能层面保证“软件做了我想要的事情吗？”。

换句话说，在TDD中，我们先定义代码应当具有的行为，然后再实现，而在ATDD中，我们先定义系统应该有的，能带来用户价值的功能，然后再实现这项功能（当然使用TDD了）。

我们已经知道什么是验收测试了，现在来从整个ATDD的过程及整个迭代的层面进行观察如何？然后我们会仔细讨论每个有意思的细节。

关于客户

你可能注意到了，用户故事这种需求管理技术强调与客户紧密而频繁地沟通。如果你担心没有在现场的客户，或者担心客户没有和你一起合作写软件的热情，那么这种担心是多余的。有很多办法可以解决这个问题，一会儿我们将会提到。现在你可以把客户当作一个角色，而非具体的人。这个角色可以由对产品领域了解足够多，可以做出与真实客户一样的决定的测试工程师或者开发人员担当。

现在，我们一起来看看由4小步组成的ATDD过程。

9.3.1　ATDD周期

ATDD过程的最简形式，可以用图9-6中简单的循环表示。

图9-6　ATDD周期

在一个迭代中，只要还有故事可做，这个周期就会继续。先选一个用户故事，然后写测试，接着把测试转成自动化的可执行的测试，最后实现功能，让验收测试通过。

在实际应用中，当然，并不总是这么容易。我们或许还没有用户故事实现；故事内容也许很模糊，甚至自相矛盾；故事间也许有依赖，等等。一会儿我们将会处理这些复杂的问题。现在，我们先忘记复杂的部分，从简单的步骤入手。说到步骤，我们来看看这些步骤的具体组成。

1. 第1步：挑选用户故事

第1步是要挑选故事，这并不总是件容易的事。不过大部分时间backlog里的所有故事都是有优先级的。假定已经有优先级了，最简单的方法就是从stack顶上拿一个，也就是挑选剩下的故事中最重要的那个。但有时候事情并不这么简单。

通常，故事都是从整个项目过程中的各种计划会议中收集来的。客户在这类会上提出新需求，用各种示例演示系统在不同情况下该如何工作。而开发人员和测试人员会提出针对新需求的问题，通过这些问题加强学习和讨论。有些信息会用故事卡（无论是虚拟的还是真实的）记录下来。在这种计划会议中，客户还会根据业务价值（包括业务风险）和技术风险（由团队评估）给用户故事排优先级。

从用户故事列表中挑选故事时可能会有什么问题？有时候要完成最高优先级的故事可能需要一项我们缺少（或掌握得不够）的技能。这时候，我们可能会想跳到下一个故事，看看是否合适做。使用结对编程的团队不常遇见这个问题。在结对工作时，即使是最明显的跨职能团队也能通过调整结对来找出合适人员做最高优先级的故事。

最不能胜任工作的人

在团队中分配任务，传统的方法是把任务交给最擅长做的人。这样做很正常，也很安全，但不一定是最好的方式。Arlo Belshee在Agile 2005会议上公布了一篇实验报告。在报告中，他提到在他工作的公司里，人们有意识地调整工作方式，然后观察什么可行，什么不可行。他们发现“把工作交给最不能胜任的人”是可行的。要想详细了解他们在这方面的经验，以及对此现象的解释，请去Agile Toolkit Podcast观看Arlo的采访（http://agiletoolkit.libsyn.com/）。

挑选用户故事方面还有更多的问题，但大部分问题都能用常识解决。现在，我们来继续过程中的第2步：为挑选出的故事写测试。

2. 第2步：为故事写测试

手中（或是在屏幕上，如果你习惯于在线管理故事）有个故事卡后，下一步就该为故事写测试了。如果你认真读过本章前面的内容，你应该知道测试该由客户来写。那么该如何做呢？

当然，首先要做的是和客户在一起。也就是说让团队成员和客户坐在一起（还记得吧，客户才是测试的所有者），然后给故事拟出一系列测试。

一如既往，做事的方式可以依照个人喜好，我喜欢迅速地列出我们要测试的故事的大致的场景或方面，验证完这些后，就能确认故事正确地实现了，以后在具体实现故事或者验收测试时再细化。写测试时只专注于要测试的几件事情，即完成故事必须要做的几件事。

在讨论验收测试的特性时，我们已经见过几个验收测试的例子了，例如图9-4中故事卡背后的那3个测试。第2步完成后我们应该能得到那样的测试。

时机

尤其是在已经开始了一段时间的项目中，客户和开发团队通常对计划会议中将会安排的下个迭代的故事已经心中有数了。在这种项目中，客户和团队可能已经在上个迭代中为下个迭代会安排的故事草拟验收测试了。这意味着我们可能会为几个星期后才会实现的故事编写验收测试。此外，在实现过程中，我们也许会发现缺失了的测试。因此，在写用户故事和客户最终验收故事之间任何一个时间点，都可能会编写测试。

有了这个简单的清单后，我们就能够细化测试，添加更多细节，讨论各部分如何工作，确定客户对界面是否有特别的要求等。根据功能的类型，对应的测试可以是一组顺序操作，或者是一组输入及对应的输出。测试常常会（特别是在流式测试（flow-style tests）中）指定某个起始状态，测试将此状态认作系统的一部分。

除了细节层次和添加细节的顺序外，我们还要考虑何时或是否把测试转化成可执行的形式。这就是我们第3步要讲的：自动化测试。

3. 第3步：自动化测试

在我们把验收测试写到故事卡背后、白板上或粉红色的餐巾纸上后，下一步就该把验收测试转化成某种能自动执行，且执行完会返回一组成功或失败结果的东西了。因为我们把上一步叫做“写测试”，那这一步就叫“实现或自动化测试”吧。

为了弄清可执行的验收测试和上一步中写的验收测试之间的区别，下面来看个例子。还记得图9-5中的测试吗？我们能用许多方法及工具把那些测试转化成可执行的形式。目前“基于表格”的工具应用最为广泛。这种工具的特点是基于表格，用行和列的表格格式编写测试，这种测试人和机器都能读。

图9-7中为图9-5中“校验账户信息”测试自动化后的形式。

图9-7　在纸上勾画出的可执行测试的一个例子

在图9-7中，我们列出了可执行测试包含的步骤，以验证携带有效账号的呼入电话会被正常处理，客户信息也能显示出来。测试的格式能很容易地用工具转化成表格形式——例如那种以HTML表格为输入，然后依照某种规则，把内容转化成一系列的Java方法调用的工具。

什么？Java?这是从哪里来的？我们刚才不是说表格吗？大部分情况下，没有工具能够读懂表格里的用领域语言写成的测试，也没法将这些测试转化成具体操作。我们必须自己做实现，这类工作通常由开发人员和测试人员完成。总结把验收测试转化成可执行测试的两个方面：我们把测试写成机器和人都可读的形式，然后写基础设施代码，把测试和系统的具体操作联系在一起。

方式

图9-7中的是一个流式测试，基于一系列的动作及动作参数。不过这并不是唯一的方式，用声明方式表达期望的需求或业务规则，这样的测试通常比流式测试更有表达力。在真实项目中，测试中的细节会比这个例子中的多很多。不过主要目的还是尽量保持测试简单，能抓住要点，并且侧重“做什么”而非“如何做”。

不同团队有不同的记录内容的方式，这没什么好奇怪的。有的团队直接用文本处理器把内容记录成电子形式；有的甚至直接把测试写成可执行的形式。有些人，包括我在内，习惯和客户先写在物理媒介上，稍后再运行测试。例如，我习惯在白板或纸上草拟可执行测试，等我确认这些东西基本不变后再做实现。

这样的好处在于，我们不会变成技术的奴隶——我发现讨论问题时，人们的注意力常会集中在工具上，而非要处理的问题，这样不好。另外，使用软件还会有副作用，例如会使产出看上去更正式，更像最终产出，因此也需要更多的润色工作。因此耗费的所有时间和金钱，都会让我们偏离真正重要的工作。

在客户资源稀缺的项目中，特别是在迭代刚开始时（这种情况很常见），让某个团队成员负责最无趣最没有技术含量的转化步骤，会让客户有更多时间去细化其他的故事。不过让某个团队成员负责此类工作的缺点在于，会让客户失去对验收测试整体的拥有感，毕竟转化后的测试和他当初写的不完全一样了。此外，把测试转化成可执行格式后，客户在阅读验收测试时可能有困难，这种困难程度依测试自动化工具和语法不同而不同。

例如，我们的测试自动化工具使用Ruby这样的简单而功能强大的脚本语言。图9-8中，比起自己参与完成的草稿，客户对验收测试代码的拥有感也许不强。虽然可执行的Ruby代码对开发人员很易读，但对非技术人员就不同了。

图9-8　草图和真实实现了的可执行验收测试的区别

另外需要考虑的是，我们该一次把全部测试都转化为可执行的，还是在实现过程中一个个的转化？有些团队——很大程度上取决于对需求的确定程度——会在实现前自动化故事的所有测试。而有些团队习惯像做普通TDD那样小步前进，实现一个测试，完成对应的功能，然后再实现另一个测试，如此往复。一次实现所有测试比起一步步实现的缺点在于，堆积了过多未完成的工作。我个人强烈推荐一步步实现验收测试，而不是一下全都实现。1

1 不过值得一提的是，若在计划会议上为测试自动化工作而细化验收测试，那么团队会更好地理解故事的复杂性，因此做估计时也更准确。

至于采用物理载体还是电子载体，是否一次把测试都全转化成可执行的形式，很大程度上取决于个人。有些客户习惯于可执行形式的测试[特别是当工具支持领域专属语言（domain-specific language）时]。有些客户可以轻松识别自己编写，但已经转化成了可执行形式的测试。因此，正如软件开发的其他方面，这些事应该依照情况而定。

不论一次自动化多少测试，在这一步完成后，我们至少会有一个可执行的测试。在动手实现功能之前，我们必须写代码，让自动化测试工具知道这些有趣的词在技术上的具体含义。也就是说，我们必须确定“选择一笔交易”或者“拨电话”对应的API调用或系统的操作。

换言之，一旦我们到达了这一步，可执行的验收测试就能告诉我们特定的功能是否缺失了。很自然，下一步就是让测试通过——即实现功能，满足测试的要求。

4. 第4步：实现功能

下一步就该实现新功能，让新加的验收测试通过了。ATDD本身并不会限制实现功能的方式，不过，不用说，使用ATDD的人都倾向使用本书前面所介绍的TDD做实现。

一般而言，每个故事都代表一项对客户有价值的功能。开发人员会把故事分成一组任务，然后会用某种办法，例如TDD，完成单个任务。单个任务完成后，开发人员会开始下一项任务，如此往复，直到完成故事，通过验收测试。

实际上，在这个过程中，一个迭代会被分成许多更小的迭代。图9-9描述了ATDD中来回“切换”TDD的过程。

可以看到，ATDD周期的第4步，即实现必要功能来修复验收测试，可以扩展成一系列小的测试—编码—重构的TDD周期，一步步地完成功能直到验收测试通过。

图中各项工作所占比例并一定与实际相符。TDD周期可能会占用几分钟或几十分钟时间，也可能要用几小时或者一整天的编码才能通过验收测试。

开发人员在实现故事时，会不断地和客户讨论功能的具体运作方式。在这个过程中，开发人员难免会发现原有测试之外的新的测试。合理的做法是，在征求客户的意见后，我们应该把新的验收测试加到清单中。之所以要征求客户的意见，是因为客户对功能特定方面的价值的看法可能和开发人员不一样。

在我们过完故事所有的任务和测试，且验收测试也通过后，取决于我们是一次自动化全部测试（不推荐），或是一个个自动化测试，我们可以回到第3步自动化另一个测试，或者回到第1步挑一个新的故事。

图9-9　TDD及ATDD的关系

或许可以起来遛达一会儿，喝杯咖啡，或者查查邮件。让验收测试通过可不是件容易的事情。等你回来后，我们会来分析这简单的4小步组成的周期如何能嵌入项目的整个迭代中。

9.3.2　迭代内的ATDD

在正常情况下，整个迭代中工作都会很繁忙。如果花费太多时间在开会或者计划上，那么你会很快落在计划之后，因此需要削减范围（scope）（这又会带来另一个计划会议！）。给迭代制定一个清晰的计划，准备高质量的用户故事，保证有效的客户沟通，那么迭代中的大部分时间应该是用在写验收测试，与客户合作，使测试自动化，以及用TDD实现功能上的。

因此，我们这由4个步骤组成的ATDD周期，挑选故事，给故事写测试，实现测试以及最后实现故事，只不过是由许多用户故事组成的大的迭代中的一部分。至于迭代中有多少故事，取决于你的团队成员的多少，以及故事的大小。为了更好地理解单个故事的“4步周期”如何能嵌入整个迭代中，下面我们会深入一些细节，看看包含验收测试的完整迭代是什么样子（其中验收测试会分散在整个迭代的时间线上）。

图9-10中为包括9个用户故事的迭代的时间线。每个长条都代表一个故事从编写验收测试，实现验收测试，及实现故事本身的整个过程。实际上，每个故事中会有很多迭代，因为我们不会一下实现全部验收测试，而是一个个分别实现。

注意到了吗，用户故事从迭代开始就一个个地相继完成了。这就是ATDD能够提供真实进度的秘诀。我们的两个想象中的开发人员（或是在结对编程时的两对开发人员）完成当前故事后，会挑选下一个优先级最高的故事。当前故事完成前，开发人员不会开始新故事的开发工作。因此，总是只有两个故事同时进行。

图9-10　把ATDD放在时间线上

如果迭代中不需要写故事，那么故事是从哪里来的？如果你熟悉敏捷过程，那么你会知道在迭代开始前，总会有个计划会议。在这个会议中，客户会决定下一个迭代该做哪些故事，哪些故事会留到以后再做。因为我们要在会议中安排何时该做哪些故事，当然要在会议前写好这些故事。

这就是持续计划的作用。

1. 持续计划

虽然在理想情况下，一个迭代应该是完整的封闭系统，包含所有的能满足迭代目标的东西。不过在迭代中还是有必要花时间准备下个迭代的事情的^2。否则，计划会议就会拖得很长，从此你就不会再喜欢长时间的会议了。

2 通常建议使用整个迭代的10%~15% 的时间来做持续计划工作。像其他的事情一样，我们可以先参照他人用过的方法，等到自己积攒到足够的经验后，再调整数字，使其更符合我们的当前情况。

通常，在这类准备工作中我们会浏览所有的故事，选出下个迭代会做的故事，挑出无用的故事，等等。在准备工作中，通常也会编写用户故事，或者编写初始的验收测试，这是为了下一个迭代作准备。我们越清楚地了解后续故事，计划会议就会越顺利，我们也能够很快回去工作继续为客户创造价值。

在计划会议前编写、估计、分解（如果有必要）故事，或者给故事排优先级，则能保证计划会议能顺利进行，让我们尽快回去完成客户所需的功能。

什么时候写验收测试

如果在写产品代码前，所有的验收测试都实现（而且测试失败）了，当然很好。但是这并不常见，因为测试也是需要花费时间编写的，并不是从天上掉下来的。事先写完所有验收测试和提前给大规模的系统做设计一样，并不合理。如果能在做每个故事时实现验收测试，效率会高很多。

如果团队有专门的测试人员，那么测试人员需要在开发人员实现故事功能时就该着手和客户一起自动化测试了。不过大部分团队都会自己实现验收测试，没有专门的“验收测试人员”。

这个过程很大程度取决于客户、测试人员和开发人员何时能在一起工作。如果客户只有在迭代开始的前几天才在团队中，那么你可能需要做些取舍，好让这几天能有最大的产出，其余的工作就推到客户不在时再做。同样，总要有人写代码，而且这人不太可能是客户，软件开发人员和测试人员需要适时参与进来。

在做迭代计划时，有时需要画些测试草图，这可以让工作量的估算更准确。不过总的原则是在结束迭代计划后，就要着手把验收测试转化成自动执行的形式了。

首先选一个故事，然后同时实现故事，以及验证工作正确性的验收测试的自动化工作。如果可能，让两个不同的人实现测试及产品代码，这样可以避免人的本性可能带来的麻烦。

再强调一遍，不要花太多工夫在自动化测试上，辛辛苦苦写了大堆测试却没有实现有用的功能。小步前进，一次就验证一个故事。无论验收测试有多大价值，如果功能没有实现，这些测试没有一点用处。

2. 中期检查

我习惯在迭代中期作检查。这时候，应该已经完成了迭代中的一半故事。当然第一个迭代除外，因为需要搭建许多基础架构。不过，如果我们擅长估计故事，那么中期时总会有大约50%的故事通过测试。

当然，在整个迭代过程中，我们都会跟踪故事的完成状态。有时在迭代的早期我们就发现故事的完成推迟了，应该立即调整后续故事。不过在迭代中期，总应该有半数的故事已经完成了。如果不是，那么安排的事情已经超出团队的能力了，或者相对于迭代的长度，故事有些大了。

从错误中不断学习，我们会发现把故事的完成率当作预测数据来源，比起用天生乐观的态度预测开发工作更准确。如果发现故事做不完了，则需要削减工作。

3. 削减工作

当时间不够用时，我们会削减工作。我们不会拼命（或希望自己突然变聪明），我们不会那样心存幻想。我们也不想牺牲质量，因为保持高质量能维持生产效率，而低劣的质量只会导致更多的返工，使进度变慢。同样，让开发人员加班也不是好主意，因为从长远来看，加班对进度不会有实质影响^3。通常有3种方法做调整：交换、丢弃和拆分。

3 Tom DeMarco和Timothy Lister帮了整个业界一个大忙。他们在著作《别让员工瞎忙》（Slack，DeMarco；Broadway，2001）以及《人件》（Peopleware，DeMarco, Lister；Dorset House，1999）中说明了加班对工作效率的负面影响。

交换故事很容易。我们把故事换成另一个更小的，因此工作量也更少。当然在清楚团队开发效率的前提下，我们必须先为此征求顾客的意见，以保证当前迭代的内容依旧完整。

与交换故事一样，丢弃故事也很直观。“这个迭代不做这个最低优先级的故事了，把它放回backlog吧。”不过考虑到整个迭代给用户交付的价值，丢弃最低优先级的故事并不总是好的选择。虽然故事本身的优先级是很低，但有可能是用户很在乎的整体功能的一部分。我们可不想做局部优化。相反，我们期望在每个迭代完成后交付的东西是完整的，可独立工作的。

第3种削减工作的方法，拆分，比起丢弃和交换来说更棘手，所以另起一节单独分析吧。

4. 拆分故事

在计划会议上，故事的粒度已经很细了，还怎么拆分？总地来说，我们可以根据功能或者细节（或者两者同时）拆分故事。例如故事“作为普通的在线银行用户，我期望能从一个列表中选择转账人信息。列表内容来自我个人历史记录中最常用的账户及最近用过的账户，这样我就不用每次都输入对方信息了。”

从功能角度拆分故事，可以把故事分为“……从最常用的账户清单中”及“……从最近用过的账户清单中”。此外，根据客户给出的“最频繁及最近使用”的具体含义，我们可以加一个故事：“……从加权过的最频繁及最近使用的账户列表中”，其中加权的算法由客户指定。这样我们就能拆分开大故事了。

如果按照细节拆分，那么故事可能会变成首先只记住账号，然后记住对方账户名，最后是VAT号，等等。这种方式是否适用取决于各个细节的工作量。如果大部分的工作都是在一个共同的基础上实现的，而不是每个细节都有各自的实现方式，那么按照功能划分会更好些。而如果大部分工作都是在代码各处添些东西，以支持新的持久化字段，那么按照细节拆分会更合理。

无论使用了哪种方式，重要的是拆分故事后每个小故事仍然是合理的，对客户也是有价值的。

目前我们已经了解ATDD的过程了，下面来一起讨论团队这能让整个过程发挥出威力的东西吧。

9.4　作为团队活动的ATDD

因为软件开发的过程实际上是人之间的活动，所以我们有必要讨论一下团队构成。无论你有什么样的技术背景，你肯定对应用ATDD的团队的类型，团队所需的角色，每个人负责的工作等问题有疑问。我们先从团队中最重要的角色——客户谈起吧。

9.4.1　客户角色定义

规模从大到小，业务内容从高科技到体力活，在种类繁多的业务中我见到了各种各样的软件项目的客户。在有些地方，客户就是付钱的那个人，在另一些地方，客户是使用系统的人，还有的地方，客户是将来卖系统的人。客户也许是最清楚应用领域的人，也可能是无要紧事可做，所以管理层让他来当客户的人。

除了最后一种外，其他都是确定客户的好方法。总的来说，客户应当直接或间接地从项目中获得好处，虽然在当前商业环境中，利益关系可能会有些复杂（特别是在软件产品中）。

注意，这里提到的客户是一个角色，并不是特定的人。虽然大部分项目中都只有一个人担当客户的角色，但事无绝对。事实上，那些跨越多个领域的系统太过复杂，要让一个人理解所有方面并不现实。这时，通常需要让一个客户团队担当客户的角色。不过在这种情况下，需要有办法让整个团队能快速地讨论问题及作出决定。

为了更好地描述出软件项目的理想客户，我们来看看客户该有何种特征。

1. 共享成功

理想的客户与团队的利益一致。要让客户和团队有共同的目标，有共同的利益很重要。若没有共同利益，就很难维持高效沟通，也没法保证客户在日常开发工作中尽职尽责。如果客户在你的项目之外还有其他任务，那么就更糟了。如果有了好的客户，那么他就是团队的一部分，而不是某个外部的人，指挥团队干这干那。

2. 决策权

虽然热情相待比冷眼相对好，不过如果客户没有决策权，那么他只不过是团队和真正客户之间的传话筒而已。在一个需要快速响应变化的敏捷团队中，能在团队内部（客户也算是团队的一部分）作出决策至关重要。如果我们想保持很高的软件交付能力，那么我们必须能很快作出制定优先级，决定用户故事内容等的决策，毕竟我们要先知道做什么，才能着手做。

3. 理解间接影响的能力

仅仅能作出决策是不够的。这些决策必须是依照当时情况的理解作出的很聪明的决定，还必须要考虑到决策背后的间接影响。软件项目的客户，必须和开发团队商量决策对技术方面的影响，也要考虑到决策对费用以及开发所需时间方面的影响。

4. 解释领域的能力

能向团队解释应用领域是客户的一项很重要，但又常被人忘记的能力。团队应当能够讲客户的语言，使用领域专有术语，但是团队不应当自学这些知识。毕竟如果没有客户帮助，要学习这些知识也许需要几年时间。有了能有效和团队沟通的客户，对项目成功至关重要。

现在我们知道客户应当做些什么了。前面我们曾提到客户会与开发团队的成员合作编写验收测试。这句话相当含混，客户与谁一起合作写验收测试？

9.4.2　客户与谁一起写测试

在有些团队中，客户会和测试人员一起写验收测试。在另一些团队中，客户会和开发人员写测试。还有一些团队中，客户会跟由测试人员和开发人员组成的小组一起写测试。在我工作过的团队中，开发人员除了写单元测试和使用TDD外，还担当许多其他的责任，所以我经常见到客户和开发人员一起写测试。不过若让测试人员参与写测试，也会有许多好处。

让测试人员，还是开发人员和客户一起写验收测试，最大的差别在于两者对系统的认知角度不同。开发人员对系统结构很熟悉，也清楚实现故事需要做的改动，这点对满足客户需求很重要。而测试人员，对系统的整体更熟悉，对领域逻辑也更了解。因为测试人员对系统该如何工作已经有了正确的看法，所以也很适合编写测试。

实际上并没有一个黄金标准。大部分情况下，最好能让整个团队参与编写验收测试。这么做不仅有利于传播知识，也能让每个团队成员与客户建立良好的关系。在一起工作得越多，合作也就越顺畅，这些都能帮助团队构建出客户需要的功能。

9.4.3　需要多少测试人员

有些组织，例如Microsoft，通常为一个开发人员配备一个测试人员。这对你的组织是否适用，我不知道。依照我的经验，每两个开发人员配备一个测试人员是可行的。如果开发人员善于做测试，那用更少的测试人员也可以（不过这并不容易，专业的测试人员思维方式与大多数专业开发人员是不一样的）。简而言之，我建议先从每两个或三个开发人员配备一个测试人员开始，然后根据具体情况进行调整。

现在我们已经知道了，ATDD是一个团队级别的过程，会影响整个团队生产软件的方法。除了“能更快地构建出更好的软件”外，我们还没有提到ATDD的其他优点。我想现在应该讨论这个话题了，以从整体上了解整个过程。

9.5　ATDD的好处

我们为什么要用ATDD？原因很简单——它能帮我们写出更好的，能正确工作的软件，也能帮我们避免在不重要的事情上浪费时间和金钱。听起来很美，一起看看ATDD如何做到这些的吧？

很大程度上，ATDD带来的好处可以看作是用户故事优点的拓展——它能使注意力集中到正确的事情上，完成重要的事情同时减少浪费，同时能最大化客户从整个过程中获取的价值。下面我们来看看，ATDD在此基础上带来的额外好处。

9.5.1　“完成”的定义

在项目管理课上我们会学到，项目一定会有开始部分及结束部分，否则就不是一个项目。在这个课上，我们还可能会学到Parkinson法则，即“工作会尽量占据所有时间”。这两点和ATDD有什么关系？问题在于“知道我们在哪儿”以及“知道何时该停止”。我们需要定义“完成”的标准，而ATDD能准确地判断“故事的所有测试都通过了吗”，从而清晰地给出这个标准。

注意　如果你熟悉Scrum，那么你对这个概念应该很熟悉了。Scrum教练要确认的第一个问题就是“完成”的定义。一个公认的“完成”的定义，对衡量进度十分必要。

把验收标准变成自动化验收测试后，因为工作的完成有了明确的标准，我们可以采取多种方法高效地完成软件开发工作。不过并不是所有验收标准都能写成自动化验收测试，还有一部分需要人工测试，或者需要用户确认。用户界面就是个很好的例子。

有人类首肯用户界面才算完成

向外部暴露系统功能的用户界面非常重要——甚至比100%正确地实现功能还要重要。如果界面很糟糕，以至于用户不能正常使用功能，那么功能的作用就大打折扣了。

用户界面的可用性很难用自动化测试来验证，因此我们可能会用一些简单约束的组合来表述验收标准，例如“要访问功能X，最多点击3次鼠标键”，或者“实现方法必须符合现代Web标准”。在实现故事过程中，也可以让用户做一系列的主观评测（比如，如果在特定环境中不能正常缩放，只要开发者足够相信他们已满足了用户故事的功能需求）。

在一定程度上，这种主观评测也可以作为验收测试，规定“如果simon同意”就算是完成了（所有的故事都应该是同一个标准——虽然所有的验收测试都通过了，只有Simon点头才算完）。

无论用什么方式确定故事的完成标准，重要的是让每个人都同意确定任务完成的方式——无论是要所有的验收测试都通过，或是需要主观评测的更复杂的情形。无论采用何种方式，ATDD都把制定测试及验收标准提到了实现用户故事之前。如果有了清晰的完成标准，那么提高工作效率就很容易了。而这点会关系到两个关键问题：“知道我们在哪儿”及“知道何时该停止”。

1. 知道我们在哪儿

验收测试并不能像TDD中的单元测试那样驱动开发过程。不过它能给我们一个具体的目标，告诉我们何时算达到目标了。与TDD中的单元测试类似，验收测试能使我们坦诚面对需要做的工作，也能帮我们防止“范围蔓延”（scope creep）问题及“镀金”（gold plating）问题。验收测试精确地告诉了我们要做什么，毫无歧义地定义完成的标准。除了能告诉我们当前进度外，更重要的是它能告诉客户完成状况。

有了ATDD，客户总可以知道开发进度。如果把验收测试加入到持续集成过程中，随着验收测试的通过，客户可以获取值得信赖的，关于团队工作进度的很有价值的信息。要得到这些信息，客户只需要察看验收测试的通过情况即可。

2. 知道何时该停止

把验收标准明确地写成验收测试，不仅能帮我们确认工作是否完成，还能帮我们避免开发人员常犯的典型的“镀金”问题。在验收测试大喊一声“你已经做完了，让客户来看看吧，然后我们就该开始做其他工作了”之后，我们很难再做出“花点时间做这个很酷的界面效果吧”这种事情了。

再强调一遍，有时候就算测试说工作结束了，实际上也没有结束。有时测试会漏掉了故事的重要部分，我们需要把这部分补上。我们需要和客户商量故事漏掉的部分该如何工作。有时在实现遗漏的功能前，也需要添加新的验收测试。

再强调一遍，验收测试可以帮我们在该停的时候停。不会太早，也不会太迟。

9.5.2　协作

ATDD的一个并不显眼的好处是创造了一个协作性更强的环境，因为所有人在同一时间都在为同一个目标努力。如果在开发完成后再由测试人员测试，那么在发现bug时，开发人员很有可能已经在做别的工作了，这样开发人员和测试人员的目标就有冲突了。

与此相比，如果开发人员和测试人员在一起工作，朝着产出高质量软件的共同目标而努力，那么效率会高得多。每个人都会贡献自己特有的知识和技能来解决问题。

9.5.3　信任及承诺

在ATDD中，我们通过编写验收测试来指定用户故事的完成标准。让客户和开发团队一起编写测试能使用户故事的含义更明确，因为这样能够在客户描述的功能，和客户实际得到的功能之间建立直接的联系。

用户故事的含义之所以更明确，部分原因在于开发团队会按照客户实际要求的那样实现功能，而非参照客户曾说过的或曾写在需求文档上的。还因为开发人员能看到客户编写验收测试，因此能更了解自己写的代码所带来的价值。

简而言之，客户能看到自己的需求真的被满足了，而开发人员能看到工作确实给客户带来价值。给客户演示完成了的故事，让验收测试全部通过，是我知道的最有效的方式了。在ATDD中，编写测试的方式很重要，这点太重要了，因此我们要列举的ATDD的下一个好处就是“通过例子验收”。

9.5.4　通过例子验收

通过例子验收是验收测试的一个重要方面。它通过有意义的例子，而不是复杂的程式或者模糊的描述来表达需求。这么做有一系列好处。

把充满歧义的泛泛描述转化成可执行的测试会很困难。此外，如果需要从复杂的描述中解析出测试的本意，那么客户会很难验证测试的正确性。

总之，用具体例子写的测试容易读，容易理解，容易验证，也很容易写。最后一点非常重要，因为我们期望客户参与到编写系统测试中来。

9.5.5　弥合差距

无论单元测试多么完备地覆盖了产品代码，它们也不是在测试系统，只是独立地测试代码和组件而已。换言之，单元测试能准确地验证代码是否工作正常，但若没有其他测试，我们不知道软件作为一个整体是否能正常工作。实际上，大部分单元测试甚至不能确认验证过单个的组件组合在一起后仍能工作，虽然适时使用基于交互的测试能解决部分问题。单元测试，特别是测试先行的编程方式能极大地提高软件质量，不过我们还需要更多测试以覆盖整个系统。

有了ATDD测试套件，有人甚至会质疑单元测试的必要性。有些成功的程序员只参照自动化验收测试的反馈，也只把自动化验收测试当作回归的保障。我个人认为，虽然在某些情况下自动化验收测试可以提供足够的回归保障，但是TDD中的单元测试带来的好处也毋庸置疑。总的来说，具体情况具体分析。

我一再强调不要把验收测试当作TDD中的单元测试——否则我们会对用户故事的整体情况失去把握，而且若没有好的单元测试，重构时也会失去保护网。ATDD不是用来替代TDD的，它们之间是相辅相成的关系。

现在我们已经了解了ATDD整个过程的基本要点及每个人的职责。现在，我们应该从技术层面上深入了解验收测试所验证的东西了。

9.6　我们究竟要测试什么

仅仅说验收测试会验证用户故事，并不能表述清楚验收测试中所验证的内容。虽然验收测试用于验证用户故事实现的是否正确，但是验证的方法却有很多种。第11章会详细讲解这些方法，而现在，我们先来了解验收测试的大致理念，从总体上认识验收测试需要验证的方面。

9.6.1　应该针对UI测试吗

验收测试是该操作真实用户界面[或者机器到机器系统（machine-to-machine system）的外部界面]，还是应该调用用户界面下的API进行测试？这个问题很常见。答案当然是“看情况”。总体来说，我们的目标是花最少的功夫，赢得最多的自信。无论是通过端到端测试（end-to-end tests），还是用分治策略编写更小的、更独立的测试，都需要视情况而定。

在验收测试中，是要直接验证系统真实的界面，或是验证抽象用户界面（例如使用第8章中提到的Presenter模式），或者是验证向用户界面暴露业务逻辑的service层，都需要考虑如下几个问题。

- 针对此界面写测试容易吗？

- 写出的测试会很脆弱吗？

- 性能如何？

对于某些系统，特别是机器对机器类型的后台系统（machine-to-machine–style backend systems），测试外部界面是最好的选择。这是因为这些界面有清晰的定义，不会像用户界面那样频繁变化。总体上，机器对机器界面更容易测试。

图形用户界面（GUI）是最难测试的一种界面。因为比起机器对机器界面，GUI在添加新功能时更容易发生变化。认真地设计GUI及其测试的实现方式，能部分地解决问题。不过，大部分情况下，这并不仅是技术问题而已。

1. 非技术问题

有了现代工具的支持，改变按钮的位置或是改变基本布局对GUI的验收测试并无影响。不过注意不要把测试弄得过于复杂。通常，有必要在接口上再封装一层测试语言来降低测试的复杂度。这样，编写GUI的验收测试的工作量就和内部API测试的工作量相近了。

2. 注意性能

有时候，性能问题是验收测试绕过真实界面，直接验证内部API的原因所在。外部界面可能是一个只有有限带宽的串口，而验收测试会从这个小细管中传送数十兆的数据。这时候，在执行测试时绕过瓶颈，牺牲一些覆盖率换来高速的回馈，就显得很有必要了。

即使我们的测试是直接针对内部API的，仍然有必要让一部分测试针对真实系统执行（虽然执行频率可能没有其他测试那样频繁）。在软件开发中，事情可不是非黑即白的。实际上， 我们的验收测试通常是要操作所有层的：从GUI，经过内部API，然后达到隔离的业务逻辑。

完全访问不了真实系统，是验收测试必须通过内部API做验证的另外一个场景。例如，我们或许没有用于访问系统的设备，或者没有部署系统的硬件设备。这就引出了下一个问题，我们究竟该测什么？

9.6.2　可以使用部分系统的伪实现吗

正如写测试验证内部接口而非外部接口一样，有时也需要用伪实现替换部分系统（并不一定是外部接口）。

例如，运行每个测试前都需要往数据库中填充大量数据，而使用的数据库速度很慢，这使反馈周期过长。这时候可以考虑用内存数据库替代真实数据库，减少访问数据库的开销。或者也可以使用运行在同一台机器上的本地数据库，以及减少网络I/O的开销。

在这里，问题依旧存在：我们应该这么做吗？为了使用方便和提高速度，我们应该用伪实现或者替代实现，替换掉部分系统吗？

1. 足够接近真实系统

是否应该这么做，取决于替换后的系统与替换之前是否足够接近。用本机上的本地数据库替代远程数据库，两者差别微乎其微。用其他的数据库替代真实的产品数据库，或者把数据库切换到内存模式下，情况就复杂得多了。

不同数据库支持的SQL语法的差别，数据类型在不同数据库中的处理方式的差别等，会带来很大风险，所以即使真实系统很慢也别无选择。这是个主观的决定，并没有一个四海皆准的答案，需要评估具体情况的风险和收益后再决定。

2. 有时候用替代实现是唯一选择

有时候用替代实现是唯一选择。例如开发嵌入式系统或者高度依赖于硬件的软件时，由于价格过于昂贵，可能会买不起硬件芯片或者设备。另外一个常见的场景是并不总能使用所需硬件，使用时间很短。在这种情况下，最好能在模拟器上运行验收测试，而不要为了运行3小时测试而等待2个星期。

运行验收测试是否应该用部分系统的替代实现这个问题，比起定义何为“足够接近真实系统”要复杂得多。验收测试并不都是一样的，而且客户感兴趣的也不仅局限在系统层面上，还包括下面要讨论的领域逻辑。

9.6.3　应该直接测试领域逻辑吗

就像传统测试不仅会测试功能性需求，还会测试非功能性需求一样，验收测试会覆盖软件的许多方面。或许大部分的验收测试都是验证功能的，用不同的交互顺序指定正确的行为。通常，还有一小部分验收测试是用来指定系统及非功能需求的，例如系统能处理的并发请求数目，或者系统如何从数据库错误或者网络问题中恢复。另外验收测试还会验证业务逻辑——我们和系统交互时在幕后发生的事情。

从客户角度来看，测试领域逻辑很有用。在许多领域中，相关的业务逻辑有必要进行单独测试。如果没有埋在无关的（例如如何填表单）信息中，业务逻辑会更容易理解。从开发人员角度也一样，集中注意在业务逻辑上，我们更能清楚系统需要做的事情。

也就是说，如果所涉及领域的领域逻辑不简单，那么最好能直接测试这些逻辑，而不是把这些逻辑藏在外部界面上一系列的操作之下。

在结束本章之前，我们来对实现ATDD中的验收测试的工具做个概览。

9.7　工具概览

就像软件开发中其他方面一样，选对了工具会让工作事半功倍。写验收测试时，工具需要提供如下支持：

- 编写测试

- 实现测试

- 运行测试

- 报告测试结果

这个工具首先能够支持客户友好的格式，能有效地促进开发人员、测试人员和顾客间的协作。此外，这个工具还需要帮我们把用户友好的测试转化为可执行的形式，而且能够执行测试——这才叫自动化。当然，在运行测试时还有其他需要注意的。

因为我们基本都需要把验收测试集成到持续集成服务器中，作为一个后台任务定期运行，因此这工具还要能以批量模式运行测试，不需要任何手工干预。如果不能知道运行结果，那么运行测试毫无意义。因此，工具还要能报告测试结果。

有许多验收测试工具符合上述要求。我会先对这些工具进行大致介绍，然后再仔细研究其中的一种。

9.7.1　基于表格的框架

最近验收测试工具流行用表格形式表示测试。通常客户会编写由行和列构成的表格，测试框架会解释表格单元中的语义及数据来执行测试。客户可以使用自己熟悉的办公软件创建测试，只要把文档保存成测试工具能读取及能解释的格式即可——通常是HTML（如果客户用的是Word）或者XLS（如果客户用的是Excel）。

这类工具包括Fit、FitNesse及Selenium。

1. Fit：聪明地解析简单的表格

Fit（Framework for Integrated Tests）由Ward Cunningham开发，目的在于方便开发人员和客户协作编写验收测试。Fit提供了以HTML表格形式构建测试夹具（test fixture）的语法，同时能把夹具与背后的各种语言（Java、C#、Python等）的实现连接在一起。

Fit能够解析给定的HTML文档，找出表格。一旦找到一个表格，Fit会调用表格对应的代码。最后，Fit会渲染输入文档，根据测试结果的不同，把表的单元格涂成不同颜色——验证失败的单元格涂成红色，验证成功的涂成绿色。其他任何非表格的元素都会被当作是说明部分，Fit不会进行处理。Fit在使用敏捷实践的团队（特别是极限编程团队中）中相当流行。

2. FitNesse：Wiki中的Fit

ObjectMentor的朋友们写出了FitNesse，它让编写Fit测试变得更容易了。FitNesse实际上是 Web服务器、Fit和wiki应用的结合体。它允许用户用更紧凑的wiki语法，而不是HTML语法（没有图形化工具时手写HTML相当痛苦），在线地编辑网页。在编写验收测试时，用轻量级的简单标记语言表示表格、标题和列表能极大地提高生产力，因为测试变得更容易编辑也更灵活了。

3. Selenium：控制浏览器

Selenium用来通过真实用户界面测试Web应用。Selenium的实现方式相当独特。

和 Fit及FitNesse一样，Selenium测试也需要写成HTML表格形式，不过是基于Javascript实现的，而且会通过JavaScript在Internet Explorer或Mozilla Firefox等真实Web浏览器中执行。 这比模拟浏览器执行Web应用的测试框架好许多，因为不同类库对JavaScript的支持程度不同，而且浏览器对JavaScript支持也不尽相同。

另外，Selenium也有远程控制（remote-control）模式。在这种模式下，可以使用Java或者Ruby这样的脚本语言编写测试，在真实Web浏览器中执行测试。

9.7.2　基于文本的框架

虽然基于表格的框架目前占据主流，不过他们并不是验收测试的唯一形式。而且不是所有人都喜欢用表格形式编写测试。表格单元中的文本很接近于书面英语，不过表格结构本身还是有一定语法规则的。幸好，有些工具能让我们用纯文本方式编写测试，而不是以HTML等形式组织文档。

1. Exactor：简单关键字驱动的测试

Exoftware的Brian Swan开发了一个名叫Exactor的框架，旨在帮助开发人员以更精炼的方式编写测试，同时不会过多限制测试实现。Exactor的测试都是以纯文本文件的形式表达的，而不需要把测试写进HTML表格中的单元格。测试有如下规则：空行或者以哈希字符开始的行是注释内容，其他所有行都是一个动作及其参数的组合，以tab字符分隔。框架会自动把动作的名称映射成Java类，参数会被传递给Java类实例的execute方法。

2. TextTest：用日志测试

TextTest是另外一个用纯文本，而非结构文档表示测试的框架，由Geoffrey Bache编写。 TextTest的独特之处在于测试是以日志文件形式编写的。这个框架的基本想法是客户能用任何语言把测试记录在纯文本中，然后开发人员会在产品代码的适当位置上添加日志语句以实现测试。运行完测试后，TextTest会比较期望结果和日志文件以确定执行结果的正确性。

9.7.3　基于脚本语言的框架

还有一类验收测试工具，这类工具借助于脚本语言的灵活性和友好性，十分强大。这些工具通常使用脚本语言，例如Python或Ruby，构建出领域专属语言。

这类工具的实用性取决于所使用的脚本语言的语法是否适合定义自己的词汇表。但若复杂的语法不是问题（例如客户有足够的编程功底处理这些语法），那么这类工具给实现测试提供了无限可能——毕竟有一个功能齐全的脚本语言做后盾。

Systir^1是这类工具的一个典型代表。它利用Ruby语言的灵活语法，能构建出相当不错的客户领域专属语言。

1 http://atomicobject.com/pages/System+Testing+in+Ruby。

9.7.4　自制工具

根据某个团队的需要专门定制的工具，是测试工具中很重要的一种。自制工具夹在了“买现成的”和“自己做”中间。自己编写测试框架固然有开销（包括构建及维护开销），而且写测试前需要花不少时间来实现测试框架。

自己写框架的好处在于用着顺手，比起地球的另一端的某个人写的工具，更能满足我们的需要。并且编写测试框架的开销比想象的要少。我曾经用一天时间写出了一个基本的基于表格的测试框架，读取Excel表格形式的测试脚本来验证Web应用。因此我建议，首先把编写自己测试工具的开销，和使用不完全适用于你项目的现成框架的开销作个对比，然后再作出选择。

自制的框架和现成框架间只有很小的差别。实际上大部分项目都会编写一部分测试框架——有些是完全从头编写，有些构建在现有工具（例如Fit）之上。问题在于构建过程中要利用多少第三方软件。

好的，关于工具的讨论已经足够了。下面用一个小结来结束本章吧。

9.8　小结

迄今为止，我们了解了用户故事这种轻量级的管理需求的技术。在本章中，我们讨论了用户故事和验收测试的关系（验收测试从功能角度驱动开发过程，正如TDD和单元测试在编码角度驱动开发过程一样）。

首先我们了解到了什么是验收测试。然后我们谈到了ATDD的整个过程，了解到了所涉及的4个步骤：挑选故事，编写验收测试，自动化测试及实现功能让测试通过。我们也看到了这4个步骤如何能嵌到敏捷开发整个迭代中去，同时了解到了在整个迭代中涉及用户故事及验收测试的各种活动。

在讨论完ATDD的优点后，我们转而讨论了团队及ATDD常见的问题，特别是客户在整个过程中的作用。最后，我们主要讨论了验收测试需要验证的点，同时浏览了实现验收测试的开源工具。

现在我们已经大体了解了ATDD及其工作方式。下面来一起深入了解其中一个实现验收测试的工具吧。下一章中会介绍Fit，我们之前提到过的一个开源工具，这个工具在今天可能是使用最为广泛的。了解Fit的工作方式，及如何用其编写自动化验收测试后，在第11章中，我们会讨论实现验收测试的各种技巧——即如何把测试逻辑和待测系统联系起来。

第10章　用Fit创建验收测试

我们在探寻两种无形的东西之间的一致性：未设计出来的形式和尚不能正确描述的上下文。

——克里斯托弗·亚历山大，模式语言运动之父

在撰写本书时，Fit框架是应用最广泛的验收测试框架了。Fit不但流行，而且还很好用。因此在本书中，我们会使用Fit来演示如何用一个既能支持客户协作，又能帮助开发及测试人员执行测试的工具来自动化验收测试。在用Fit深入ATDD细节之前，我们需要先了解Fit，以及如何用Fit编写验收测试。这些就是本章的主要内容。

我们会先从大体上了解什么是Fit，它能做什么，以及如何用它实现ATDD。然后我们会讨论Fit的核心概念——测试文档（test document）及夹具表（fixture table）。接着，我们会学习到Fit如何把表格绑定到夹具类上，以把夹具表转化成可执行代码。我们也会了解到Fit如何通过三种内建夹具，即ColumnFixture、RowFixture及ActionFixture来实现此功能。

学习完三种内建夹具类型后，我们会讨论如何扩展及定制夹具来满足特殊的需要。我们也会提到Fit的扩展——FitLibrary所提供的额外夹具。最后，我们会学着通过命令行执行Fit测试，以及用Apache Ant把Fit测试集成到自动化构建步骤中。

若要深入了解Fit框架，我强烈推荐Fit for Developing Software（Addison-Wesley, 2005）。这本书的作者是Ward Cunningham和Rick Mugridge，他们分别是Fit及FitLibrary的作者。比起这一章，那本书的内容更加全面详实。不过读完本章后，我们也能对Fit有足够的了解，在我们的项目中应用自动化验收测试也不会有问题。

那么，现在开始吧。

10.1　Fit是什么

Fit最初为“集成测试框架”（Framework for Integrated Tests）的缩写，几年前由Ward Cunningham编写，旨在方便客户编写验收测试。Fit支持许多主流编程语言（还支持一些非主流的）。它实际上是一个很薄的框架，能解析基于表格的文档，并把这些表格转化成一系列对基本夹具类的方法调用。夹具类可以用Java、C#、Python或Ruby等多种语言编写。Fit还能产生出用户友好的、多彩的测试结果，用红色或绿色标记表格的每个行的运行结果。

在深入了解谁解析什么，什么会被翻译成什么等细节前，我们先来讨论Fit如何能集成到ATDD过程中。

10.1.1　用Fit进行ATDD

引用Fit主页上的文字“Fit在客户和开发人员之间建立了反馈循环，让客户和测试人员（不必是开发人员）能使用Microsoft Office这样的工具给出程序行为的具体例子。Fit会自动按照例子所描述的检查真实的程序，因此在业务世界和开发人员的世界之间建立起一座简单但强大的沟通的桥梁。”

客户能够使用自己熟悉的工具，在文档中插入表格来描述程序期望的行为，验证特定的功能和行为是否已经完成，这是反馈和合作的基础。不过从一个包含文字的表格到一段能检查系统功能的计算机代码，中间还有不少差距。图10-1中描述了ATDD过程中会发生的各种事件。

在图10-1中，Fit创建可执行验收测试的过程从左上角开始。下面我们会逐个解释每一步。

图10-1　由客户描述的测试文档转化成可执行测试的过程。客户起草完测试文档后，开发团队首先要清理表格，让Fit能正确解析。有了语法正确的测试文档，开发团队接下来需要实现支持代码，即夹具类，这样Fit在处理测试文档时就知道该做何操作了。这三步都完成后，我们就有了一个完整的可执行测试了，测试包括一个含有夹具表的文档和实现了夹具表行为的代码

1. 客户起草

第一步是让客户（可以和开发团队的一个成员合作）起草测试文档。“起草”表明这份文档不会是最终可执行格式，但是已经能描述出用户故事所表述的行为了，文档中还会用表格描述系统行为的具体例子。

在起草阶段，测试文档是客户表达需求的工具。因此，即使是和技术人员一起编写，文档的重点也是在内容本身，而非小心遵循Fit规定的特定测试格式。有些团队喜欢在纸上起草，在下一步清理草稿表格时才将其转化成电子格式

2. 团队清理草稿表格

在第二步中，开发团队（开发人员，测试人员，业务分析师——所有熟悉Fit及现存夹具类的人）会进一步处理草稿，以符合Fit的要求。

处理文档时，通常都会给客户创建的表格上添加表头，填入Fit需要的元数据（稍后解释），修改客户使用的文字以符合现有的夹具类，因为技术原因把表格一分为二，等等。

虽然可以在表格上做润色、拆分及移动等一系列重构，不过请注意不要改变客户的初衷。换言之，虽然需要把草稿整理成标准格式以方便Fit处理，但是更重要的是整理后的文档要能让客户理解。因此为了增加可读性，多个表之间可以存在重复，而不能为了消除重复使文档可读性变差。

在开发团队清理完测试文档后，Fit就可以解析及处理文档了。不过，在这个时候大部分测试都会失败，因为Fit并不明白“累计利息”或“投资组合中的债券数量”在技术实现层面是何含义。因此，在图10-1中还需要有第三步，下面我们进行讨论。

3. 团队实现支持代码

在客户以表格形式草拟出期望行为的实例，开发团队清理完表格之后，开发人员就可以实现夹具类，把测试文档和待测系统联系在一起了。图10-2中为Fit框架的翻译过程。

图10-2　Fit框架能把夹具表中的客户语言转化成夹具类中的计算机语言。夹具类可以由许多编程语言实现，通常（但不必须）由实现系统的语言做实现。脚本语言很灵活，且容易与其他技术集成，因此也是实现backing代码的好选择

Fit在解析测试文档遇到一个表格时，需要知道如何正确处理该表格。但是客户在表格中用客户能理解的语言（例如英语）描述期望的行为，而待测系统只能理解0或者1，所以必须要翻译。

Fit就像一个哑翻译器，它能理解夹具表的格式和结构，然后根据某种规则把夹具表的内容映射到夹具类上。夹具类会做好自己的工作——不过这些和Fit毫无关系——然后依照某种规则传回执行结果。当讨论具体的夹具类时，我们会深入了解相关细节。

为了把夹具表和待测系统联系在一起，Fit要为每个夹具表准备一个夹具类。幸好，大部分夹具类都能被多个测试文档重用。有时也不需要为测试文档实现新的夹具类，因为文档使用了由已有夹具类支持的夹具表。

我们刚才提到了测试文档、夹具表及夹具类。下面我们来看看这些东西究竟是什么，先从文档和表格开始吧。

10.1.2　包含夹具表的测试文档

在Fit中，测试被宽泛地定义为包含任意叙述性文字和可执行夹具表的测试文档。当我们用Fit执行这种测试文档时，叙述性文字部分会被忽略，而夹具表会依照出现顺序逐个解析。

虽然也可以把这些测试文档写成其他格式，但是在使用Fit的项目中，HTML格式最为常见。有些团队使用Microsoft Word编辑文档，然后保存成HTML格式。图10-3中为一个在Microsoft Word中打开进行编辑的HTML测试文档。

图10-3　Fit测试文档是普通的HTML文件，可以用文本编辑器修改。在图中的样例文档中，可执行测试和夹具表与叙述性文字内容混合了在一起

图中的文档包含两个表格，中间穿插了一些叙述性文字。文档中包含标题——Fit会把这部分当作叙述性文字处理——文档中也可以包含其他任何HTML标记，例如图片（像UI原型的截图）、外部文档的链接，等等。总之，Fit完全不关心这部分内容。Fit所关心的只有文档中的两个表格，因为这是需要执行的内容。

图10-3中的两个表格是测试文档的可执行部分，即我们曾提到过的夹具表。在测试自动化的世界里，夹具一词有很多含义。在第一部分中我们曾学到，在单元测试中夹具是指，测试类执行自动化测试时用到的某个状态或一组对象。而在Fit中，夹具一词有不同的含义。下面进行解释。

为测试文档创建公共模板

虽然在技术层面上并没有规定测试文档必须完全一样，不过大部分项目最终都会为Fit测试创建某种模板。例如在某个项目中，所有测试文档的开始都会有一个表格，记录相关的测试用例及所在迭代信息。对Fit而言，这个表格只不过是普通一个夹具表而已，因此开发人员需要为其添加一个支持夹具类。不过此夹具类并不会做任何事，因为表格是用于描述测试本身的，而不是用来测试系统的。

10.1.3　夹具：表格和类的结合

迄今为止，我们知道了在Fit中测试文档中的表格被称为夹具表。从开发人员（或者任何负责实现夹具表支持代码的人）角度来看，图10-3中的两个表是CalculatorFixture类的两个实例。这个类能把表格数据映射到操作待测系统的方法调用上。可以说夹具是测试文档中抽象夹具表的编程语言实现。图10-4是图10-3中的一个表。

图10-4　图10-3中测试文档的一个夹具表。第一行告诉了Fit该使用哪个夹具类解析表格，表格剩下内容就交由夹具类处理了

第一行的第一个单元格内容为夹具类的名称，在这里是com.tddinaction.fit.fixtures.CalculatorFixture。当遇见一个表格时，Fit会加载第一个单元格中指定的类，用默认（无参）构造函数创建出类的实例。夹具类能让夹具表“变活”，它能告诉Fit在遇到与此类相关的夹具表时该执行的具体操作。因此Fit遇到夹具表所做操作完全取决于对应的夹具类；无论是什么夹具类，Fit的表格解析器都会把表格的行逐个传给夹具对象。

刚才讨论了夹具表和夹具类，那么什么是夹具？有些使用Fit的团队，无论是HTML文档还是Java类，都会被称为夹具。有些人用夹具作为夹具表和夹具类的简写，相信由上下文能推断出其真实含义。不过还是有其他方式解释这些术语的。

从客户开始起草文档，到最后得到可执行的自动化验收测试，我们总是在表格或类上工作。考虑到夹具表和夹具类是紧耦合的，因此我们可以将夹具表和夹具类统称为夹具。

关于夹具，就说这么多吧。为了能深入理解什么是夹具，夹具能做什么，我们来介绍几个具体的夹具类。从三个内建的夹具开始，如何？

10.2　三个内建夹具

Fit自带三个夹具类：ColumnFixture、RowFixture和ActionFixture。除此之外，每个项目还可以根据特定应用领域特征及个人偏好等创建自己的夹具库。不过在自定义夹具前，我们先来了解三种最基本的夹具及其所支持的功能吧。

我们会从最直观的ColumnFixture开始。一旦明白ColumnFixture如何用给定输入验证待测业务逻辑后，我们会继续讨论ColumnFixture的一个更加面向数据的伙伴，RowFixtures。最后，我们会介绍专门用于面向交互测试的ActionFixture。

首先介绍ColumnFixture。

10.2.1　ColumnFixture

ColumnFixture是三种内建夹具中最简单的一个了。它会直接把夹具表格的列映射到基本夹具类的公有域（public field）和方法上，这样夹具类就能根据表格的内容执行简单的计算了。在讨论映射机制具体细节前，我们先来看看ColumnFixture表的结构。

ColumnFixture表格的第二行——就在夹具类名称的正下方——称为表头行。Fit会根据表头行中每列的值确定表中剩下的行中的数据该如何处理。如果表头行中的单元格的内容为left，Fit将会把本列其余行中的值设给对应夹具类的left 公有属性上。图10-5注解了图10-4中ColumnFixture表格的重要部分。

图10-5　图10-4中的ColumnFixture表格，其中表格结构各部分附上了解释

若表头行中的单元格的值为result()（注意括号），Fit会把这列作为期望值处理。这样对于每行的此列，Fit都会调用名为result的方法，然后把返回值与单元格中的期望值相比。由于这种域和方法的映射方式，ColumnFixture常用在相对简单的计算类型的测试中。这类测试主要侧重于验证各种不同输入对应的输出是否正确。

样例Fit文档中的夹具类CalculatorFixture是fit.ColumnFixture的子类。现在我们来深入细节，弄清楚Fit是如何将表中数据映射到夹具类的，以及是如何编写代码来操作待测系统的。图10-5中的CalculatorFixture表格的夹具类实现如代码清单10-1所示。

代码清单10-1　CalculatorFixture.java

public class CalculatorFixture extends fit.ColumnFixture {

public double left;     /*❶（以下3行）输入域*/

public double right;

public char operator;

public double result() {      // ❷产生输出的方法

Calculator calculator = Calculator.getInstance();     /*❸（以下10行）result()方法可以运行的所有输入项*/

switch (operator) {

case '+': return calculator.add(left, right);

case '-': return calculator.subtract(left, right);

case '*': return calculator.multiply(left, right);

case '/': return calculator.divide(left, right);

default:

throw new IllegalArgumentException(

"Unknown operator: " + operator);

}

}

}

在代码清单10-1中，我们可以看见三个公有实例变量，Fit将会把夹具表列中的值绑定到这些变量上❶。注意left和right列的内容会被转化成double类型。java.lang包中的绝大部分基本数据类型都可以直接转换，而剩余的则需要手工转换（不过这不是本书的内容）。

而以“( )”结尾的列，会被映射到没有任何参数的公有result方法上❷。Fit不关心方法返回值类型——在把行中每个值映射到实例变量上后，Fit会调用期望结果列中指定的方法，然后把实际返回值与夹具表中定义的期望返回值作对比。换言之，此方法可以访问当前行的所有输入项❸。

实现CalculatorFixture类（还有产品代码^1）后，Fit就可以运行测试文档来检测系统了。稍后我们会介绍运行Fit的方法。而现在，我们先跳过这一步，直接查看测试结果吧。图10-6中为Fit运行测试后产生的结果文档。

1 我们当然可以在没有产品代码时运行测试，只是那样毫无意义，因为夹具类没有测试任何东西或者不能编译。

在图10-6中的测试结果中，Fit把实际值与期望值不符的单元格背景涂成了粉红色（虽然书中图片是黑白的，但还是能通过灰度分辨出来），并把两个值用水平线分隔。显然，我们不小心把加法和乘法弄反了，太粗心了（我们更期望Fit能发现遗漏的功能，而非这种小编码错误）。

图10-6　Fit执行测试时，会把相关单元格涂成不同颜色以表示执行结果。如果结果与期望不符，期望值和真实值都会显示在单元格中

如果检测通过了，那么背景会是绿色。注意，其他单元格都会保持原样，因为它们并没有做任何检测工作，只是提供数据而已。

ColumnFixture就介绍到这吧。下面我们来讲下一个内建夹具，RowFixture。

10.2.2　RowFixture

RowFixture可以用来验证更复杂的查询操作的结果，通常用于验证系统在被前面的夹具操作过后的状态的正确性。例如我们可以用RowFixture验证计算器能记住所执行的最后5次运算。

正如ColumnFixture，要实现自己的验证逻辑，必须要继承自RowFixture。RowFixture和ColumnFixture的区别在于，RowFixture不会把列值映射到夹具类上。RowFixture主要用来对比夹具类查询方法的返回值与夹具表中的行的值是否一致。夹具表中的每一行都表示一个领域对象（domain object），夹具表中所有行组成的领域对象列表应与夹具查询方法返回的领域对象列表相等。Fit会通过反射对比列表中的每个对象。

下面我们编写一个测试来检测计算器的短期记忆。当查询运算记录时，Calculator对象应该可以返回夹具表中执行过的所有操作。

图10-7中为测试的夹具表。

图10-7　RowFixture可以很容易地对比大数据集。RowFixture会将表中各行表示的数据与夹具类查询方法所返回的对象作对比

注意，我们的测试所验证的是计算器只会记住最后5项运算。相应的夹具类很容易实现，见代码清单10-2。

代码清单10-2　验证计算器能正确记录运算历史

public class CalculatorHistoryFixture extends fit.RowFixture {

public Class getTargetClass() {

return Operation.class;     // 返回Operation对象

}

public Object[] query() throws Exception {     // Fit调用的查寻方法

java.util.List history =

Calculator.getInstance().getOperations();

return history.toArray(new Operation[history.size()]);

}

}

从代码清单10-2中可以看出，RowFixture宣称自己的目标类是Operation。在内部实现时，Fit会使用这个信息辨别需要对比的域及方法，以判断表中的对象列表与夹具的查询方法返回的对象列表的一致性。

代码清单10-3中为Operation类，用公有域表示列值。

代码清单10-3　query方法返回的领域对象

public class Operation {     /*（以下6行）领域对象类代表执行的运算*/

public int index;     // 定位到历史记录

public double left;

public double right;

public char operator;

}

因为尚未给历史记录长度加限制，RowFixture的匹配算法发现查询方法返回的对象列表与表内容不符，因此用粉红色背景标注了差别，见图10-8。

图10-8　通过比较夹具表中提供的数据与查询方法返回的对象序列，RowFixture 能找出并高亮期望数据与实际数据间的差别。任何多余或者缺失记录的背景都会被涂成粉红色，而匹配的记录会以绿色标识

图10-8中结果指出，RowFixture在夹具的查询方法所返回的对象列表中发现了一个多余的对象，这与期望结果不符。如果返回的列表遗漏了某项操作记录，那么我们会得到一个类似的结果报告，只不过会用missing（缺失）字样代替surplus（多余）。

可以看出RowFixture专门用于对比结果集，因此它与面向计算的ColumnFixture是一对好伙伴。不过，在使用RowFixture对比结果集时，我们应当知道夹具表的行以及查询方法返回的对象列表，都会先被排序再比较，所以对象的次序无关紧要。这是RowFixture的一个重要特点。在Fit的文档中并没有明确说明。若要检测顺序，那RowFixture就不适用了。

接下来是ActionFixture。

10.2.3　ActionFixture

比起其他内建夹具，ActionFixture更面向动作。它最初专为GUI测试而设计，不过后来人们试着用各种方式对其进行扩展，使其用途极其广泛；不过究其本质，ActionFixture还是需要针对某种抽象用户界面建模的。

在试着扩展ActionFixture之前，我们先来了解它提供的标准功能吧。

与ColumnFixture和RowFixture不同，ActionFixture不依赖于表头行。它要求表格每行的第一列都要包含一个关键字，即动作的名称。Fit会将这些名称映射到一个称为actor的对象上。actor对象通常就是ActionFixture自己，或者是其他能将动作映射到系统具体操作上的任何对象。有时actor对象甚至可以是待测系统的一部分，只要系统代码的方法名的抽象层次合适即可（也就是说，在用户角度看也合理）。

ActionFixture有四种内建动作：“开始”、“输入”、“点击”及“检查”。下面是各个动作的概述。

- 开始——类似于首次打开一个窗口。当ActionFixture遇见“开始”动作时，其会生成第二列中指定的类的实例，替代当前actor。然后所有的动作调用都会发生在新actor对象上。

- 输入——类似于往文本框中输入信息。当ActionFixture遇见“输入”动作时，其会调用当前actor的方法，方法名在输入关键字后的第二列中指定。第三列及后续各列为方法参数。

- 点击——类似于点击用户界面上的按钮。当遇见“点击”动作时，ActionFixture会调用无参方法，方法名在点击关键字后的第二列中指定。

- 检查——就像在说“比较这两个值吧，他们应该相同”。当遇见“检查”动作时，ActionFixture 会调用actor对象的无参方法，方法名在检查关键字后的第二列中指定。然后ActionFixture 会将方法返回值与第三列中的值作对比。

图10-9为这些动作的用法。图中的测试以命令方式调用了Calculator类，模拟用户点击计算器上的按钮，然后验证了特定输入产生的输出。

表格大体上描述了在Fit框架中如下的执行序列：

1. 创建出CalculatorActionFixture的实例，将其设置为当前actor；

2. 调用CalculatorActionFixture实例上的display方法，期望返回0；

3. 调用CalculatorActionFixture实例上的input方法，方法参数为1；

4. 再次调用display，方法返回值应该为1；

5. 调用CalculatorActionFixture实例上的plus方法；

6. 再次调用display方法，验证返回值仍然为1；

7. 调用CalculatorActionFixture实例上的input方法，参数为3；

8. 再次调用display方法，验证返回值应该为3；

9. 调用CalculatorActionFixture实例上的equals方法；

10. 最后一次调用display方法，验证返回值为4，这是最终结果了。

图10-9　ActionFixture，顾名思义，是面向动作的，尤其适合对交互行为进行建模。其支持“开始”、“检查”、“输入”和“点击”四个动作

在执行过程中，ActionFixture会将面向应用的显示、输入、加、等于等方法转化成待测系统的具体方法调用。例如，若我们能在Fit测试的进程中运行系统，就可以在夹具类的构造函数中创建出计算器的实例。依照这种方式，CalculatorActionFixture类的实现如代码清单10-4所示。

代码清单10-4　CalculatorActionFixture.java

public class CalculatorActionFixture extends fit.ActionFixture {

private CalculatorPresenter presenter;

public CalculatorActionFixture() {

actor = this;     // 与当前关联

presenter = new CalculatorPresenter(new Calculator());    // 获取待测系统信息

}

public double display() {

return presenter.display();     /*（以下11行）按代码方法所示执行*/

}

public void equals() {

presenter.evaluate();

}

public void input(double input) {

presenter.inputValue(input);

}

public void plus() {

presenter.plusClicked();

}

}

ActionFixture的四种内建动作足以测试很复杂的系统了。不过，我们的目的是让夹具类对特定的应用和领域越贴近越好。所以，即使ActionFixture的基本逻辑和关键字仍然可用，但是 “开始”、“输入”、“点击”和“检查”就不一定能完全满足我们的需要了。例如，我们可以扩展内建ActionFixture，覆盖其中某一部分，这样就能指定“验证显示了| X”而非“检查|显示 | X”，或者指定“加”而非“点击 | 加”了。

虽然纯粹从技术角度讲ColumnFixture、RowFixture及ActionFixture这三种内建夹具可以完成任何类型的测试，不过我们可不想局限在这几个夹具上。相反，我们希望能扩展这些夹具，针对自己的需求创建出自己的夹具——就像我们扩展那三种Calculator夹具所做的那样。

下面用一分钟时间看个例子。

10.2.4　扩展内建夹具

这三种内建夹具都提供了扩展方式，以解决测试中遇到的特定类型的问题。不过夹具提供的方法并不一定完全满足我们的需要，因此有时我们需要深入这些内建夹具的源代码寻找其他的扩展点。

1. 例子：为测试准备数据

例如，我们要用夹具往数据库中填充测试数据。用ColumnFixture可以解决问题：把要添加的数据填到夹具表中，让数据映射到夹具类的域上，然后再添加一个“add()”方法把数据写入数据库。“add()”唯一的用途是列中数据齐备后向数据库写数据，这种噪音列不应该存在于测试文档中。

我们也可以直接继承ColumnFixture，覆盖reset及execute方法来实现同样的功能。如代码清单10-5中的Java代码所示。

代码清单10-5　扩展ColumnFixture实现数据，创建数据填充夹具

public class HandmadeSetUpFixture extends fit.ColumnFixture {

public String name;     /*（以下2行）域收集填充数据*/

public String email;

@Override

public void reset() throws Exception {

name = null;      /*（以下2行）在每行前重设数据*/

email = null;

}

@Override

public void execute() throws Exception {

// ...     // 在execute()中填充数据

}

}

从代码清单10-5中可以看到，我们并没有提供填充数据的add方法，而是覆盖了两个方法（reset及execute）。这样既能实现功能，又不会给测试文档添加多余列。在夹具生命周期中恰当的时期，这两个方法会被调用：reset方法会在处理每行之前调用，而execute方法会在每行处理完成之后且Fit准备处理输出列时被调用（本例中没有输出列）。要把表中数据填入数据库，execute方法是极佳选择。

2. 额外的代码，更好的测试

上个例子中 execute方法的作用，与添加一个无参数且无返回值的方法，然后再添加一列来执行数据填充动作的作用完全相同。此外，我们还需要实现reset方法以清空上一列所填充的值，而在那个不够优美的方法中却不用这么做。不过比起夹具表可用性的提高及冗余列的移除，这些额外的工作量完全可以接受。

内建的夹具并没有照顾到所有常见的功能需要（或者没有我们想象的好用）。幸好Fit开放了源代码，因此很容易扩展。不过我们不需要亲手做这项工作，Auckland大学的教授Rick Mugridge对Fit进行了扩展，此扩展在Fit社区中广泛应用。

10.3　FitLibrary对内建夹具的扩展

承蒙Rick Mugridge和Ward Cunningham持续的贡献，FitLibrary项目包含了许多扩展和工具，让Fit的使用更加方便，也让验收测试的初学者更易上手了。虽然FitLibrary包含许多东西，但是我们只会集中介绍两种有代表性的夹具：DoFixture和SetUpFixture。

10.3.1　DoFixture

那些试图扩展ActionFixture，想提供与业务领域更接近的测试语言的人可能已经发现了，扩展ActionFixture并不容易。在处理表格结构方面，ActionFixture有些限制，这使测试语言只能接近而不能完全达到理想形式。DoFixture很好地解决了这个问题，因此若要构建流式夹具，它是极好的选择。

1. 用自然语言编写测试

DoFixture与ActionFixture有什么区别？首先，DoFixture可以处理混合在一起的关键字单元格和参数单元格，这使得测试可以以自然语言的形式书写。例如，把ActionFixture表格中的行：

与能把关键字和变量值混合在一起的DoFixture行进行对比：

语法上的差别也对支撑夹具表的Java代码产生了影响。以前的代码为：

public void transfer(float amount, String from, String to) {

...

}

而DoFixture的是：

public boolean transferDollarsFromTo(float amount, String from,

String to) {

...

}

DoFixture提取方法名的规则很简单：关键字列和参数列会混合在一起，通过camel-casing规则把关键字列组合在一起就能得到方法名。足够简单吧？

虽然在ActionFixture中，我们能使用更有意义的动作的名称，让支撑代码和DoFixture的一样可读，不过DoFixture的语法在可读性方面有巨大的优势，因此我认为它比ActionFixture更好用。

不过，这额外的灵活性和威力不是没有代价的。DoFixture的实现机制要求其必须是测试文档中的第一个夹具。不过这从来都不是一个问题，而且要构建出用自然语言书写的流式测试，这点代价根本微不足道。

2. 委托子夹具

DoFixture还可以把测试文档中的夹具拆分成多个表格。这是因为测试文档中所有表都会由初始的DoFixture处理，而不用在每个表上都作显示的夹具表声明。夹具表中的动作也能选择返回夹具对象，如果返回了新的夹具对象，那么Fit会用返回的新对象处理表格的余下部分。执行FitLibrary提供的SetUpFixture是这项功能的常见的用途之一。接下来我们会讨论与此相关的内容。

图10-10中为使用DoFixture的测试的例子。文档中包含各种各样的夹具表，演示了DoFixture的主要特性，包括两种不同类型的SetUpFixtures，表示动作的行，对系统状态的验证以及用混合的单元格语法写出自然语言形式的测试。

图10-10　使用自定义的DoFixture和ReviewsDoFixture的测试样例，演示了DoFixture的额外功能，与内建的ActionFixture作了对比。虽然文档包含多个夹具表，不过所有的表都由ReviewsDoFixture处理（由页面第一个表格指定）。除返回夹具的方法外，余下的表格和行都会映射到ReviewsDoFixture实例的方法上。若方法返回了夹具对象，则表格剩下的内容将会交由此夹具对象处理

图10-10中的第一个表格说明整个文档会由ReviewsDoFixture类（我们自定义的DoFixture类）处理。读入此内容之后，Fit会用ReviewsDoFixture处理页中所有表格。

下一个表格第一行为setup book data，无参数。Fit会把这个动作映射到ReviewsDoFixture类上的setupBookData方法。而setupBookData方法会返回一个SetUpFixture对象，用于为待测系统准备测试数据，见代码清单10-6。

代码清单10-6　DoFixture 返回了SetUpFixture，处理表格的余下部分

public class ReviewsDoFixture extends fit.DoFixture {

public Fixture setupBookData() throws Exception {

return new BookSetupFixture();

}

}

这段代码表明表格的余下行都会由BookSetupFixture处理（SetUpFixture的一个子类，下节将会讲到）。同样，下一个表格的第一个方法setup review data会映射到ReviewsDoFixture的另外一个方法上，因为BookSetupFixture类只负责处理一个表的内容。把那个方法也加到ReviewsDoFixture中，代码见代码清单10-7。

代码清单10-7　代码清单10-6中的DoFixture返回了两种不同的SetUpFixture

public class ReviewsDoFixture extends fit.DoFixture {

public Fixture setupBookData() throws Exception {

return new BookSetupFixture();

}

public Fixture setupReviewData() throws Exception {

return new ReviewSetupFixture();

}

}

继两个SetUpFixture表后，是一个与ActionFixture表类似的表，表中两个动作分别映射到了goToFrontPage及 textPresent(String)上。下面为对应的Java代码：

public class ReviewsDoFixture extends fit.DoFixture {

...

public boolean goToFrontPage() {

...

}

public boolean textPresent(String expected) {

...

}

}

等一下，表格的Java代码里为何没有ensure字样？这是因为ensure是DoFixture支持的特殊动作。这个动作告诉Fit，此方法的返回值应该为true。类似的关键字还有check，用于比较动作返回值和最后一列的值，而关键字reject会验证动作执行失败（返回false），等等。

剩下的几个表格有的再次使用了SetUpFixture往系统里添加评论数据，还有的调用了go to front page和text present动作对系统进行验证。最后一个表很好地展示了混合关键字和参数带来的可读性的提升。当然，在表之间添加描述性语言也可以进一步提高可读性。

DoFixture就介绍到这吧。下面我们来仔细研究上个例子中见到过的SetUpFixtures。

10.3.2　SetUpFixture

SetUpFixture，我们工具箱中另一个好用的工具，顾名思义，主要用来使准备数据的表看起来更直观——很像代码清单10-5中我们自己创建的数据填充夹具。下面让我们来分解上个例子中的setup review data表（见图10-11）。

图10-11　图10-8中的夹具表的第一行会创建并返回一个SetUpFixture对象，用于处理剩下的5行数据

表中的setup review data动作是DoFixture的一部分，这个动作会创建并返回ReviewSetupFixture。接下来，ReviewSetupFixture会从含有各列列名的行开始处理，用这些列名来确定解析后续各行的方法。在本例中，对于每一行，ReviewSetupFixture会读取行中的四个参数，并用这些参数调用名为titleEditionReviewDateReviewText的方法。

代码清单10-8为夹具表的Java代码。

代码清单10-8　SetUpFixture的例子

public class ReviewSetupFixture extends fit.SetUpFixture {

public ReviewSetupFixture() throws Exception {     /*（以下2行）确认数据格式*/

super.registerParseDelegate(

Date.class, new SimpleDateFormat("yyyy-MM-dd"));

deleteReviewData();     // 处理表格前删除当前数据

}

private void deleteReviewData() throws Exception {

// implementation omitted for brevity

}

public void titleEditionReviewDateReviewText(String title,     /*（以下5行）逐一处理创建表格中的行*/

int edition, Date date, String text) throws Exception {

// create a Review object from the arguments and

// persist it to the system under test

}

}

titleEditionReviewDateReviewText方法的参数除字符串外，还有整型和java.util.Date类型。Fit知道该如何将字符串转化为整数和其他类型的数字，但却不知道测试文档中用的日期格式；我们需要手动调用从SetUpFixture中继承的registerParseDelegate来处理日期。

在代码清单10-8的代码中，夹具在构造函数中删除了所有现有数据，然后用add方法（在本例中为titleEditionReviewDateReviewText方法）依次加入新数据。这是SetUpFixture应用的常见模式。

其实使用内建的ColumnFixture，再给夹具表添加一个add()列用于执行代码清单10-8中的titleEditionReviewDateReviewText操作，也可以实现同样的功能。若这么做，代码也能保持相对清晰，特别在add方法有大量参数时。不过此方法缺点在于要用额外的行控制测试数据的值和填充进数据库的时间。

10.3.3　还有更多功能

刚才介绍的两个夹具，DoFixture和SetUpFixture，并不是FitLibrary中仅有的工具。FitLibrary还提供了处理有序列表及无序列表的夹具，以及通过计算、检查约束和组合等多种途径验证业务逻辑的夹具。FitLibrary还支持基于文件系统的比较、图片及其他功能。

此外，FitLibrary还有一项重要功能尚未提及，即如何运行测试，下面将会讲到。

10.4　执行Fit测试

我们刚才看到了Fit框架内建的三种夹具，看到了夹具表及对应的夹具类实现，也看到了Fit运行测试后产生的输出结果。下面，我们来看如何运行测试，首先是基础知识。

运行Fit测试文档需要什么工件？首先当然需要测试文档，然后是文档引用的夹具类。另外还需要夹具类引用的产品代码和库，这样夹具类才能连上待测系统。如何准备这些工件取决于夹具类的实现语言。若用Java，那么在运行测试文档前需要先编译夹具类，而且需要把上面提到的所有工件都放在Fit执行的类路径（class path）下，除测试文档之外。

若测试文档不在类路径下，那么应该放哪儿呢？好问题。在讨论执行测试的各种方法前，我们应该先了解测试文档的存放形式。

如果客户期望能自己编写和更新测试文档，那么应当把文档放在一个方便客户访问的地方——例如共享网络文件夹。此外还可以使用基于Web的方法，例如Fitnesse。最简单的方法是把测试文档和源代码放在一起，即保存在配置管理工具里。只要客户能访问，这个方案就可行。

虽然我们以后可能采用某种更高级的方案，不过目前先用“Fit和在本地磁盘上的HTML文件”方式吧。我们用如下顺序介绍Fit的运行——首先是运行单个Fit测试文档，然后是运行整个测试文件夹，最后是把Fit测试整合于Apache Ant的构建脚本中。

10.4.1　单个测试文档

Fit中自带一个基本的测试运行类（test-runner class），名为fit.FileRunner。运行时将测试文档的路径和测试结果产生路径传给FileRunner即可。下面是在命令行上调用FileRunner的例子：

$ java –classpath fit.jar:classes:. fit.FileRunner \

src/story/tests/example.htm testresults/example.htm

运行这行命令后，stderr会输出如下执行结果：

$ java –classpath fit.jar;classes;. fit.FileRunner ...

23 right, 4 wrong, 0 ignored, 0 exceptions

这就是FileRunner产生的所有输出了——一个包含通过测试数目，失败测试数目，忽略测试数目和测试运行中抛出多少异常的结果报告。只是个简练的报告，结果文档包含所有细节。

FileRunner退出代码（exit code）和失败了的测试数目相同。如果没有任何测试失败，则退出代码为0；如果有5个测试失败，则退出代码为5；等等。这个特性在用shell脚本集成Fit测试时候用。

10.4.2　把所有测试放在一个目录结构中

很显然，一次只运行一个测试是不够的。虽然通常我们一次只做一个故事，一次也只运行一个Fit测试（这时候从IDE中或命令行中调用FileRunner就足够了），有时候也有可能需要以自动方式运行所有验收测试——或所有测试的子集——同时不需要任何手工干预。因此Rick Mugridge开发了FolderRunner，现在是FitLibrary中的一部分。

FolderRunner的用法和FileRunner很相似。唯一区别在于不再是传入单个测试文件和单个结果文档的路径，而是传入包含所有测试文件的文件夹路径，以及保存所有测试结果文档的文件夹路径，如下所示：

$ java –classpath fit.jar:fitlibrary.jar:classes:.

fit.FolderRunner src/story/tests/ testresults/

如果源文件夹包含一个测试文档的目录树，FolderRunner会在结果文件夹中创建完全一样的目录结构，为每个HTML测试文档生成对应的HTML结果文档。FolderRunner也会产生一个所有测试的总结报告，内容包括所有测试文档的结果。

FolderRunner也支持相对复杂的setup/teardown。我们可以将setup.htm和setup.html（或者setup.xls——FolderRunner也支持Excel表格)文档放到测试文件夹中，FolderRunner会执行本目录及所有上级目录中的setup文档。名称为teardown.htm、teardown.html和teardown.xls 的Teardown文档执行过程也一样。这样我们就能避免在测试文档中重复setup夹具了，可以集中注意力处理本质问题。

10.4.3　把测试整合进自动化测试中

我们当然可以从命令行执行Fit的测试运行类，我们也可以将这些命令写到shell脚本里，但是这些解决方法都不够完美。例如为多个平台，Windows和Linux等，维护这些脚本就很麻烦；而且每次添加新包或者库都编辑脚本，也很麻烦，否则就要用一些高级的脚本功能来遍历文件系统才能找到类路径。

当今Java项目多会采用现代构建工具，例如Apache Ant或Apache Maven（http://maven.apache.org），部分因为项目需要将Fit测试集成到自动化构建过程中去。虽然Fit中没有包含现成的运行Fit测试的Ant任务（Ant task），不过我们能很容易地在Ant中调用命令行工具来执行测试。见代码清单10-9。

代码清单10-9　用Ant构建脚本执行Fit

<project name="SampleProject" default="...">

...

<path id="myclasspath">     /*（以下4行）取决于Fit、FitLibrary和我们的夹具*/

<path location="classes" />

<fileset dir="lib" includes="*.jar" />

</path>

<target name="acceptance-test" depends="compile, unit-test">

<java classname="fit.runner.FolderRunner"      // 执行FolderRunner

classpathref="myclasspath">     // 取决于Fit、FitLibrary和我们的夹具

<arg line="src/fit-tests" />     /*（以下2行）指定源和结果目录*/

<arg line="reports/fit" />

</java>

</target>

</project>

Ant的优点在于，我们能利用其提供的强有力的工具来构建运行Fit必须的类路径，我们还可以利用操作文件系统的任务来创建缺失的输出文件夹，删除上次的测试运行结果，等等。

不过仅仅把Fit测试集成在自动化构建过程中是不够的。虽然在任务运行FolderRunner时，能根据退出代码来确定测试是否全部成功，但更重要的信息却都用颜色和错误信息标注在了测试文档中。将报告复制到Web服务器上是发布报告的常用方式。下列清单用Ant的附加FTP任务进行报告发布。

<ftp server="ftp.ourserver.com" port="21"

remotedir="/www/reports/acceptance"

userid="coder" password="java1">

<fileset dir="reports/fit">

<include name="**/*.html"/>

</fileset>

</ftp>

使用CruiseControl等持续集成服务器将报告传到网站上是另外一种发布测试报告的方法，不过这不是本书的内容。

Fit就先介绍到这了。在下一章中，我们将会学到关于Fit的更加复杂的内容，目前所学的已经足以开始下部分内容了。

10.5　小结

唷！真是个紧凑的Fit简介。大部分使用敏捷方法的软件项目都会选择Fit框架做验收测试。

首先我们讨论了Fit如何能够嵌入ATDD过程中，谈到了把一个用户故事或者用例转化成自动化验收测试的三个步骤：首先起草测试文档以及在文档中给出表格形式的例子，然后清理测试文档中的夹具表，最后实现支持夹具表。在了解了Fit如何用于测试驱动新功能后，我们转而学习了Fit框架本身。

Fit测试文档中可以含有叙述性文字及不同类型的可执行夹具表。Fit中含有三种内建的夹具类型，用于把测试表格绑定到待测系统上。这三个夹具分别是ColumnFixture、RowFixture以及ActionFixture，我们能够扩展这些夹具满足项目特定需求。

FitLibrary是Fit工具箱最重要的扩展之一。它提供了很多额外的夹具，我们可以在此基础上扩展。FitLibrary还提供了FolderRunner，利用这个类，我们可以从命令行执行测试，或者把测试执行集成到自动化构建过程中。使用FolderRunner，我们能把Fit测试组织到一个带有多层次的setup夹具及汇总信息的目录结构中。

这里介绍的只是Fit和FitLibrary的一小部分功能。若要深入了解，你可以去读Ward Cunningham和Rick Mugridge编写的Fit官方宝典_Fit for Developing Software _(Addison-Wesley, 2005)。FitLibrary和Fit的网站上（http://sourceforge.net/projects/fitlibrary及http://fit.c2.com）也有很多有用信息。

我们现在已经了解了Fit的核心概念，也知道了如何编写和自动化Fit测试。我们已经准备好在实际软件项目中使用Fit来提高团队之间及和客户的沟通效率了。不过验收测试不仅仅是选择工具和运行测试这么简单，如何将测试联系到待测系统上也是需要慎重考虑的。因此，下一章将会仔细探讨各种方法的优缺点。

第11章　执行验收测试的策略

画图有助于我们理解部件间复杂的关系。

——克里斯托弗·亚历山大，模式语言运动之父

现在，我们对验收测试已经有了大致的了解，也知道了敏捷或迭代开发团队中如何使用验收测试。同时大致了解了现在相当流行的验收测试工具——Fit框架。在此过程中，我们了解到了验收测试的技术实现，以及自动化测试过程中可能遇到的各种问题。在本章中，我们将会在技术层面集中讲解验收测试（即夹具类，若采用Fit框架）与待测系统的交互方式。

无论是手动测试还是用脚本测试，把系统当成一个整体，端到端地验证功能一直是QA的传统工作方式。这有一定的合理性，毕竟我们希望系统整体功能正常，而不仅是每个单独的部分运行正确。不过这世界不是非黑即白的。除在系统级别做测试之外，其他级别的测试也是有意义的。投入产出比是关键因素。

为了能正确认识其他测试方法，我们会讨论选择各种方法时需要考虑的因素、三种连接验收测试和待测系统的方式，以及一些支持更普遍方式的底层技术。了解了这些方法后，我们会深入其中一些技术，了解为何这些技术会影响到测试与待测系统的连接方式。最后，我们会回答一些自动化测试中的常见问题。

现在开始吧。

11.1　验收测试该检测什么

测试中该检测什么？这是我想到的第一个问题。我们想确认系统工作正常，没有问题，并且可以使用。那么在验收测试中究竟该验证什么呢？

有一句话说“要用最合适的工具完成手头工作”，测试时也是如此。我们不能忽略手动和视觉测试，因为计算机并不擅长对应用程序的可用性和美观性作出判断。或者说的更准确些，我们人类不善于告诉计算机什么样的东西看起来既直观又美观。相反，人不擅长做重复劳动，因此需要把绝大部分回归测试都变成自动化形式。计算机才是做这工作的最佳工具，换句话说，验收测试不必测试所有东西！

专业测试人员的重要性

无论我们如何强调测试自动化在保持软件开发效率上的重要性，但是没有任何自动化测试能比好的测试人员更聪明或更有发现问题的“第六感”。好的测试人员有能力想出各种场景或者操作顺序来找出系统的问题。不过，为了解放测试人员的创造力，我们必须减轻他们重复执行测试的负担。这正是自动化回归测试的目的。自动化回归测试能帮我们确认已经发现的问题不再出现，同时给人脑机会来找出新的、未发现的问题。

验收测试该检测什么？答案很简单：应该验证故事（或其他任何形式的媒介）描述的功能正确地实现了。有时候要测试算法，有时候要测试用户动作序列。取决你认为什么样的测试能证明用户故事实现的正确性，也就是要抓住问题本质。虽然端到端的功能测试很好，但也要考虑到代价。

要确定测试什么及如何测试，我们需要考虑以下几个问题：故事、波动性及技术。本章中，我们会详细讨论以上各点，下面从故事开始。

11.1.1　抓住问题本质

若要实现数字运算功能，相应的测试应该集中检测运算逻辑，而不是检查UI上能显示出正确的数字。数字是否能显示正确与计算结果是否正确没有直接关系，因此没有必要混为一谈。而且若把领域逻辑与表现形式混合在一起，会使改动代码变得更困难。

又比如我们要实现用Visa或MasterCard信用卡在网上商店付账的功能。我们需要检查应用程序能拒绝无效卡号，能验证数字，等等。如果验收测试要先登录系统，打开页面，填入无效数据，提交表单，然后再检查拒绝信息，那么验证过程就很繁琐了。我们并不关心这些细节，为何还要这样写测试呢？完全没道理。

而其他一些故事可能需要通过真实UI测试。例如某个用户故事提出：“如果用户的会话超时了，那么用户会被重定向到登录页面。”这种故事的测试应该直接操作真实页面来模拟端到端的操作。哪怕修改会话超时时间，让用户登录后立即超时而非等20分钟，通过操作Web浏览器访问页面来测试这类功能也是合理的。

根据故事的自身特征，能判断出测试中该验证的东西。不过还有其他因素需要考虑，例如波动性。

11.1.2　避免波动频繁界面

自动化测试的最大风险是其可能成为维护的噩梦。我们需要预先考虑验收测试操作的那一部分系统会有何种变化。我们可不想增加这种波动性带来的风险（这点又再次说明了领域逻辑不与表现形式混合的重要性）。

再拿信用卡验证的故事做例子。如果客户想要增加对American Express信用卡的支持呢？我们需要在测试中重复一遍页面导航步骤吗？即使导航过程已经被封装好了，只需调用一行代码即可，我也不会这么做，因为这些导航过程是应当尽力避免的噪音。

假使我们已经增加了对American Express的支持，现在三种信用卡都可用了。接下来，客户想重做整套结账流程。这时我们发现所有信用卡验证的测试都要更新，因为所有验证都是通过界面做的，而界面已经大变样了。如果直接检测验证逻辑而非通过UI，那么所需改动的只是检测结账时会对信用卡做验证的那一部分测试而已。我们可不希望因为一些无关的东西发生了变化而去改一大堆的验收测试。

软件开发中变化时常出现，波动性也不可避免。如果测试时没有选择好层次，那么波动性导致的问题会更加严重。因此在选择验收测试所操作的界面时，有必要考虑所选界面的波动性，同时要了解系统变化所带来的返工开销。不过变更所带来的开销并不是仅有的因素，我们还要考虑编写测试的难易程度，所以技术因素也不容忽视。

11.1.3　在技术障碍最小的地方越过

虽然技术只是达成目标的手段，不过技术带来的限制和biases也会决定我们达到目标的方式，因此在选择验收测试的方式时也要考虑技术因素。有时需要在技术障碍最小的地方越过，而有时需要攀的更高些，因为美丽的世界就在障碍的另一边。

首先要考虑技术带来的硬性限制。如果构建的软件运行在嵌入式系统上，那么则会受到操作系统功能的限制。如果软件用C语言编写，那么则会受到C语言、编译器和底层平台的限制。而若技术平台提供的入口点越多，待测系统提供的可选界面越多，那么实现测试的限制就越少。

当了解技术带来的硬性限制后，我们就可以评估哪种测试自动化方式更适合于当前技术了。例如在Java这种基于虚拟机的具有动态反射功能的平台下，侵入式的白盒技术比非侵入式的通过UI检测的技术更常用。而在那些采用本机UI组件的技术下，则更倾向于让操作UI控件及查询UI控件状态的操作变得更容易，而通过API层访问应用程序的内部功能要复杂得多。

虽然技术会影响我们采取的验证方式，但我们也有办法改变技术带来的影响。如果能使单元和模块层面的设计更可测以及能恰当的演进架构设计，就能有全新的方式访问系统，因此测试时就有更多选择了。例如，给应用程序构建一个清晰的service层把业务逻辑和基础设施service隔离在内部，再把service层暴露给测试，我们就可以绕过UI直接测试领域层面的功能了。

技术是在这个运算式中是核心变量，它在验收测试和待测系统之间的连接方式上有重要的影响。在稍后讨论具体类型的系统时我们会继续讨论这个问题。不过在这之前，先来了解连接待测系统的几种高层次的方式吧。

11.2　实现方式

有多少个团队，有多少种技术，差不多就有多少种测试系统的方式。不过就像在其他领域一样，模式就是从混乱中横空出世的。我们总结出了几种验收测试的类型，这几种类型基本涵盖了ATDD团队中使用的主要的测试方式。

为了沿袭给简单事物起花哨名字的传统，我们把这三种方式分别称为“端到端”、“绕过UI测试”和“直接测试内部逻辑”，这三种是高层次的方式。另外还有第四及第五种辅助方式，介绍完高层次方式后会做讨论。后两种是是辅助性方式，应该与前三种高层次方式配合使用。

下面首先讨论端到端方式。

11.2.1　端到端

在理想情况下，测试时应当把系统当作一个整体。换言之，测试系统与部署在产品环境中的系统应当一样。我们通过真实界面进行测试，无论这界面是GUI还是机器到机器的技术性界面。这种端到端的测试会调用到系统中的所有组件。虽然我们可能会直接读取数据库以验证系统行为的正确性，这种测试依旧是以黑盒方式端到端地测试系统，如图11-1所示。

图11-1　端到端测试。测试直接访问系统外部界面，完全不知道内部实现细节

用jWebUnit等类库为Web应用编写验收测试是端到端测试的一个典型例子。jWebUnit源代码中包含了一个Fit夹具类，这样在Fit中一样可以使用jWebUnit。我们可以继承jWebUnit的WebFixture类，在jWebUnit的API基础上构建自己的测试语言，见代码清单11-1。

代码清单11-1　端到端测试Web应用的夹具类样例

public class WebFixture extends

net.sourceforge.jwebunit.fit.WebFixture {

public boolean goToFrontPage() {

beginAt("/home");

return true;

}

public boolean logInAs(String username) {

page("/login");

enter("j_username", username);

enter("j_password", TestConfig.getPasswordFor(username));

submit();

tester.assertTextPresent("Welcome");

return true;

}

...

}

取决于采用的工具和类库，代码清单11-1这样连接待测系统的夹具通常都很容易实现。这类夹具的主要功能是把客户语言转换成操作Web应用的具体步骤。

1. 为什么要测试整个系统

在本节开始部分，我们曾提到“理想情况下，测试系统时应当把系统当作一个整体”。为什么是理想情况下？因为端到端的测试最能体现系统的真实状态。换言之，端到端的测试能最广泛地覆盖整个系统。没有技术背景的客户也能直观地理解这种测试方式，因为这种测试把系统当作一个黑盒，这与客户观察系统的角度一样。让测试验证用户访问的功能，直接操作系统，也能促进客户对系统的理解。

这种方式的优势是毋庸置疑的，不过也并不是没有缺点。端到端测试的最大的问题在于太脆弱，因为其依赖于UI，而UI比业务逻辑变化得更频繁，但这种频繁的变化并不应该影响所对应的业务逻辑的正确性。此外，编写这类测试也很费功夫，因为测试需要满足UI的需要，这会使我们偏离真正想测试的功能。

测试执行速度是另一个需要考虑的因素。

2. 用确定性换速度

比起绕过UI的测试，这种测试执行速度会慢几个数量级，另外在将待测系统当作黑盒方面也不十分严格。测试执行速度可不容忽视。在实现用户故事时，能快速及简单地运行相应验收测试十分必要，因为这类测试会被一遍遍执行直到全部通过。而且在转向下一项工作前，也有必要运行所有的验收测试。每一秒钟都很重要，因为大型项目通常都会有成百上千个验收测试。

若不能运行整个测试套件，那么我们就需要在“对整个系统正确性的确定性上”和“开发速度”之间做出权衡，如图11-2所示。

图11-2　运行所有测试来确认功能完全没问题，还是运行少一些的测试以快速得到回馈？我们需要在这两种方式间做出权衡

我们可以将完整的回馈交由持续集成服务器做，在后台运行完整的测试时，我们可以开始下一个故事。随着提出问题和得到答案之间时间的延长，提问的开销也会增加。每次集成时我们都能节约一点时间，同时开始下一项工作后发现上一项工作中的错误的机会也会增加，修复错误的开销也相应地增加了。

只要能解决端到端测试的脆弱和耗时的问题，这种测试方式能带来很多好处。认真做设计，毫无保留地重构，可以使这种测试的风险降到最低。不过有时一个略为不同的方式可以产生意想不到的效果。直接针对UI测试所带来的脆弱性和耗时性是端到端测试的最大问题；我们将这种方式称为绕过UI进行测试。

11.2.2　绕过UI进行测试

绕过UI进行测试表示透过系统的外壳（即UI）做测试。这种方法是指引入一个新的替代性的UI，即抽象UI，用于连接待测系统。这种方式解决了端到端方式的最大问题，正如上文提到的，这些问题都和直接通过UI进行测试有关。

1. 避免不必要的改动

让测试直接验证应用程序及领域逻辑而非UI概念，或者直接验证抽象UI而非具体的技术相关的UI。这样每次改动UI时，就不会有一堆测试代码需要维护了。当然，即使测试真实UI也可以尽量避免这类问题，但是如果绕过UI或者用概念 UI代替具体控件，问题就更容易解决了。

图11-3中的测试不仅会访问UI，还会深入系统直接验证抽象UI及内部API。

图11-3　除测试真实UI外，还可以测试内部API或者抽象UI

图11-3中，测试通过三种方式调用系统：外部UI、抽象UI和内部服务层。这三种方式是否都适用要取决于待测系统的架构。代码清单11-2中的代码演示了在测试weblog应用时，这几种方式在夹具类中的具体实现方式。

代码清单11-2　透过系统外壳做测试的例子

public class WeblogApplicationFixture extends fit.DoFixture {

...

public boolean subscribeForAnnouncements(String email) {

InitialContext ctx = new InitialContext(serverConfig);    /*❶（以下3行）避开UI直接与服务API联系*/

Subscriptions bean = (Subscriptions)

ctx.lookup("ejb/Subscriptions");

bean.subscribe(email);

}

...

public boolean postNewBlogEntry(String title, String content) {

BlogEntry entry = new BlogEntry();

entry.setTitle(title);

entry.setContent(content);

BloggingAPI api = applicationContext.getBloggingAPI();     /*❷（以下2行）在同一个JVM中访问API*/

api.saveEntry(entry);

}

...

public boolean undo() {

Command command = new UndoLastEdit();

applicationEventQueue.append(command);     // ❸通过内部事件队列API运行抽象UI

command.waitForCompletion();

}

}

我们来看看代码清单11-2中的代码如何透过系统外壳做测试的。第一个方法subscribeFor Announcements使用Java命名和目录接口（JNDI）在远程服务器上（即待测系统的运行机）执行查找操作❶。这种系统架构使运行在远程Java虚拟机（JVM）上的测试对系统内部API的访问变得很容易。

而第二个方法postNewBlogEntry直接用普通的Java API调用访问了内部的服务层❷。若测试和待测系统运行在同一个JVM上，这种方式很适用。

第三个方法undo没有访问内部服务层的API，而是访问了内部抽象UI的API。在这个例子中，真实UI会利用一个事件队列发送应用的命令，这些命令会触发应用的相应操作❸。在这种方式中我们首先创建了一个抽象命令对象，然后直接将其放入事件队列，而没有操作真实UI。

给提交按钮改名或者用下拉框代替单选组框等UI的改动，并不会影响到用这三种验收测试实现方法所编写的测试，因为这些测试是根据抽象UI或者底层业务逻辑编写的，而非控件和其他具体UI细节。

这种绕过UI的、用编程接口实现的测试，能帮我们在以后整个维护阶段保持开发的速度。除了低维护成本外，这种方式还有其他优势吗？答案是肯定的。

2. 易于实现

除了维护成本低外这种测试还易于实现。这个优点并不直观，因为我们已经习惯了以“我们能做什么”的方式认知系统。那么，在验收测试中不按照已经习惯的抽象层次进行测试，而是检测其他东西，会更简单一些吗？

人和计算机认知事物的方式是有差别的。人通常会通过视觉进行观察，而计算机只能识别0和1。为了能写出针对UI的足够健壮的测试，在验收测试中我们必须能操作界面。这样做可能会极大地降低生产力，也可能没有任何影响，取决于采用的UI技术，以及相应的测试工具是否齐备。

例如在Web应用中，我们也许要为动态产生的Web页面上的元素制定命名规则，否则我们如何能在测试中点击从数据库里读出的产品列表中的特定产品呢？我们当然可以直接选择第4个产品，但是一旦其他人修改了数据，那么这个测试就无效了。

现在我们来考虑同一个选择特定产品的测试，不过这次不通过Web界面而是通过API。我们再也不用各种试探的方式（例如正则表达式等）来确定要点击的链接了。我们只需要调用API读取出一个产品对象列表，在列表中找出特定产品并将其设为选择状态即可。编程语言表达能力很强大，这种操作很容易实现，也更健壮。

比起操作UI的端到端测试，针对编程接口的验收测试执行速度更快。绕过UI进行测试当然也有弊端，不是吗？下面来讨论这点。

3. 绕过UI的弊端

只测试核心功能是不错，但事无绝对。此方法的缺点在于并没有以真实的方式测试系统。这种测试跳过了UI——系统的重要部分，因此有必要在手动测试中弥补。众所周知，计算机不善于判断图形界面的质量，而手动测试如上下文驱动测试法或探索性测试法有时则擅长发现新问题。我们必须很清楚所选测试方式的优缺点及风险。

这种绕过UI的测试也可能会使无技术背景的客户感到困惑。要解决这个问题，我们可以保持测试的描述语言不变，虽然在实现层面上已经不再是通过用户界面了。此外，还可以创建一套领域专属语言，即使在测试中没有提到按钮、链接、滚动条及其他UI控件，用户一样可以理解。

这种测试方式对技术人员也是个挑战。当绕过UI做测试时，用户、客户和其他非技术人员就像来到了异域。在开发人员考虑测试实现方式时也会有同样的感觉。当把功能暴露在测试专用的接口上时，这种感觉最为强烈，因为我们很少会把内部功能暴露给一个以上的客户端调用^1。

1 这里的客户端是指UI和测试。——译者注

这种方式与我们长期以来形成的“严格封装”及“基于需要的信息做设计”等理念格格不入，不过它确实能带来一些好处。把领域逻辑同时暴露给UI和测试能促进优秀的，可测试的模块化设计。对于系统的关键部分，这点很重要。

生活在当今这个多样化的世界中，我们必须要问，还有其他方式吗？验收测试还可以继续改进吗？当然可以。有时候我们可以用更“纤细”的验收测试检测业务逻辑。这第三种方法称为直接测试内部逻辑。

11.2.3　直接测试内部逻辑

直接测试内部逻辑是指直接测试系统内部的业务逻辑或应用逻辑，不通过UI或者API。这可以看作是绕过UI进行测试的极端形式。这种验收测试适合验证单个用户故事的业务逻辑。

例如，用户可见的功能有单独的验收测试检测，例如测试“try有效输入”，“try缺失信息”，以及“try无效信息”等。除此之外，验证逻辑也可以由针对内部的验收测试进行精确的检测（和记录！），而不是通过用户操作进行间接检测。

图11-4中为绕过UI和外部API，直接检测系统内部业务逻辑的测试。下面我们来看看如何在Fit夹具类中实现这种测试。代码清单11-3中是一个检测信用卡验证逻辑的样例夹具类。

图11-4　验收测试也可以直接访问系统内部的业务逻辑，绕过外部界面或API

代码清单11-3　直接深入核心业务逻辑的Fit夹具类的例子

public class CreditCardValidationFixture extends fit.ColumnFixture {

public String cardNumber;

public boolean isValid() {

return "".equals(validationMessage());

}

public String validationMessage() {

try {

CreditCard card = new CreditCard(cardNumber);

return "";

} catch (ValidationException e) {

return e.getMessage();

}

}

}

代码清单11-3中的ColumnFixture绝对不是一个会调用整个系统，占用一半网络资源的测试。它相当简单和独立，就像一个单元测试！

这种方式的优势在于能精确地检测我们想测试的那部分代码，完全不涉及其他无关部分。当今企业应用的业务逻辑相当复杂，若能把业务逻辑隔离在几个精准的测试中是很有帮助的。剥去裹在业务规则外的无关部分，我们就能更清晰地表达测试了，这能帮助客户和开发人员发现测试中存在的问题。

单元测试和直接测试系统内部的验收测试有什么区别？首先，单元测试用实现系统的语言编写，而验收测试应该由客户领域的语言编写。虽然在验收测试中也可以测试单个或几个相关联的类，就像单元测试一样，不过还是有区别的。验收测试与单元测试的语义不同。单元测试是为开发人员编写的，而验收测试是为客户编写的，相应的测试语言也应该能反映出用户所关心的测试结果。

单元测试自身并不足以保证系统能正常工作，只测试系统内部的验收测试也一样。在实际开发中，我们会频繁使用端到端测试和绕过UI进行测试，只有在业务逻辑及其重要或客户非常关心的那一小部分上采用面向内部的测试策略。提到辅助方法，我们会在下面讨论能简化测试工作的替换无关组件的方法。

11.2.4　替换无关组件

替换无关组件是一项重要的技巧。把系统的一些非关键构件替换成测试桩，仿真器或者其他测试友好的实现，用与产品环境不同的配置来测试系统。用内存数据库代替产品数据库就是这种替换的一个常见例子。

被替换掉的那部分系统大都是关系数据库、第三方Web服务、目录服务等外部资源。图11-5中的系统替换掉了Web服务。

图11-5　把待测系统的无关组件替换成了测试友好的、执行速度快的测试桩

不过有时候我们也会替换掉待测系统重要的组成部分。测试执行速度缓慢，系统状态管理过于困难，或者某个组件无法访问（或许是由于未开发完毕，或者要花数百万资金才能买到），等等，都可能是这么做的原因。

正如直接测试内部逻辑一样，替换无关组件也是一个附加测试方式。如果系统从未使用过产品环境的数据库，或从未连接过真实的第三方支付服务，或者从未在集成了占用大量CPU或I/O资源组件的情况下做过压力测试，那么直接发布是很危险的。很显然我们有必要做这类集成测试。

大部分测试都可以在部分组件被替换的系统上执行，只有一小部分测试才有必要在各个构件都到位的配置环境下运行。从风险管理的角度，有一个支持替换组件的模块化的架构设计非常重要。我们期望系统的各部分集成之后在产品环境中仍能正常工作。一个简单的、专门为此目而做的设计会有很大帮助，因为我们可以据此分析出测试的整体覆盖率：“A和B做过集成测试，B和C也做过集成测试，这说明这3个构件能一起正常工作”。

有时候在验收测试中替换某个组件是因为技术上的限制。如果系统会频繁地读写磁盘，那么可以用高速的内存文件系统替换真实文件系统。低速I/O只是技术受限的一个例子。技术不仅会影响到何时或如何为验收测试替换部分系统组件，还会产生其他影响。

技术还能给测试创造其他条件，让我们实现验收测试时有更多的选择。J.B. Rainsberger把这种方式称为测试后门。

11.2.5　测试后门

有时利用后门是合理的。在计算机和验收测试的语境中，后门——测试后门，是一种替代性的接口。通过这个接口，验收测试等“局外人”可以窥见系统内部以做验证，或者都可以在之前的“室内设计”不能适应当前测试的情况下重新摆放某些“数字家具”。

现代的企业应用中，数据库是个典型的测试后门（见图11-6）。数据库是整体系统的一部分，但是它也有一个外部接口，这个接口对验收测试和系统余下部分同样可见。这意味着我们可以直接把测试数据写入数据库而不用调用待测系统的API。有时，甚至没有API让我们填充数据库，连接数据库是唯一选择。有时测试后门（例如数据库的测试后门）甚至是验证验收测试结果的唯一方式。

图11-6　在验收测试中使用测试后门

有时还会遇见这样的事情：“某项功能最初是测试后门，但后来演化成了系统的公有API，使应用的发展改变了方向”。例如某个后门专门为接受失败事务的消息而设计，但是可能会逐渐演化成后台监控及失败管理的功能，并且产品借此功能打败了同类竞争产品！

简言之，测试后门是为了克服技术限制而生，但是后来可能演化成某个重要功能，不再是一个小的专门为测试而存在的hack了。

说到技术，我们下面开始讨论些技术类的话题吧，以了解主流技术会如何影响验收测试的方式。

11.3　技术相关考虑

我们已经看到了，选择验收测试与待测系统的连接方式时技术因素不容忽视。技术有时会带来硬性限制，而有时几乎没有限制。技术也许不会带来任何问题，但有时会让事情变得很复杂。各种各样的问题大都与技术相关。在本节中，我们会把前面讨论过的各种测试实现方式与当今主流软件类型（类库、应用程序和分布式系统等）结合起来。

我们先从最简单的开始——开发人员构建应用程序时所使用的库。然后我们会介绍面向用户的应用程序，包括控制台及图形界面。最后，我们会提到网络的分布式系统及测试方式。

11.3.1　库

在构建软件时，使用库使我们不用从头编写所有代码。库由开发人员编写，用户也是开发人员，而且验收测试也很容易。

例如我们要开发一个XPath引擎，支持用XPath表达式从XML文档中提取信息。有了这个引擎，用户就不用手动搜索整个DOM树，也不用使用令人费解的正则表达式了。用户故事中都是技术术语，因为客户就是开发人员。例如故事可以是“读取某个XML元素中特定属性的值”。对于这种故事，我们的验收测试中应该检测“读取下列XML的root元素的attr属性时应该返回值value”。

<root attr="value" />

这种测试该如何实现？我们应该通过UI测试，还是绕过UI测试？我们应该直接操作系统内部吗？测试库时能选择的方式不多，因此很容易作出决定。实际上测试库时我们总是在绕过UI进行的，因为库根本就没有UI，而且我们也是在做端到端测试，因为公共编程接口就是我们的UI。我们真正需要考虑的是，若库中某些逻辑没有直接通过接口暴露，也需要做测试吗？

上面提到的测试对应的Java代码如下：

public class XPathTest {

@Test

public void retrievingValueOfAttributeFromEmptyRootElement()

throws Exception {

Document doc = parse("<root attr=\"value\" />");

assertEquals("value",

XPathEngine.valueOf("root/@attr", doc));

}

}

这个验收测试的抽象层次合适吗？你怎么想？可以重构这个测试以提高可读性吗？你肯定会有些想法，不过你想过换掉JUnit，使用某些不太“技术化”的框架吗？在构建编程库时，使用Fit这种表格类的测试工具合适吗？也许可以，也许不可以。我相信大部分团队都会选择JUnit，然后把单元测试和验收测试合并在一起。不过也有的团队会选择以更可读的方式编写测试，而不会使用充斥着转移符号和换行符的Java代码。

那么该替换吗？做验收测试时替换部分系统可以吗？如果合理，为什么不呢？例如，如果在验收测试中把系统时钟部分替换掉，或者使用写内存而非写磁盘的日志组件，那么事情就会更容易些了。

编程库就讲这么多吧。下面我们会讨论大些的东西——有网络接口的分布式应用程序。

11.3.2　无界面的分布式系统

当今大部分软件都会有网络通信。有各种各样的Web应用通过HTTP协议提供静态或动态的内容。Web服务也会利用SOAP协议等为其他系统提供服务。ERP系统会通过通信协议和API等向外持续发布更新信息。这些不同形式的接口都需要有测试保证其正确性，这比测试简单的编程库更复杂。问题是，这些额外的复杂性对验收测试的实现方式有什么影响？

首先，虽然分布式系统比普通编程库更复杂，而且大部分的复杂性都在网络部分，但是我们最关心的还是核心功能，而非功能的访问方式。这样，大部分测试都应该绕过外部界面，访问内部API，而不是直接访问网络socket。只有在为架构做整体检查或验证两端能用网络库正常通信的前提下才做端到端测试，见图11-7。

图11-7　比起直接调用Java API，通过网络调用的速度会慢很多，因此无界面系统的大多数验收测试可以直接访问内部API，只让一小部分测试验证网络API能正确处理输入也能给出对应的输出即可

绝大部分机器对机器的接口都容易做自动化测试，因为这些接口就是为调用而设计的，不需要用高级的模式识别或人工智能来模拟人类用户操作来进行测试。还有一部分原因是这种机器对机器的接口通常都提供相对单一的服务。由于其相对简单且易于自动化，端到端验收测试的成本并不高。

专有的封闭接口是个例外，因为我们首先要通过反向工程破解通信协议，或者调用客户端API来实现测试。甚至有可能要通过客户端应用程序的界面来间接地测试接口！幸好，即使验收测试占用了所有网络资源也没关系。在上述各种罕见的情况下，绕过UI测试也不失是个好选择。

如果机器对机器接口的特征使得无界面的分布式系统易于自动化及做端到端的验收测试，那么带UI的应用会怎么样？我们先从控制台应用这种UI应用的鼻祖开始吧。

11.3.3　控制台应用

控制台应用（也称为命令行界面，缩写CLI），是当今最简单的UI应用了。FTP就是一个大家都熟知的例子。当今几乎所有的操作系统都包含FTP的命令行客户端。

大部分的命令行FTP客户端都会有一个交互式的基于文字的UI。应用会提示用户输入命令，然后会处理输入的命令，执行完毕后会提示输入新的命令。代码清单11-4中为用户和FTP客户间交互的例子。

代码清单11-4　CLI应用交互的例子

$ ftp ftp.myserver.com

Connected to ftp.myserver.com

220 ProFTPD 1.3.0rc2 Server [123.123.123.123]

User (ftp.myserver.com:(none)): lasse

331 Password required for lasse.

Password: *****

230 User lasse logged in.

ftp> help

Commands may be abbreviated.  Commands are:

!               delete          literal          prompt      send

?               debug           ls               put         status

append          dir             mdelete          pwd         trace

ascii           disconnect      mdir             quit        type

bell            get             mget             quote       user

binary          glob            mkdir            recv        verbose

bye             hash            mls              remotehelp

cd              help            mput             rename

close           lcd             open             rmdir

ftp> quit

Bye.

$

在代码清单11-4中，我们在操作系统的shell（或控制台）中启动了应用程序，在提示后输入认证信息，然后执行help命令，最后执行quit命令退出系统。

这类应用的大部分功能都能映射到各个命令上。例如，假设我们要构建一个世界级的基于命令行的FTP客户端，我们可能会有一个故事来描述向服务器上传本地文件的功能，当然也有相应的验收测试。可以用put作为上传指令，指令参数为本地文件路径。同样，我们也可能会有验收测试来处理出错情形，例如参数所指的本地文件不存在时应该在屏幕上显示出错误描述信息等。这种验收测试该采取什么方式实现？

端到端测试相对容易实现，因为UI是一个交互性的、会提示ASCII字符的界面。通过这个界面我们能很容易地输入信息，也可以解析应用的响应。这种UI给应用程序附加的语义负担也很小，因为上传文件到服务器这类操作通常都可以映射到单个的命令上。

即使UI很薄，我们也一样可以绕过UI直接用API实现“上传本地文件”的验收测试。如果表现层和应用逻辑隔离的好，那么这项任务会很容易。如果这样做测试则还需要单独测试UI，考虑到实现测试以及日后由系统变化而带来的维护等成本，这种方式不一定比端到端测试的工作量小。

同样，某些系统功能需要直接测试，深入内部进行验证，而非从外部操作角度间接地验证。此外，我们也可能要替换部分系统以验证系统行为，例如验证上传文件时网络连接突然丢失等错误情形下系统的行为。不过大部分情况下，我赞成采用端到端测试验证控制台应用。

控制台应用的情况基本如此，那么拥有华丽图形界面的应用会怎样呢？

11.3.4　GUI应用

所谓GUI应用就是像Microsoft Outlook、Adobe Photoshop和Mozilla Firefox这样的富客户端桌面应用程序。幸好，绝大部分GUI应用的规模都不会像Outlook或Photoshop那样大。即便如此，其复杂度也不容小觑。说到复杂性，在各种UI应用中，桌面GUI应用的UI逻辑是最复杂的，因此为其编写自动化测试也最困难。

1. 语义复杂度

语义复杂度很高，并不只是因为这种应用包含无数的自定义控件，还因为这种应用通常是由事件驱动的，所有功能都在后台运行。“往输入框中输入一些文本，点击按钮，然后再验证文件写入了磁盘或者状态栏上的错误信息被移除掉”，这种方式已经不足以验证此种应用了，因为我们根本不知道动作会何时发生。因此，在做验证前，我们必须要等待一定时长。这不仅会让测试变慢，还会使测试变得脆弱。然而GUI应用的多线程本质，以及做验证前需要等待一定时长等并不是端到端测试的主要障碍。在大多数情况下，GUI验收测试的最大挑战是如何定位及操纵UI控件。例如，Java Swing应用只会向操作系统和其他应用程序暴露GUI的顶部框架及标题，其他信息一概不可见。

下面我们来介绍几种方式以应对验收测试中遇到的语义复杂度。先从端到端方式开始，然后会介绍绕过UI测试方式。

2. 触发操作系统层面的事件

桌面GUI应用的技术性 UI通常就是键盘和鼠标。若要编写端到端的验收测试，该如何模拟用户输入呢？我们可以模拟真实用户所触发的UI事件。操作系统提供了本机API，可以发送原生的操作系统的事件（raw operating system-level events），但发送事件时必须在应用窗口中精确地定位坐标。这样我们实际上是在盲目地测试，轻信硬编码的坐标值是正确的。

实际上基于坐标的自动化测试是一个泥潭，我不推荐这种方式。这种测试很脆弱，维护也很困难。另外，在Java Swing应用中，若要验证系统的状态必须要使用Jemmy或Abbot等工具来查询内部控件状态（与第8章中的内容相似）。这是因为Swing控件对Windows系统不可见——除非专门为测试而添加日志代码，通过解析日志文件来验证执行结果。这种方法听起来不怎么样，实际也确实不怎么样。总而言之，用这种方式实现验收测试是下策。

倘若我们可以在另一个进程中访问到应用程序的所有UI控件，那么测试可以怎么写呢？

3. 使用原生API

某些使用原生控件（native widget）来绘制文本框、按钮等元素的GUI技术使得端到端测试应用程序成为可能。我们可以编写代码在应用程序的窗口中用名字或者类型来查找控件，可以给控件发送事件（例如“点击左键”），还可以查询控件的状态（“文本框当前内容是什么？”，“那个按钮可用吗？”）。

利用这种方式，我们可以把应用程序当作一个黑盒来测试，而不用侵入系统内部。这种方式的缺点也正在于此，我们无法操纵系统内部。如果我们需要用原生API操纵控件，同时还需要利用自定义钩子做进程间通信以方便测试，那么这种方式就略显麻烦了。

我个人认为这种黑盒测试方式并不适用于GUI应用。首先，在绝大部分情况下，我们必须能（或者喜欢）操纵系统内部。其次，根据当前UI上存在的元素来表达测试会有局限性，因为我们是在开发过程中编写测试，而非开发完成之后。

既然提起来了，下面我们就来讨论在应用中暴露钩子这一思路吧。

4. 应用中暴露钩子

之所以要在应用中暴露钩子是为了让测试——在单独进程中运行的测试——定位，操作以及查询UI的特定部分、应用逻辑以及应用当前状态。利用程序暴露的钩子做测试可以验证通过UI不能检测的部分。这实际上是一种绕过UI测试或测试后门。

不过从技术层面讲，钩子到底是什么？在我曾参与的一个项目中，应用程序开启了远程方法调用（Remote Method Invocation，RMI）服务，然后把测试接口的一个实现注册成了RMI对象，这样Java Swing的整个控件树都能在外部访问了。有了这种机制，我们就能通过RMI连接访问运行中的应用程序，然后从控件树中拿到任何需要的信息。

不过暴露钩子并不意味着一定要提供底层访问。在一个能够远程访问的接口上暴露一些操作、验证操作等也可以。这种钩子能帮我们在恰当的抽象层次，即在对客户有价值的功能层面上编写测试，而不会纠缠在UI组件名称等实现细节上。

暴露钩子的缺点在于工作量大。要实现远程访问应用控件和内部状态的功能需要很大投入，特别是对小规模项目。此外把测试实现拆分到两处——测试代码和钩子——会使执行速度变慢。这点引出了一个新问题——我们需要把两者拆分开吗？其实是可以把测试代码和应用程序放在同一个进程里面的。

5. 合并测试和应用程序

我们可以把各种测试方式想像成一条路，这条路从纯黑盒测试开始，渐变成灰色，终点是纯白盒方式。在应用中暴露钩子（无论钩子使用了哪种API操纵GUI控件）而非通过真实UI测试时，我们就已经从端到端测试转向绕过UI测试了。除了把测试代码嵌到应用程序内部这种方法外，难道还有什么更简单直接的方式进行绕过UI的测试吗？

要将测试代码和应用程序代码放在一起，有两种方式。我们可以将测试嵌入到应用程序中，也可以把应用程序嵌入到测试中。

后一种方式，即让测试运行应用程序（如图11-8所示），或许更适合自动化测试的环境，因为测试已经是自动化构件过程的一部分了，此外开发工具也很好的集成了主流的测试框架。总而言之，执行测试并在测试中执行待测应用程序很容易，然而如果我们先启动应用程序，然后执行测试并收集测试结果，我们会做很多工作。

图11-8　在测试中启动的应用程序

而有时，第一种方式更适用，即把测试嵌入到应用程序中，然后让测试在应用程序内部执行，见图11-9。

图11-9　通过插件机制嵌入到应用程序中的测试

在最近的一个Java WebStart 应用中我们采用了这种方式。Java WebStart应用实际上是个Swing程序，不过是由特殊的客户端启动的。此客户端读取服务器上的配置文件，并根据配置文件从服务端下载所需的资源及运行时（runtime）。因此，我们可以用测试Swing的库来测试这种应用。

不过，因为WebStart应用的部署配置文件（主要是安全设置部分）可能存在一些潜在的问题，上个版本曾出现过问题，因此我们想通过真实的WebStart来运行整个应用。我们将一个测试模块嵌入了应用程序，在应用外部可以调用此模块，测试运行完后会返回测试结果。这种方式相对麻烦，不过却能解决问题。

当然，系统的有些功能适合直接从内部做测试，我们也可能需要在测试中替换部分系统。嵌入式测试能很好地支持以上各种方式，因为我们通常会用单元测试框架实现此类测试，并且实现系统的语言与编写测试所用的是同一种语言。

这种方式的缺陷在于非技术人员，例如用户和客户会不习惯这种方式。若能把验收测试同时当作测试和与客户沟通的媒介，那么比这种方式会好的多，因为这种方式要么效率不高，要么不能很好地与客户沟通。

桌面应用就讨论到这里吧。还有一种常见的带有图形界面的应用程序我们尚未提及——Web应用。下面我们来看看如何在Web技术下做验收测试吧。

11.3.5　Web应用

Web应用恐怕是企业应用中最重要的一种了。在当今的e时代中，几乎所有开发人员都想要掌握这项技能。现代的Web应用开发，不仅要面对Web的分布式计算的特征，以及请求-应答形式的HTTP协议，还要处理各种各样的标准，以及所谓标准的差别各异的实现。Web浏览器对Web标准（特别是新标准）的处理也不尽相同，这对验收测试的实现方式也有影响，使基于真实浏览器的端到端测试更为重要。总之，事情变得更复杂了。

我们可以直接访问系统内部来检测各种细节，就像我们测试其他类型的应用一样。我们也可以替换难以掌控的组件或资源。不过为Web应用做验收测试，最大的问题还是在于测试方式的选择：应该通过UI检测，还是要绕过UI？如果选择了端到端测试，那么在测试中应该模拟Web浏览器，还是控制真实浏览器？

1. 通过UI或绕过UI

通过UI做测试之所以对Web应用很重要是因为表现层、HTTP会话、JavaScript等Web 基础结构在应用程序行为的正确性方面有很大影响。如果让验收测试直接通过UI来验证系统行为，那么至少可以发现表现层可能存在的问题——包括浏览器兼容性问题等。

端到端测试是有一些好处，但是通过UI测试会增加工作量也会使测试体积膨胀，这样会导致测试的脆弱性增加。此外，测试构建在Java EE或.NET平台上的Web应用对测试执行速度也有影响，因为启动应用服务器及部署应用也会消耗很多时间。

绕过UI的测试方式可以部分解决测试脆弱和运行缓慢的问题。虽然使用远程技术来绕过UI进行测试很符合直觉，但是让应用和测试运行在同一个进程中，抛弃应用服务器，是个更好的选择。例如我们可以让验收测试直接访问Web设施后面的service层，直接绕过应用服务器的启动及应用的部署工作。

现在我们已经了解到了端到端形式的优缺点。假设我们打算采用这种方式，那么下一步要做出的选择是，我们该在测试中模拟Web浏览器还是控制真实的浏览器？

2. 模拟浏览器还是控制浏览器

是该模拟浏览器还是控制浏览器，主要取决于模拟库（例如Java平台的jWebUnit或Python的Twill）是否支持Javascript及其他技术。不过大部分库只部分支持或完全不支持这些技术。

这些模拟库会向待测Web应用发送HTTP请求，然后解析返回的HTML页面，生成页面元素的对象层级结构供测试操作。这种库拥有真实Web浏览器的所有功能，除了渲染之外（因为不需要显示页面）。这也正是这种库不能完全替代真实浏览器的原因。

代码清单11-5中的验收测试用JUnit和jWebUnit库编写。测试中连接了远程Web服务器，用HTTP协议交互，但是并没有使用真实的Web浏览器。

代码清单11-5　利用jWebUnit库测试Manning网站的例子

import net.sourceforge.jwebunit.junit.WebTestCase;

import org.junit.Before;

import org.junit.Test;

public class TestWebApplicationWithJWebUnit extends WebTestCase {

@Before

protected void setUp() throws Exception {

getTestContext().setBaseUrl("http://www.manning.com/");

setScriptingEnabled(false);

}

@Test

public void testManningWebsiteWithJWebUnit() throws Exception {

beginAt("/");

assertLinkPresentWithText("Ordering Info");

clickLinkWithText("Ordering Info");

String[] paymentOptions = { "Visa", "MasterCard",

"American Express",

"Discover Card",

"eCheck", "PayPal" };

for (String option : paymentOptions) {

assertTextPresent(option);

}

}

}

模拟库专为测试设计，因此内建了许多验证及操作。库提供的API足以满足我们的绝大部分需要，但我们也可以自行添加任何想要的功能。

这类模拟工具给我们编写测试提供了方便，但是其并不适用于所有应用，特别是那些大量应用了Web技术高级功能（如JavaScript）的应用程序。例如Ajax技术就会使jWebUnit头疼。我们能直接操控真实的Web浏览器以避免这类问题吗？某种程度上可以，虽然这种方式并不完美。

操控Internet Explorer、Mozilla Firefox或Safari等Web浏览器的前提是浏览器暴露出了足够的API，能满足测试的需要。

幸好，Windows平台上的Internet Explorer利用COM组件模型提供了此类功能。Internet Explorer会在Windows平台中注册COM组件，通过获取COM组件的实例，我们可以启动Internet Explorer。同时我们也可以调用COM接口上暴露的方法来操作Internet Explorer。其他工具，例如Watir（Ruby）、jWatir（Java）、Pamie（Python）及Samie（Perl）也都是基于此类方式的。

不过在编写本书时，并没有一个能够跨平台地支持目前所有主流开发平台（Windows、Linux及Mac OS X）的解决方案。例如在Mac OS X平台下，SafariWatir（Watir的扩展，用于控制Safari；Safari是Mac OS X上的默认浏览器）是能控制浏览器的唯一库。图11-10为Safariwatir扩展在操控Safari Web浏览器。

Mozilla项目中引入了XPCOM技术，和Microsoft的COM类似，支持跨平台操作，但是现在还不够成熟，不可能大规模应用。还有一个插件，专门为基于Mozilla的浏览器设计，其支持外部过程通过Telnet来控制浏览器；但是这个插件只支持Windows操作系统。

图11-10　一段控制Mac OS X操作系统上的Safari Web浏览器的Ruby脚本

Selenium在跨平台方面处于领先地位，不过在编写本书时，其报表功能和与构建集成的能力还有待提高。此外，我们编写Selenium测试时与客户的协作也不很顺畅，不过我相信这些问题都会逐渐解决。

执行速度是控制真实Web浏览器的另一个问题——由此我们又给测试增加了新变数，另外测试库和浏览器间的通信也可能很缓慢。此外，运行每个测试时我们都希望能新开一个会话，因此可能要重新启动浏览器，这又会增加测试运行时间。如果这些对你都不是问题，那么控制真实的浏览器是一种很好的端到端验收测试的实现方式。

总之，为Web应用选择验收测试的实现方式，并不是件容易的事。当然为任何带有UI的应用做选择都不容易。虽然技术对我们的选择起决定作用，但是团队成员的喜好及技术背景等因素也不容忽视。毕竟软件开发是人的活动，促进协作的验收测试自己并不能解决任何问题。

在结束本章之前，我们或许该讲解一些测试自动化时的常见问题，无论是否采用测试先行的方式，这些问题都有可能出现。

11.4　常见问题的处理技巧

给复杂的系统做验收测试并不是件简单的事情。许多企业应用会处理海量数据，做多变量的复杂计算，还要处理各种规则的特例。我曾见到过一个项目的测试代码和系统本身一样复杂，因为没有像对待产品代码那样重构和设计验收测试代码。

过于复杂的验收测试并不是我见到的唯一问题。性能，即测试执行的时间，通常会给许多团队带来问题。我们的一个客户运行一次构建需要8小时，大部分时间都耗在了成百个端到端测试上。这样，某项功能被破坏后需要一整个工作日才能发现。

很显然，有许多问题都要注意避免。下一节就是帮你提早发现问题，这样就有充足的时间想出对策了。首先我们先来讨论提高测试运行速度的技巧。

11.4.1　加快测试执行速度

在采用验收测试的团队中，测试执行缓慢是最常见的问题。不仅测试运行时间长，测试运行前的准备及测试运行间的状态恢复也很耗时。

有次在我抱怨工作太忙时，同事的一句话提醒了我。他的建议是“少干些”，少干些确实也是提高测试执行速度的关键。下面我们来看各种能帮我们少干些的技巧。

1. 组织测试

如果测试中有许多初始化工作，那么你首先应该去检查测试，看看是否所有的测试都真的需要这么多初始化。通常你能发现有一部分测试可以少做初始化工作的，因此也会变快。

另一种能显著减少测试运行时间的方式是，把测试套件分成两组，一组有副作用，另一组没有。例如，验证业务逻辑和校验规则的测试通常不会写数据库，因此这些测试可以归到“无副作用”类中。我们可以先执行这类测试，然后再执行需要刷新数据库的测试，如图11-11所示。

图11-11　无副作用的测试可以连续执行，这类测试之间不用重复初始化数据。有副作用的测试可以正常执行，每个测试之间都会做初始化工作

另外，通过检测系统内部边界也能给测试分类。例如，如果某些测试的功能只会访问客户信息相关的表，那么就没有必要往产品表中添加数据了。

还有一种方法可以加速数据库和持久化相关的测试。我称之为持久化中的偷工。

2. 持久化中的“偷工”

磁盘I/O是导致执行效率底下的常见原因。关系数据库和其他持久化方式会大量访问磁盘，若使用的持久化方式能少做I/O操作测试就能快不少。

当然，我们可以把数据库切换到内存模式（持久化中的偷工是指我们并不把任何数据写入磁盘，因此不会保存任何东西），或者调整配置使其少访问数据库。

另一个更可靠的方式是把工作分配执行。下面我们就来讨论分布式工作。

3. 分布式执行

分派任务也能使你自己少干些。要提升验收测试的执行速度，我们可以把测试分配到并行执行的多个计算机上执行。这样每个机器工作量就都减少了，每个机器只用几分之一的时间就能完成任务。

需要注意分布式执行的优势在于有更多的磁盘I/O和CPU资源供测试运行。通常并不是测试本身——而是待测系统用去了所有资源。因此，若分布式测试集群中没有增加运行的待测系统的数量，那就没有意义了。通常我们会有如下几种方式分派任务：

- 一对多

- 多对多

- 多对更多

下面我们对这三种方式的优缺点做个简短的分析，先从一对多（见图11-12）开始。

图11-12　单个测试在多个系统间做负载均衡

在一对多的情况下，测试客户端会在多个目标系统间做测试运行的负载均衡。这种方式的优势在于当一个测试由于等待系统响应而阻塞时，另外一个测试可以继续执行。实现这种方式只需要一个多线程的测试运行机即可，只要瓶颈在于待测系统而非其他原因，例如通信开销或测试解析系统响应的时间开销等。

另外一个相对简单的方式是多个测试客户端每个都有自己专用的系统，如图11-13所示。这只不过是让多个计算机并行执行测试，然后把任务平均分配到每个测试客户端而已。或者也可以引入一个协调测试运行机，专门在其他测试客户端执行完测试后分配新测试。若采用这种方式，那么协调测试运行机还需要能收集测试执行结果及生成综合报告。

图11-13　多个测试客户端在专有的系统上执行各自的测试子集

如果一对多和多对多方式都不能把测试时间缩减到可接受的程度，那么你可以考虑采用第三种方式，即把前两方式结合起来：每个测试客户端独占几个系统，然后在这几个系统间做负载均衡，如图11-14所示。

图11-14　多个测试客户端在各自的多系统间做负载均衡

有了多个测试客户端及更多的系统的方式，我们就可以向任何方向扩展测试场了。我们可以增加运行的待测系统的数目直到测试客户端变成瓶颈。在这时，我们可以添加新的测试客户端，让测试客户端的数目和待测系统的数目达到一定比例，使得测试客户端不再是瓶颈，等等。这种方式的缺点在于其复杂性明显比前两种方式更高。

说到复杂性，下面的几种方式是用系统复杂性交换测试速度的提升。

4. 用依赖型测试最小化初始化工作

在一些企业应用系统的开发过程中，每个测试执行前的初始化工作会耗费不少时间。初始化时通常会填充数据库或者启停服务器。我们可以只执行一次这类工作。比如我们可以在执行整个测试套件之前填充数据库，如图11-15所示。

图11-15　使用有序的、有依赖关系的测试，我们可以只在测试套件运行前执行初始化工作。不过每个测试都要清楚前面的测试产生的副作用

不过这样会使测试之间产生依赖，因为测试会改变系统的内部状态。例如测试T1也许会验证网店的购物筐能正确处理交易，把订单信息写入数据库中。如果测试T2要验证销售统计的正确性，那么T1和T2之间就会有依赖了，因为若改变T1，T2也会受到影响。

虽然测试执行速度变快了，但是却使测试相互依赖了。随着测试数目（及依赖）的增加，维护也会更加困难。为了添加一个测试需要修改许多其他测试，没人喜欢这么干。

结果报告是依赖型测试的另外一个问题。因为测试之间存在依赖，一个真正失败的测试可能会导致其他有依赖关系的测试也失败，即使那些测试并没有任何问题，见图11-16所示。这样我们的测试报告就会包含很多无用信息，比只包含真实错误的报告低效得多。

图11-16　若相互依赖的测试失败了，必须要仔细分析测试日志，否则没法区分开真正失败的测试和受依赖关系影响而失败的测试

在依赖型测试中，若某个测试失败了，我们可以把后续的依赖于这个测试的所有测试都标为成功，无论后续测试是否失败。如此我们可以知道有测试失败，但不知道失败测试的具体数目！

虽然依赖型测试不是加速测试的好办法，但也不失为一种选择。只要明白这种方式潜在的问题即可。

现在我们来看一种风险较小的加速验收测试的方法吧，这种方法专门用于修改数据库内容的测试。

5. 回滚事务

验收测试经常会重复地恢复数据库状态，因为前一个测试改动了数据。我们在第6章中学到的技巧在验收测试中仍然可用，即在测试执行后回滚事务，把数据库恢复原状。

之所以要回滚事务而不是删除整个数据库再重新填充数据，是因为回滚操作要快几个数量级。虽然这种方式能极大地提高速度，但是并不太容易实现。首先它要求验收测试也能访问事务，其次每个测试必须只在单个事务中——这在许多系统中是不现实的。

总之，这种方式很有潜力，但是适用场合较少。不过这种方式还是值得关注的！下面我们开始介绍最后一个加速测试执行的方法——软启动。

6. 软启动

在有些项目中，验收测试需要频繁地重启系统以恢复其内部状态。虽然只要几行Perl或Bash代码就可以完成，但是重启一个完整的Java EE应用服务器会耗费不少时间。我们可以想办法做软启动，而非完全重启。

软启动不会中止进程，不会回收上百兆的内存，不会从数据库中读取数据填充到应用层的缓存中，也不会执行其他缓慢的操作。软启动是一种智能操作，只会完成最少量的任务以把系统带回到某个状态。

网关系统是软启动技术应用的典型场景，此类系统会连接两个网络处理和转发包。我们不必重启整个网关系统，只要刷新网关内部缓存和队列即可。

然而软启动并不总可行。要支持这种方式，必须谨慎地设计及重构整体架构，以保证所有状态都能被重置。当然这种方式也并非适用所有情况，但绝对是加快测试执行速度的好办法。

至此我们已经介绍了许多加快测试执行速度的方法，是时间讨论下一个常见的问题了——测试用例的复杂度。

11.4.2　减少测试的复杂度

验收测试冗长、复杂是许多团队的问题。到处都存在重复；测试初始化代码足有40行，全都在描述实现细节而不是故事的相关概念，这样很容易见木不见林。如何减少这种复杂度？

重构以消除重复，改名（并且用恰当的语言）以更清晰地表达含义，用公用夹具把验收测试组织成有机的整体，等等。除此之外并没有更好的建议了——恰当应用这些技巧就足以改善验收测试了。

话虽如此，验收测试，例如Web应用的验收测试，还可以用环境对象（context object）替代公共初始化的。环境对象可以把待测系统带入到指定状态下。因此测试可以指定一个环境说：“把我带到这里”，而不必使用层层继承的初始化层级。不同的环境可以相互建立在对方基础上，或许也可以创建一个继承等级，不过现在在测试用例中是看不到这种继承等级的。

环境对象的扩展——缓存环境对象（caching context object）也能加快测试执行速度，如图11-17所示。

缓存环境对象可以把当前会话状态保存成快照，然后在后续操作时用快照内容恢复会话状态，这样就能不必再重复HTTP请求了。实现此类缓存环境对象，待测系统需要暴露API为服务端的会话数据保存及恢复快照。

图11-17　缓存环境对象不仅知道如何把系统带入指定状态，还能在系统进入某个状态后保存快照。后续测试可以利用此快照恢复系统状态而不用重复状态准备过程

缓存环境对象比较容易实现，只要数据不是保存在外部系统中即可（例如关系数据库）。说到数据库，我们有必要讨论测试数据的管理技巧，这也是最后一个常见问题。

11.4.3　管理测试数据

保持自动化验收测试代码干净整洁是一回事，而维护测试数据（即在测试初始化中会写入数据库且验收测试会假设已经存在于数据库中的那些数据）是另一回事。管理测试数据不可避免地比期望的更复杂。不过我们还是有一些简单的规则用于提高测试数据的可管理性的：

- 使用单数据集

- 产生数据集

- 给数据做版本控制

下面我们来逐个进行讲解。

1. 使用单数据集

管理测试数据的第一条建议就是只为验收测试准备单个数据集。若现有测试数据量已经很大了，而且开始在每个测试用例间重复，让事情变得更糟，这时可以考虑采用单数据集。此外，在几个数据集间保持数据同步也不是一件容易的事情。若有新需求，而且验收测试需要的数据不在当前数据集中，那么在单个数据集中添加新数据也很容易。

但无论是类还是数据集，避免出现一个巨大的实体不是编码和设计的常识吗？是的，我们应该尝试把测试数据拆分成小块，组合在一起时能提供完整的数据集。有时，数据集可能太大，我们无法把它作为单数据集进行处理。不过即便如此，我们也需要考虑拆分的代价。如果这样管理整个测试数据不会带来太多脑力负担，那么是可以接受的。

另外一种拆分数据的方式是将测试数据（比如SQL脚本，人为编辑的垃圾文件等）由静态定义改为动态定义。例如我们能动态产生测试数据。

2. 产生数据集

产生数据而非静态定义数据的好处之一是数据体积小。一段产生数据的脚本会很简短，因此也更容易管理和跟踪。

我个人觉得应该用待测系统采用的持久化实现来产生数据。例如，如果系统采用Hibernate或其他ORM框架保存领域对象，那么我们可以使用同样的领域类和同样的ORM框架为验收测试产生测试数据。这样的好处在于数据集是用领域描述的，而非用架构、表、列和SQL语句。

无论采用静态还是动态方式，你都应该对测试数据定义做版本控制，这就是管理测试数据的第三条准则。

3. 对测试数据做版本控制

比较两次测试运行间的测试数据变化很麻烦，因此我们有必要对测试数据做版本控制。不过这样还不够，我们需要选择易于操作的定义形式。特别是能够比较两个版本的差别，即两个版本中哪些行发生了变化。

因此，数据集定义就不能采用二进制文件了，而需要使用基于文本的格式。虽然这显而易见，但是我还是见到许多项目都用二进制文件格式维护测试数据，甚至还以运行着的数据库中的架构形式保存数据。强迫开发人员和测试人员使用数据库客户端和SQL语句对比数据集的变化，是破坏生产力的最有效途径之一。

测试数据就讨论到这吧。显然，没有什么魔法或银弹能解决所有数据相关的问题，但是我希望这些建议能帮你把相关问题减少到最少。下面我们来结束本章吧。

11.5　小结

在本章开始部分，我们研究了验收测试连接待测系统的几种方式。首先我们讨论了三种需要考虑的因素：功能和验收测试自身特征、变化的频繁程度和大小以及实现技术。通过一个简单的例子，我们讨论了这三种因素可能产生的影响。

了解到需要考虑的因素后，我们讨论了一些验收测试的实现方式。我们先从最直观的端到端验收测试开始，即像真实用户操作系统的方式一样通过UI进行验收测试。接下来我们了解了称为“在应用程序界面下做测试”或“绕过UI做测试”的方式。最后我们简要讨论了直接的测试方式。这种形式的测试属于纯白盒测试。如果必要，我们可以越过不易测试或减慢测试执行速度的组件。我们还讨论了如何使用测试后门来验证期望的行为以及副作用。

接下来，我们讨论了第三种因素——技术。我们依次讨论了编程库、分布式系统、控制台应用、带有GUI的桌面应用和Web应用。通过研究各种系统的验收测试的实现方式，我们有能力在验收测试方式方面做出明智的决定了。

在结束本章之前，我们一起讨论了各种技巧，以解决团队在验收测试套件出现混乱，速度极慢时会遇到的常见问题。

编写好的验收测试很难，将其完美地自动化也不容易。现在你应该已经能应对这些挑战了，也有能力进行深入探索了。

在下一章中我们会再次改变话题，集中讨论如何能顺利地在个人及团队中应用和保持TDD及ATDD实践。由此，我们会结束整个TDD和ATDD的旅程。

第12章　TDD应用

人们常说时间可以改变一切，事实上，一切都是由人来改变的。

——安迪·沃霍尔

你读这本书可能是因为对TDD很感兴趣，或者是已经尝试在工作中采用这项技术了，但你的同事很可能对TDD没有多少兴趣。让团队中的其他成员也使用TDD显然能帮助你更好地利用TDD，对整个团队更是好处多多，因此应该尝试在整个团队中推广TDD。本章会帮你掌握TDD，同时促使你的同事也愿意采用这种编程方式。

我们不会只谈TDD，因为无论发生何种改变人们接受新事物的规律都一样。我们将会讲解各种因素，这些因素会影响每个人引领变化的能力——无论这些变化是发生在我们自己身上、发生在我们的同事身上，还是发生在咨询我们的客户团队身上。

我们先从成功采用TDD这类技术的必要条件谈起吧。

12.1　成功采用TDD的必要条件

学习新开发方法并不容易，尤其是新方法与旧方法差异很大时。而且掌握新方法不可能一蹴而就，需要不断地练习和改进，但最终所有努力都是值得的。

下面我们来讨论几点因素，它们能影响到个人应用TDD（或者其他技术）的成败。

12.1.1　理解本质

许多优秀的软件工程师在某种程度上都患有程序员特有的注意缺陷障碍，他们不断地尝试新事物，但几周之后就会把新学的工具、框架或技术全都抛弃。

若要让变化得以保持，理解变化背后的原理很重要，而认清楚技术带来的真正好处是关键所在。若不能做到这点，那么在面对外部压力时就很可能会放弃新方法。因此，和团队其他成员共同讨论问题和分享心得很有必要。

实战经验也有助于理解技术背后的原因。把“TDD对你有好处”这话重复一百遍也不会让他人认真采用这项实践。只有在亲眼看见由于先写测试客户再没有在电话的那端向我们怒吼，开发方式才会真的发生变化。我们一步步地创建出整个解决方案时，才会意识到一些事先设计系统时不会考虑的事情。

不过即便如此，若没有改进的切实需要，没有紧迫感，那么也很难体会到变革的好处。

12.1.2　紧迫感

W. Edwards Deming^1曾说“你不必改变——生存并不是必需的”，这句话点破了人们采用新方法时的心态。我们或许可以意识到变革带来的好处，但是若没有切实的需要，这些好处很容易被忘掉。对大多数人来讲，变革意味着风险，只要目前的方式仍然好用我们大可以维持现状以免出现差错。

1 日本战后工业复兴之父，Out of Crisis（MIT出版社，1982）一书的作者。

不幸的是我们通常都没有必要的紧迫感。这并不是指在面对管理层压力时没感觉，只是说我们倾向于关注短期目标，例如按时交付项目等，而会不专注提高整个组织的交付能力这种长期目标。不过，我们可以自己想办法营造些紧迫感。

我们技术人员提高自身水平的愿望可以带来紧迫感，提高交付能力的渴望也能创造紧迫感，期望充分满足客户需求也会使人有紧迫感。不过即便如此，紧迫感要切合实际，而且足够具体，这样我们才会知道如何提高自我。

下面是个营造紧迫感的例子：一对开发人员下决心把长长的bug列表彻底解决掉。只在几个星期之后，所有的bug要么修复了，或者是关闭了，还有的已经改了优先级，而且新bug出现的速度基本为零。还有一对开发人员再也不想返工去修复bug了，希望写出的代码不再有bug。他们越来越意识到测试的重要性，最终选择了TDD，从此他们编写的代码再也没出现bug。

对于这种下决心才能成功的变革，紧迫感很重要，因为它是巨大的动力来源。不过除非能不断得到正面的反馈以证明我们真的在进步，这种变革的生命力也不会太久。换言之，我们需要成就感。

12.1.3　成就感

我们不喜欢做不擅长的事情，至少我不喜欢。我们喜欢做那种可以持续提高或改进的事。频繁的小胜利和积攒起来的成就感能不断激励我们。

成就感是变革成功的关键。我们必须设定足够小，足够具体的目标，这样我们就能快速及频繁地达到目标了。无论这目标多小，我们都应该为我们的小成就庆祝一下。当你终于能在测试中初始化那遗留代码中的类时，站起来去喝杯咖啡（茶也可以，只要你喜欢）。如果你坚持住一整天都用测试驱动的方式写代码，那就出去吃饭庆祝一下。

得到什么样的奖赏并不重要，重要的是要意识到自己的进步，而具体的奖励，无论大小，都是激励斗志的好东西。此外你的同事也能看到你的夸张笑容，大冰激凌，“爽！”的喝彩声，以及其他庆祝进步的方式。

不过无论怎么样庆祝，我们决不能对小的问题视而不见。我们必须保持正直和诚实。

12.1.4　表现诚实

在团队环境中引入新技术时，决不能把采用新技术的动机藏在心里。人们会相互影响，而且几乎没有人会接受自己不理解的事物，因此我们的言行都要足够诚实。诚实首先是指言行前后一致，另外做事的方法以及对结果的看法也要开诚布公。

1. 保持言行一致

想象你在某个离家很远的地方旅行，向一个本地人询问旅馆的方向。起先那人指着左边。为了确认方向正确，你指了指地图，但那人改变了主意，指向了右边。你不确定到底是左还是右，所以又问了一遍，但那人再次改变了注意，指向了左边。你想这人知道旅馆的方向吗？

若你想说服他人采用新实践，那就必须保持言行一致。不负责任的行为会严重损害我们的信誉，也会使人们不再完全相信我们所推广的实践的优点。例如，讲空话就完全没用，因为最终我们还是会食言，这样我们的话就不再有份量了。如果向同事推荐TDD，我们自己要先做练习。如果在遗留代码上应用实践有困难，我们也应该坦诚地说明情况，解释清楚当前所做的事情对解决问题有长远的好处。例如我们可能会决定重构代码及研究某种能帮我们解决问题的工具。

要记住，除了保持言行一致，避免不断改变我们要传达的信息外，更要紧的是绝不能报喜不报忧，必须保证完全开放的心态。

2. 开诚布公

宣传自己的成功事迹很容易，但是面对失败也要坦诚。否则别人会有所察觉并不再理会这些宣传。我们并不是要证明变革完美无缺，而是要使其他人明白我们也是有理性地倡导变革的，因此需要真实客观地分享我们的经验。

除了注意言行外，引入变革的时机也很重要。下面我们先讨论这个话题，然后再谈谈如何促使他人积极参与变革。

12.1.5　变革的时机

事情都是会变化的，我们想改变的那些东西所处的环境也会有变化。因此不能蛮干，而是要选好引入变革的时机。

1. 压力及紧迫的时间表

考虑到以上各种因素，选择引入变革的时机很重要。在一个项目压力很大并且还有一周就到最后期限的团队中引入新技术不像是什么好主意。

虽然我们明白变革会提高生产力，但是在团队适应及熟悉新方法的过程中会存在学习曲线。此外，临近交付日期所带来的压力也不会帮助团队成员和管理人员理解变革的原因，也不会使他们支持这变革。他们首先会想：“为什么选在这时候，我们都已经火烧眉毛了！”

通常可以在自然的变革边界上引入大的变化，这时候变化正是人们所期待的，而不会被视为风险。

2. 自然的边界及捎带变革

通常最好在自然的变革边界上引入变革，而非在某项活动（例如软件开发项目）的中期，特别是团队化解风险的能力已经由于外界压力等而变低时。

新项目开始，新软件发布，或者团队结构变化时，都是引入变革的自然边界。迭代边界、休假或者团队场外活动等也可以。

这些自然边界中暗含着我们已经接受了的其他变革。在一些变化（我们都已经接受了并不再提出疑问的变化）上捎带一个新变化通常都不会有问题，因为这本来就是个新的开始了。这时我们已经做好变化的准备，无论这变化是使用新IDE或者是采用TDD开发技术还是编码前先写验收测试。

我们刚才讨论的各种因素——理解本质，有紧迫感和成就感，足够坦诚，以及识别正确的变革时机——都会影响到变革的难易程度。要引入变革，这些因素同等重要，或许还不止这些，因为并非人人都像我们一样对变革充满热情。

12.2　让其他人参与进来

要使其他人也能参与进变革（例如采用TDD），我们必须要分享自己对于变革的理解，让整个团队都明白其中的道理。每个团队成员还需要将他人的经验转化成自己的理解。想担当引领者的人不只一个，如何协调使得团队维护平衡很关键。

引领者是很重要的角色，因为他们是动力和激情的来源。幸好，引领者的角色可以由许多有意愿并有能力的人共同担当。说到角色，引领者实际上是种虚拟的角色，附加在个人的正式角色和责任之上。不同角色影响他人的方式也不尽相同。下面我们来讨论引领者正式的角色如何能影响到其引导变革的能力吧。

12.2.1　引导变革的角色和能力

变革可以由许多角色引导，不过不同角色遇到的情况也有所不同。需要营造一个有利于变革的环境，在这种环境中不仅能体会到紧迫感还能频繁地获得成就感。虽然变革引领者通常是职位高的同事，例如技术架构师、技术指导或者高级工程师，不过引领者角色也可以由非技术人员和初级技术人员担当。

与其随意挑选一些角色进行讨论，不如从影响力的角度分析这些角色，因为角色通常与个人在组中的资历与权威性相关。影响可以分为三类：影响初级同事，影响同级，以及影响老板们。下面逐个讨论。

1. 帮带初级同事

高级员工帮带初级员工是引入变革最常见的情形（注意初级二字并不一定意味着年轻）。高级员工的正式角色可能是技术领导、项目经理——甚至可以是业务经理。激励初级员工以及帮助他们适应变化相当容易，因为资历会产生领导力，无论这种资历是源自于多年经验或仅仅是因为组织层级关系。

帮带初级员工时注意不要过多控制对方。初级同事必须有足够的时间和空间自主地适应变化。导师的主要任务是提供信息、反馈或其他支持。导师也可以给初级同事一个讨论单元测试模式的网页地址，教会同事结对编程，如何提反馈，或者只担当一个解决问题的角色——要让对方明白天下没有不该问的问题。

虽然一开始需要多费心，但是如果一段时间后成效还不明显，那么被帮带的对象的动机可能会有问题。也许是没有体会到变革的好处，也许是变革与个人的利益不一致。例如个人奖金或许与工作时长有关系，而TDD可能会减少工作时间，因为工作效率提高了。无论何种原因，导师们都需要能细心地发现问题并采取对策。

高级员工和初级员工之间的帮带关系很常见，也很自然，不过平级之间，即两人的水平相似时，问题就没有那么简单了。

2. 引导同级接受变化

与水平相当的人工作是很宝贵的经历。在这种协作关系中新点子会不断涌现，双方也很容易分享成功的喜悦，体验失败时的挫折感。在这种关系中双方都有一定的权威性也会互相尊重。不过并不是所有同级之间都是这种关系，这给变革者带来了新的挑战。

若双方尚未建立起相互协作的关系，那么要提高工作效率就必须要先建立起这种关系。没人能够在短期内就赢得对方的信任，不过只要保持开放及诚实的心态，最终是会成功的。在技术人员之间，建立互相信任和尊重的关系最好的办法是言行一致，让对方知道你很有能力。

有句话说“认识一个人需要时间”。在开发人员进行变革的时候，引领一个人也需要时间。总而言之，我们需要让对方知道我们所倡导的确实有效，我们也必须足够敏锐，在推广实践时避免侵入同事的“领地”，让他们依照自己的步伐前进。

在影响同事时，要注意不要扮演一个无所不知的强硬的角色。我们必须学会倾听对方的意见，并以此调整自己的沟通方式。学会倾听对于影响的第三种类型，即管理上级，也很重要。

3. 管理上级

每个人，至少绝大部分人都有老板。通常老板们对我们引入的变革都很有兴趣。我们必须要学会管理上级，因为上级的不理解可能会导致变革失败。在不同的组织或不同的企业文化下，管理上级需要不同的工作量，不过有一件事情是确定的——管理层与我们看待变革的角度不同。

例如我是软件开发人员，需要向项目经理报告工作。项目经理负责整个项目，保证项目按时完成，不能超出预算，质量也不能低。因此项目经理毫无疑问地想知道变革——例如采用TDD——对项目进度的影响。如果我是项目经理并且在团队中引入了新的变化，那么业务经理（客户经理、销售总监，产品经理等等）也可能想了解这些变化会对他的工作的影响。

此外，和初级和同级同事不同，管理人员所能理解的语言也是不同的。我们必须细心找出他们的疑虑所在，然后收集信息消除他们的疑虑。若不能让上级理解变革的意义，那么我们就不会得到他们的理解和支持——往往适得其反，因为人们通常会反对自己不理解的事物。

因此变革者需要做好应对阻力的准备，要明白斗争可能会持续很长时间，因为改变需要时间。

12.2.2　改变需要时间

行为方式不可能在一夜之间发生改变。对于变革者，推广TDD等技术意味着改变一个专业软件工程师的工作方式，抛弃多年应用老方法时所积攒的经验。对于那位软件工程师，这种变革显然不容易。人们只能慢慢地改变，我们必须耐心地辅助同事，直到他们准备好为止。

变化过程不仅很漫长，也会消耗变革者的大量时间，因此要避免同时帮带太多人。一次帮带一个人或者一个团队比一次带四个团队要现实得多。此外遇到的阻力越大，能帮带的人就越少。毕竟帮带一个有抵触情绪的人需要耗费大量时间和精力，比帮带一个对变革充满激情的人要困难得多。

说到阻力，Woodrow Wilson曾经说：“如果你想树敌，那么就试着改变点什么吧。”换言之，没有人喜欢听我们的建议，让他们彻底改变工作习惯。变革总是会遇到阻力的。作为变革者，我们必须要面对阻力并试着克服。

在下一节中，我们会讨论各种识别并处理阻力的方式。如果你现在像我一样急切地希望学到处理阻力的技巧，那么我最好还是不要唠叨了，直接开始正题吧。

12.3　如何应对阻力

有人说“抵抗是无用的”，我想大部分公司内部的变革者都不会同意这种看法。在第一次试着改变自己习惯的时候，我就觉得让我四处碰壁的阻力不可小觑了。实际上学会应对阻力是变革者的必备技能之一。

在本节中我们会学到许多应对阻力的技巧。不过在我们应对阻力之前首先要能识别阻力及其根源。下面我们先来讨论如何识别阻力吧。

12.3.1　识别阻力

你可能认为，引入绝大部分变革时我都能及时察觉到阻力，但实际上在新环境中，甚至在熟悉的环境中引入变革时，我都常常对阻力视而不见。我也是慢慢地才能做到识别阻力及相应调整自己的行为方式的。做到这点并不容易，因为基本上有多少人参与变革，就会有多少种抵触方式。

有些阻力很容易识别，如当面拒绝等，但是还有许多种阻力并不易察觉。

1. 附带的变化

我曾经加入过一个已经开始了几个月的项目。每个人都负责自己的那部分代码，而且都是独自工作8个小时，除了午饭和偶尔的进度报告外一直不停工作。我在这个团队中试着推广结对编程，但是效果不怎么好。几个星期后我基本要放弃时，才发现以前从未注意过的问题。

有一个开发人员开始提前来上班，因此下班也早一些。首先我根本没放在心上，因为在夏天太阳提前升起时人们都会样做，这在芬兰很普遍。后来这位开发人员也开始提前吃午饭，不过这也没让我感到意外。后来我开始思考这究竟是巧合，还是和我们推行结对编程有关。不久之后大家出去喝啤酒，我才得知此人并不喜欢结对编程。他之所以要改变生活节奏，就是为了多几个小时的单独工作时间。

这并不是那个项目中的唯一阻力，不过却能很好地说明伪装过的阻力什么样。这种阻力我从来没想到过，一开始也没有注意到。因此提高对附带变化的感知能力，学会辨别与所引入变革的关系，或许就发现许多以前忽视了的东西。

2. 察其言，亦要观其行

除了注意附带变化外，我们还要观察人们的行为，而不是完全听信他们的言语。假装同意也是阻力的常见形式。我曾经拜访过一个团队，目的是快速检查一下他们的工作状况及对他们的工作提出一些改进方法，但是一切看来都很好，团队对所有问题都做了肯定回答。

几个星期后我又联系了客户，发现我走后什么变化都没发生。那个团队举行了一次会议，会上他们基本上否决了我的每一个建议。这件事教会了我很多咨询技巧，不过最值得一提的是若言行不一致，那么言本身没有一点份量。我们要观察行动，也要倾听言语。

3. 倾听缺失的话语

有人说沉默代表默许，但实际上也可表否定。特别是在引入变革会改变人们的工作方式时，沉默是种警告——人们也许持有保留意见。当然也可能是对方不爱讲话而已，不过不能忽略沉默，直接当之为默许，而最好能找个轻松的环境和当事人单独谈谈，注意不要让他感到不舒服。

此外有些人还会尽量掩盖问题的存在，让其他人无法了解他的真实想法。我们很自然地会反对让那些自己陷入被动的变化，也不愿意处在对自己不利的境地之中。如果自己是整个团队里唯一不愿意使用TDD的人，当然会受到他人无形的压力。

发现问题是好事，这样我们就能扫除障碍，推动变革了。要注意克服障碍并不是指让每一个有问题的人都追随大流，有时候观点不同是可以接受的。例如Bob和Chris都喜欢TDD，而Dave则感觉一般。其实能让每个人都认清形势已经很不错了。

还有一种阻力是在被动语境下得到的肯定答复。

4. 条件性肯定

条件性肯定是指那种带有“如果”的肯定句。假如你在向你的老板解释采用ATDD的好处时，你的老板回答说“我会绝对支持你，如果你能做个全面的风险分析，并把这东西推荐给客户和我的老板”。虽然这可能是你老板的真实想法，也可能是不完全同意这个提议。也许是他们不想直接提出质疑，让你做别的事来拖延时间而已。

一些激情四射的变革者听到这个要求后可能会立即着手去做，做完后才发现老板的疑虑还是没有解决，因此需要仔细分析然后得出结论。曾经有个聪明人说“是的”和“也许”之间的距离比“不行”和“也许”之间的距离大得多。从他人角度观察变革是确认及解决问题的关键所在。

在一个部门庞杂的组织中，引入变革可能需要花费大量时间编写文档。这是因为批准这些变革的人们通常都不在一起工作——这一点在我们的下个例子关于不很明显的形式的阻力中会显露出来。

5. 所谓“不能做”的事

在讨论问题时常会有人说“这事没法做”，我常常会直接附和着说“是啊，确实没法做”。不过或许我应该反问“为什么？”。发掘出“不能”背后的根源，也许就可以消除自设的限制和错误的假设所带来的阻力。

“这事没法做”可以有很多种含义。有时候真的是做不了，而有时候说明我们（或者是我）不打算做。这时候，我们需要找出造成这种阻力的担忧、期望或者假设。我们需要明白“如果做了，会怎么样？”，然后试着去消除其负面影响。在某段代码上做TDD“不可能”，很可能只是“不容易”或是因为我们自己不熟悉方才觉得不舒服而已。

过于含混的答案中通常都隐藏着真正的解决方法。多问几次为什么，结果或许会有很大不同。

6. 做不完的高优先级的事情

高优先级综合症是我多年咨询生涯中见到的另外一种很棘手的阻力。高优先级综合症的症状是手头的变革总被其他高优先级的事情挤掉。

例如我的一个客户（一个极其传统的庞大的组织）的数据库管理员们，每当要帮我们开发团队的成员编写数据库模式时，都会遇到各种各样的亟待处理的紧急情况。实际上，这些数据库管理员之所以那么忙，很大程度上是因为他们不喜欢这种新的数据库模式，以前从未处理过这种情况。高优先级的事情当然要处理，但是也可能是阻力的另一种形式而已。

7. 设置障碍

当我们的安全受到威胁或感觉不舒服时，我们会很自然地做出防御的反应，或者直接采取进攻姿态。这种防御行为可能表现为言语、动作、姿势，或者更常见的是表现为以上三种形式。这些行为实际上是在构建起一道防线，保护我们不受那些变革的威胁。

遇到阻碍时，我们自己首先不要变得过于敏感。相反我们要保持开放的心态来扫除障碍。先确认反对者的顾虑，然后和他谈谈暂时把变革放在一边。这样坦诚相见，因害怕变革而产生的顾虑才会冰消雪融，双方也才会有建设性商讨的环境。那些无用的防线迟早都会自己垮掉的。

一个变革者会遇到如此多样的阻力，那么有好办法来应对吗？很显然，如果没有，我就不会问了。

12.3.2　应对阻力的三种常见方法

虽然能够识别阻力很关键，能应对这些阻力也很重要。那么究竟该如何应对呢？除了注意点社交技巧及稍微了解些心理学外没有什么好办法了。

不过下面罗列的这些技巧无论是在处理职业生涯中的事情，还是处理家庭问题——比如，打算给家中添置一件超贵的家电时都可能派上用场。

1. 逻辑

第一层防线是技术狂人们的最爱——逻辑。只要从实际效果角度解释变革的作用，讲清楚变革带来的各种好处，那么就可能收到意想不到的效果。

注意把变革对个人工作的具体影响作为解释的重点，毕竟人们支持变革也是为了自己。例如在推广TDD时需要向人们展示其提高生产效率和改善工作质量等特点。

2. 尝试

如果讲逻辑没作用，那么我们可以劝说对方“尝试一下”。接受一个临时解决方案比接受一个必须恪守一辈子的决定简单得多。试用TDD一个月，远比“从现在开始用TDD”更容易，因为后者意味着“从现在到永远”。一旦对方愿意尝试了，我们不能放任不管，而是应该尽量帮助对方解决遇到的问题，这样更容易成功。

3. 恳求

就算你没长着一双足以让人动情的眼睛，你一样可以从情感上征服那些反对者。有用的方法包括呼吁共同的利益（团队的利益，公司的利益，等等），或对个人职业的好处。“帮我这个忙好吗，杰克”，这招总能见效。当然，同意尝试只是第一步，让对方真正愿意接受变革才是最重要的。

除了这些比较明显的应对阻力的技巧外，还有一些间接的方式可以考虑。

12.3.3　应对阻力的技巧

虽然逻辑、尝试和恳求等各种技巧很有用，但并不总能解决问题。下面这些方法也有可能派上用场，先从调整目标来支持变革开始讨论吧。

1. 调整目标以支持变革

当变革遇到阻力时，改变当前形势或许有利于解决问题。若能把周遭环境变得更有利于新的做事方法，问题也许就迎刃而解了。例如要求对方做一件用TDD比用传统方法更擅长解决的事情，那么对方可能会更容易接受TDD的开发方式。

要求对方做事时要显得自然，不能带有太明显的设计过的痕迹。没有人喜欢双手被绑着做事，若绑手之人还站在背后就更糟了。另外我们要用最终结果来表述需求，而不要过于强调实现方法。

2. 听——认真倾听——然后总结

要想有效地沟通，倾听很重要。当你要帮助反对变革的人时，让对方感觉到你在认真倾听他们的话并能站在他的角度考虑，对解决问题至关重要。

用自己的语言复述对方的话以表明确实理解了他们的观点，并询问对方是否有信息被遗漏了，这是很有效的倾听方式。另外，还要设身处地为他人着想，从而验证自己的理解并弄清楚他们真正担心的是什么。

建立一种关系对促进相互理解至关重要，因为越是相互了解，就越容易明白对方的真实意图。你可能想象不到，当你从楼上下来跟另一个以往只通过邮件与其交流的人促膝谈心时，问题似乎眨眼功夫就变得没有那么难解决了。

3. 别把它真当阻力

把阻力真当阻力，对解决问题没好处，甚至还会有负面影响，这是因为参照系会变得不同。

问题的参照系会影响到解决问题的思路。不同的参照系中有不同的工具，不同的的解决方式，有时甚至问题本身在另一个参照系中都不再是问题。总的来说，最好能把阻力想象成某种有用的信息。实际上，有时候我们真正需要客服的是自己——有时变革者的手中也许正握着解决问题的钥匙。

我们或许是在某个不注重软件质量而是尽量拖延交付时间，增加团队人员数量，想方设法从客户那赚取系统维护费用的公司内推行提高软件质量的技术。这种组织对TDD和ATDD等技术绝不可能有兴趣，此时我们最好能悄悄退出，去别处寻找机会。

说到选择，有时给对方提供选择也能有效地解决问题。

4. 提供选择

有人说真正的咨询师总会让客户自己提出解决方案，变革者也是一样。

人们从不会否定自己想出来的点子，也不会反对自己倡导的变革。这种倾向同样会影响到变革的生命力。有时我们可以提供些选项，退一步，让对方作出选择。比起别人安排的事情，人们对自己的选择更加尽心尽力。

作出选择会给人以主人翁感，不过并不会帮助带有抵触情绪的人消除心中的疑虑。因此我们在提出替代方案时，还需要让大家对变革产生一定的亲切感。

5. 增加亲切感

参考前面提到的参照系概念，有时候我们可以用大家熟知的概念解释变革，或者将变革与那些反对者们曾克服过的困难联系在一起，以应对由陌生感而产生的阻力。

例如，若反对者碰巧很赞成系统测试中采用测试自动化，那么就应该参考系统级自动化测试的优势来解释TDD的好处，而不要强调演进式设计。

另外让成员们做些小实验，脱离开引发抵触情绪的环境，也会有所帮助。我的一些客户每周都会组织编码dojo——一种推广TDD的小组编程会话。在会话这种特殊环境中可以大家都会放松一些，犯了错误也不要紧。彼此之间可以分享经验，因而加深对TDD的了解。另外Satir Change Model参照系也是处理阻力的一种思考工具。

6. 萨提尔变化模式

萨提尔变化模式将变革的转化过程分为4个状态：旧状态、混乱无序、练习和整合，最后是新状态。变革由当前状态开始，随着变革的引入而进入混乱无序的状态，然后新实践的应用会逐渐稳定下来，随后新状态也就开始了。不过我们变革者与其他同事常常处在不同的转化阶段。

例如我们正在某个团队中推行TDD。我们自己已有多年的测试驱动的经验了，而坐在身旁的人只是在几天前刚刚听过TDD而已。我们也曾体会过颠倒设计和编码顺序后不知所措的感觉，但那已经是很久以前的事了，印象也不再深刻了。但是我们必须能切身体会到身边的人正在经历的手足无措的感觉，也要能理解他们正在尽全力改变自己的编程理念。

资深咨询师Dale Emery曾说过“你眼中也许只有壮丽的美景，但摆在他们面前的却是疑惑、痛苦及各种风险”。我们必须能意识到这种差别，并依次调整参照系。我们无忧无虑，但其他人可能满心忧愁。要对当前情形有个共同的认识，讲故事是个非常有效的办法。

7. 讲故事

讲故事是人类传习知识的重要方式，这种方式比用表格或者清单列举事实强多了。因此，讲故事也是我们应对阻力的工具之一。通过讲故事，我们可以让观众站在故事中主角的角度看问题，也可以通过此媒介举例说明变革的可行性及优势。听故事时我们也会站在一个与以前不同的角度上看问题。

我发现讲故事非常有效，即使故事内容和阻力的根源没有直接关系也可以。我曾经给一个开发人员讲过另外一个团队的故事。那个团队的一个测试工程师声称自己不愿采用ATDD，不过后来在整个团队的努力下问题得到了解决。听故事的那个开发人员并没有抗拒ATDD，而是不喜欢TDD。但是听完那个故事后他仔细考虑了那个团队的问题，进而联想到了自己，问题就这么迎刃而解了。即使问题不尽相同，讲故事也可能解决问题。

不论是给反对者讲故事还是一起讨论问题，讲话语气很重要。通常我们需要使用轻快语气来提升感染力。

8. 讲话语气轻松

我们中那些曾在面向命令和控制的环境中做过管理的人讲话时措辞可能过于强硬。这些强硬的、绝对的词语决不能轻易使用。若语气不合适，对话双方可能会更固执己见。而轻松的对话则更有建设性。毕竟若双方都不愿改变自己，消除阻力的机会就会小很多。

以上各种克服阻力的技巧帮到了很多变革者。若需要更多的技巧，可以阅读Mary Lynn Manns和Linda Rising合著的Fearless Change（Addison-Wesley, 2004）。接下来，我们来讨论如何应对过于强烈的阻力。

12.3.4　挑选战场

应对阻力技巧的选择并不容易，因为没有哪个技巧能适合所有情形。有时候应该选择坚持及以理服人，而不是强迫他人接受；有时候应该退后一步，放慢速度；而有时我们应该选择战场。

1. 一个真实的故事

有一次我应邀给某个团队培训TDD。这个团队的成员水平不一，工作激情也不一样，但是有一个团队成员引起了我的注意，因为他不断地直接反对TDD。我在那个团队工作的整个过程中，他不断提出质疑但又不能说出任何论据。我不断地试着解释TDD的好处，但收效甚微。

这段经历教会我一个道理：有些仗是不值得打的。那个人的存在使得战争不可能胜利——或者说不能在合理的时间内胜利。毕竟我只有有限的时间全力帮助那个团队，但最后发现我花了太多时间在打一场不可能胜利的仗。

2. 让步也是可行的

并不是所有人都能接受TDD，无论我们多么努力地改变他们的想法或帮他们解除疑虑。虽然不能稍遇阻力就退缩，不过适当让步，让喜欢的人继续使用新方法，不喜欢的人回到以前老方法也是可行的。

做出让步其实也不容易。我们可以选定项目的某个部分，让有兴趣的团队成员完全采用TDD。不过TDD和非TDD两个部分的整合处常会存在问题。例如在可测试性很差的代码上用TDD进行开发对TDD的新手来说可不是件容易的事情。同样，两种开发方式集成的频度也会导致问题，因为旧开发方式集成频率很低，因此也要做更多的代码合并工作。

我们会尽量避免选择战场，但是有时也别无选择，尤其是在员工的背景差异很大，对技术的态度也不尽相同的大组织中。

12.4　如何推进变革

虽然没有哪个技巧是万能的银弹，但还是有许多方法可以帮我们解决问题的。本章前面已经讨论过一些技巧了，下面的这些可以作为额外的工具。此外下面这些工具也是前面的工具的扩展或者抽象。

我们先从引入变革时常首先使用的技巧开始吧——为变革造势。

12.4.1　造势

我最大的客户之一正在从软件密集型产品的开发向敏捷方法转变过程中。变革开始之前我们先在团队内部组织了几场免费的讲座。这些讲座旨在提高敏捷方法的认知程度，并发掘出组织中已有的对敏捷方法感兴趣的人，促使他们与变革者交流。这些讲座由一个著名的咨询师主讲，参加人数很多。这为后来的变革做了很好的铺垫。

这些讲座纯粹为了传播信息，描绘变革后的美好未来，给与会者增加动力。

1. DICEE

苹果公司院士及著名风险投资家Guy Kawasaki用DICEE来描述伟大产品的特征。

- 深邃——伟大的产品应该是有深度的。

- 包容——伟大的产品应该是奢华的。

- 完美——伟大的产品不止是个东西而已。

- 优雅——伟大的产品应该有个优雅的用户界面。

- 动人——伟大的产品应该激发你采取行动。

虽然我们这些软件开发组织中的变革者更可能为工程实践，而非产品而造势，但这些特征一样值得借鉴。

下面来看看这些特征如何能正面地影响到目标听众吧。这次我们将TDD作为造势的具体内容。

2. 展示深度

首先，我们必须要把TDD作为一个有深度的方案来讲解，这种方案不会跑五里路后就没油了。作为传播者，我们应该展示TDD如何提高质量和生产效率的，也要说明TDD是一个完整的软件开发方式，能经济地开发出高质量的软件。为此我们需要准备好应付各种反对意见，例如“但是这种方法对我们不适用，因为……”。我们也要预备好各种真实生活中的例子，同一个组织中应用这种方法的成功先例，外部资料，等等——因为我们的听众期望看到证据。

3. 突出特色

其次，我们要把TDD展示成一件奢侈品。我们期望与众不同；把新实践描绘成一件特别的事物，例如“最好的开发人员才做的事”，听众便会乐于采用这项实践了。Guy曾说“购买我们的产品时人们应当感觉在犒劳自己”，而我们的产品就是TDD。因此我们不应该只说TDD能提高代码的技术质量或者简洁实用，还应该强调TDD能让我们的生活更加精彩和有意义。

4. 全面完整

真正的evangelists不会过于注重细节，而会强调变革在整体上如何适用。若整体解决方案没有漏洞，那么那些反对“不安全”新事物的力量自然会消失。完整性表明我们全盘考虑过这个问题，而不是纸上谈兵，也不会把难题留给别人去解决。完整性并不单指提供信息，还包括后续支持。听众期望需要帮助和支持时我们就在身边——无论是提供培训、指导或咨询、辅助管理，或者只为他们攻克难关。

5. 展现优雅

虽然DICEE中的“优雅”是指优雅的用户界面，在宣传方法和实践时也一样。Guy曾说“事物应该按照你期望的那样运作。好的产品绝不会与你作对，而会顺应你”。在宣传TDD时，一定要表明TDD能帮我们完成任务，也会顺应我们考虑问题的方式。当给一屋子开发人员做现场演示时，不能只描述我们所做，还要讲述我们所想以及做法的来由。让听众理解做TDD时的整个思路，会完美的展现此技术的优雅之处。

6. 感动听众

伟大产品一旦上市就开始推销自己了。作为一个宣讲者, 我们的重要任务之一是把听众变为我们产品的支持者。要让我们所倡导的实践激发起他人的情感——让听众们激动起来，并想立即把感受分享给其他人。而我们倡导所要做的就是把自己对TDD的激情及理解清晰地表达给听众。着重强调方法的优点，让听众自己去尝试，帮他们体会到同样的激情。要让听众体会到激情，要点之一是给他们准备好触手可及的成果。

说到触手可及的成果及开始引入变革，我们该结束宣讲部分的讨论，开始下一个技术吧——降低门槛。

12.4.2　降低门槛

要保证变革顺利进行，能持续地体验到成就感很重要。毫无疑问，保证初次尝试不会失败至关重要，让推行的变革看起来很难实行可不好。我们要想方设法降低其入门门槛，使之容易上手。

1. 快速获得首次成功

例如，我们可以打包提供一整套解决方案，使新开发方法的环境设置变得很容易，也可以提供一些模板和代码样例。此外，除了完整的在线文档，还可以准备5分钟的方便学习的录像。

当然第一次的成功只是个开始。我们不仅要设法使初次尝试变得更容易成功，而且要让采用实践的整个初期阶段都变得简单些。我们通过宕弛时间来达到这点。

2. 宕弛时间

你很可能知道Tom Demarco的畅销书：Getting Past Burnout, Busywork, and the Myth of Total Efficiency（Broadway，2001），并因此听说过“宕弛”。Tom Demarco将其定义为“公司变化的自由度”。

有了充足的宕弛时间，个人才有时间反思目前的变革，执行时才不会有压力。若没有充足的宕弛时间，那么要接受新事物就必须要在思想方法上发生质的飞跃——因为人们都有许多工作要做，而初学新技术和工具时工作效率难免会降低，若不能及时掌握新方法来弥补落后的进度，那么就会有麻烦。

宕弛可以有多种方式。对于学习TDD的团队，管理层可以不要打断团队的学习过程，例如暂时不让团队去修复上个版本中存在的缺陷等等。另外也可以让团队少做些工作，以保证有充足时间掌握TDD。

宕弛时间能给团队带来许多好处，但其中最重要的是能让团队成功地接受变革。这也是降低门槛的第三个要点——允许失败。

3. 允许失败

宕弛时间能为变革铺设一条光明大道，但无论这道路多么平坦，风险依旧存在。因此我们要允许别人失败让他们知道自己有权失败。

我曾和一个在团队中担当领头羊的项目经理讨论过问题。他在团队中推行迭代式增量软件开发之后，发现代码质量也有待提高，因此决定引入TDD。不过这个过程并不顺利，只有一个开发人员会测试先行地编程，其他人还是编码完成后再补测试。

有趣的是当我建议那个项目经理允许自己团队成员失败时，他完全不能接受我的意见。对他来说允许失败就等于给了开发团队松懈的借口。显然我不同意他的想法，你最好也别同意。实际上不允许失败会降低团队的生产力，因为团队成员会认为若变革最终行不通，他们自己要为残局负责。

在本节中我们讨论了三种降低入门难度的方法。快速成功、宕弛及允许失败可以解决掉很多障碍，不过它们不可能让情况突然好转。这些方法不会有一点用处，除非我们能识别出阻力。多与对方沟通是鼓励其尝试变革的好办法。我们可以在宣讲结束后立即与听众交谈，一周后在饮水机旁边沟通也可以。这类对话应该在对方迈出第一步（通常是参加培训）后开始。

12.4.3　培训

组织培训是为变革造势后要做的事。此时让管理层鼓励团队和个人参与培训很重要。注意要保证说到做到——如果只能让几个团队下个月参加培训，那么要实话实说，不能掩饰。

让一个有名的宣讲人在公司内大张旗鼓地宣传后不提供后续培训，那么公司的工作效率可能会像遭受了自然灾害一样锐减。激发起人们的热情后不能对他们置之不理，而需要提供恰当的引导和指点。

随着变革不断向前进展，培训的作用也随之发生变化。一开始在公司内部的大规模培训主要是讲解新变革的基本知识。随着团队不断地积累新实践和新过程的使用经验，培训的内容会越来越倾向于特定问题的解决方法（例如在遗留代码上做测试驱动，或者测试驱动Swing）。

虽然培训是一种经济实用的传播信息和知识的方式，不过每个参与培训的人都只能获取一小部分信息。幸好，在整个组织层面上大部分信息还是会被吸收的。为此，我们有必要在各组间共享信息。

12.4.4　共享及感染

高级咨询师Jerry Weinberg曾说过，果酱摊的越大，厚度也越小。虽然让宣讲人给大量听众做报告，以及请经验丰富的人做培训是不可缺少的一环，但掌握知识最终要靠个人实践。每个人的知识体系都不同，看法也有区别，所以与他人分享自己的想法很重要。

分享有三种常见层别：个人、团队、及组织层别。下面我们将会介绍一系列技巧用来帮助我们在各个层别上进行知识分享。我们先从近距离所带来的好处开始，然后将影响由中心转向外围。

1. 坐在一起

让整个团队的人坐在一起是加快信息分享的一个最简单的途径。“一起”并不是指相邻的隔间，而是指能方便地看见同事在做什么。我们期望构建出一个环境，使请教问题和分享经验（例如刚发现的一个方便的快捷键）都毫无障碍。如果总能看见同事是否在忙（例如在接电话），那么我们就知道该不该打断同事来请教问题或分享经验了，因此交流也更方便。如果我们要到另一栋楼上去找别人谈话，那么就太不方便了。坐在一起，再进一步，就是结对工作。

2. 结对

结对工作把个人信息分享发挥到了极致。当学习TDD时，结对编程能有力地传播好的实践方法。除了能分享快捷键和编程技巧外，结对双方还能及时地互相提供反馈，在精神上支持对方，以及相互监督。这些都对学习TDD和理解好的实践方法有帮助。

不过即便如此，人们都需要充足的时间和个人空间来真正地消化新知识。说到时间和空间，在整个团队内部分享知识也是必要的。

3. 组织会话

现代软件开发以人为中心，需要团队协作才能完成。工作效率高的团队就像一个整体，有共同的目标，共同的认识，有时甚至有共同的意见和想法。在引入变革时，让整个团队对开发实践有共同的认识十分重要。这种会议有黄袋会议和编码道场等。

黄袋会议之所以有这个名字，是因为大家会把午饭带到会议室，边吃边听讲座。不过大部分公司中黄袋会议会或多或少地演化成正式的会议，大家会在饭后或者下班后参加会议，而非在吃饭时（也许是因为用盘子吃饭更舒服些吧）。

而编码道场形式的交互性更强。做报告的人会在一台计算机上写代码，用投影仪将内容显示在大屏幕上，大家都能看见。最初编码道场更像做报告，做报告的人会现场演示一段事先准备好的开发场景，解释每个步骤，借此与其他人分享开发思路和技术。经过逐渐地演化，编码道场变得更具有交互性了。会议中会要求与会者和小组成员共同解决问题，两人一起编码，定时轮换，其他人观察。

黄袋会议和编码dojo有利于知识的传播，也有助于构建出相互帮助，开放的环境，这样人们就能放心地实验和相互学习了。不过没有必要把参与人员限制在团队或者项目之内，这种形式同样有利于在整个组织层别上传播知识。

4. 向外传播

当组织中各个部分都已经开始接受变革时，促使这些互不往来的部分相互沟通就愈加重要了。我们可以通过举办公开session组建出一个社区，这有利于变革持续进行，也能保持变革朝正确方向发展。随着变革持续进行，原有的变革者们或许已经无法照顾到所有方面了，这时若存在一个社区，那么初学者能从中获取足够的帮助，也为那些深入学习的人提供了一个交流的平台。

目前，我们已经讨论过了各种推进变革的方法，包括从抽象层面的各种宣讲方法到更面向人层面的知识共享。除了这些技术外，在团队引入变革的过程中，通过指导和督促可以提供有帮助的信息和看问题的崭新视角。

12.4.5　指导和督促

除了用于共享信息的现场培训和论坛外，给团队配置一个教练或督促者也很有帮助。培训主要目的是传播知识和技能，而教练和督促者则会退后一步，从旁观者角度观察我们所做的事。教练能帮我们了解自己的行为及应对策略，而不会帮我们理解所引入的变革。

1. 请教练

若要指导的效果好，教练必须和指导的环境有一定距离。即使是最有经验的教练，当处在特定场景中时也可能会有偏见，或者对问题视而不见。因此，最好能从团队外请教练。不过教练不必是外面请来的咨询师，只要水平够高，经验够多，也有一定威信，也能恰当地提出质疑，回顾问题，并组织讨论，那么效果也会不错。

有经验的教练能协助整个团队及团队成员工作，帮他们找出问题的解决方法，并能指出当事人在自己的角度无法察觉的问题。教练不是传统意义的咨询师，因为他们不提供解决方案，而是帮助团队自己解决问题。我们也不能直接将教练安排到团队中工作，而需要事先和教练一起制定所指导的范围和目标。换言之，要有教练，但不能强迫教练听从团队的指派。

教练需要倾听和观察，并提出建议，也要能像镜子一样反射出团队的感受和行为方式。此外，在召开督促会议时，教练的专业技能也会发挥很大的作用。

2. 督促会议

督促会议可不只是制定会议日程或安排杂事。在帮助团队适应变革时，督促会议还要使与会者在做出决策时能全面地考虑问题，也能清楚其他可行方案。督促者要让与会者清楚自己在处理冲突时的行为和习惯，也要使团队在讨论问题时更容易达成一致。

督促者要能意识到沟通背后暗含的动向，确保所有与会者都明白存在的问题——同时保证安全和互相协作的气氛，不会出现隔阂或争吵。为了达成会议的目标，督促者需要调整会议的步调，交流的方式，以及会议的安排。有时需要保持会议轻松愉快，而有时要强调结果并能完成任务。因此有时要让大家团结合作以对付外部威胁，而有时要在团队内部制造点竞争气氛。

教练还要帮助团队成员开发自己的潜力，而内省和反思等方法是有效的工具。下面来介绍“停止说话”背后的机理吧。

3. 停止说话

在指导团队（或个人）的过程中可以引入反思会等特殊形式的会议。反思会为与会者创造出一个自由的环境，使每个人都能自由地表达对以前成功或失败的经历的想法，说出自己不同的意见，同时也能坦诚地指出团队当前工作方式的问题。通过客观地观察变革的施行情况，我们能使变革一直保持活力，也会明白变革真的发挥了作用。这点极其重要。参加完反思会后，我不止一次想“他们是我熟悉的那些人么？”，团队的热情从很低急升到很高的水平，仅仅是因为他们有机会表达自己的想法。

指导和督促主要作用是提高参与度。下一个干预技巧会介绍如何通过给团队成员分配角色来提高参与和投入程度。

12.4.6　通过分配角色让人们参与进来

我们应该知道，若人们不能参与进变革则很可能会反对变革。因此，若能让人有机会参与，那么就会减少推行变革的阻力。方法之一是给人们分配角色。

1. 让利益相关的群体参与进来

例如某个组织的QA部门极力反对采用ATDD，因为他们认为“自动化测试会代替我们这些专业测试工程师”，这样工作也就难保了。我们很清楚事情并非如此，测试工程师是一个有特殊能力的群体，但测试工程师自己也许并不清楚。

在这种情况下，我们需要花时间给QA部门解释ATDD究竟是什么，也要说清楚这项变革不会对他们产生负面影响。除此之外，我们可以采取一种合作机制，将变革指向ATDD，在这一过程中包括给测试人员指定角色。

2. 将威胁变成机会

例如会编程的测试工程师可以负责自动化测试用例，以及在系统的可测试性方面给开发人员一定建议。而不擅长编程的测试工程师可以专注于探索性测试及上下文驱动测试，也可以多和客户打交道。他们可以和客户代表一起编写需求文档，分析测试用例的覆盖率，协调非功能性测试，等等。

只要稍加考虑，我们就能将产生阻力的威胁转化成个人成长的机会。给人们分配角色时还需要注意具体的分配方式，例如让谁担当何种角色等。这时社交领袖就能发挥重要作用了。

3. 确定社交领袖

每个团队中都会有非正式的社交领袖。虽然这些人并不一定是最有经验或者技术最强的，但是其他人总会征求他们的建议。有些人天生就会激励他人也能给团队带来凝聚力，这些人是最好的社交领袖。给这些社交领袖分配一个合适的角色，我们基本上就有了一个“代理变革者”，然后让他们带动整个团队。

激发社交领袖对变革的热情就像火上加油——能以难以想象的速度清楚掉各种障碍。分配角色对引入变革同样有利，因为给人们分配与自己技能不完全符合的角色会让状态变得不稳定。

12.4.7　打破稳定状态

状态表示稳定和安全。为了扰乱状态及鼓励变革，我们可以有意地把当前安全的地方变得不那么安全，促使人们通过变革来达到新的安全的地方。安全、可控和稳定是人们的基本需要，这种方法也正是基于这个特征。只要这些需要已经满足了，任何变化都会遇到阻力。当这些需要没有被完全满足时，变革就不会遇到太多阻力——特别是变革能帮人们满足这些基本需要时。

打破当前状态有很多种方法，不过几乎所有方法都会让团队成员了解事实真相。例如把大家的注意力从组织内部转移到组织外的残酷竞争上，我们可以增强凝聚力，让大家朝着生存这共同目标而努力。公布糟糕的财务业绩也可以让反对者们认清形势，让他们意识到竞争对手们正在威胁到公司的生存。让开发人员接触愤怒的客户也是个有效的办法。不过不要太过分——如果刺激过于强烈，人们反而会有抵触情绪。

下面我们来讨论迟后的奖励，这也是要介绍的最后一种方法。

12.4.8　迟后的奖励

现在我们应该已经意识到了内部变革者、外部教练和社交领袖的重要性了。所有这些角色对成功变革都有很大作用。若这些重要人物离开公司或者接手其他工作，新实践的推行进程很可能会倒退。迟后的奖励是减小这类风险的有效途径之一。

迟后的奖励主要是让推动变革的关键人物能参与整个变革过程。奖励可以是纯荣誉，也可以是升职，冰冷的钞票也不错。注意这种奖励一定要均衡，而且也不能替代其他奖励。换言之，要频繁及时地进行奖励，除此之外还要有迟后的奖励！

12.5　小结

在本章中，我们谈到若能更好地了解团队成员，理解变革带来的好处，能不断体验到成就感，以及保持诚实及开放的心态以避免不必要的外部压力，那么变革成功的可能性会大很多。我们还了解到虽然时机不是决定性因素，但也会产生一定影响。

要引入变革，处于不同角色的变革者需要做三种方向上的管理，分别是向上、向下、及平级。此外我们还讨论不同角色常见的陷阱。

我们列举一系列不那么明显的阻力的表象，例如把阻力伪装成其他东西的语言或行为方式等。我们还讨论了一些应对阻力的技巧，从讲逻辑，说服他人先做尝试，以及从情感上打动对方等常见做法开始，到一些间接地处理阻力的方法，例如一些思考方式及参照周遭环境调整自我行为等。

为促进组织变革，我们介绍了几种推动组织采用的介入方式，学习了有效说服他人的方法，分享了一些经验，包括以培训和宣讲为手段降低变革的门槛，进一步讨论了与相关人员共享知识的途径。最后，讨论了培训、沟通和辅导的效果。最关键的，我们还考虑到了动摇某些人的地位对推动变革的可能性。而且，不要忘了让重要的人参与进来，必要时还要给予他们延期奖励。

倡导变革在任何组织和文化下都是很困难的事情，但对公司持续发展又至关重要。充满激情的变革者们必须要学会坚持。无论如何这件事情都不会容易。多听多观察，熟练掌握各种技巧，开动脑筋，事情最终会解决的。

另外，如果你一直都很有挫败感，那么可以检查一下自己的口袋——或许你已经颇有收获，只不过自己还没意识到！

附录A　JUnit 4简明教程

import static org.junit.Assert.*;     // ❶

import org.junit.*;

import java.io.*;

public class TestConsoleLogger {      // ❷

private static final String EOL =

System.getProperty("line.separator");

private ConsoleLogger logger;        /*❸（以下3行）*/

private PrintStream originalSysOut, originalSysErr;

private ByteArrayOutputStream sysOut, sysErr;

@Before public void createFixture() {      // ❹

logger = new ConsoleLogger();        /*❸（以下5行）*/

originalSysOut = System.out;

originalSysErr = System.err;

sysOut = new ByteArrayOutputStream();

sysErr = new ByteArrayOutputStream();

System.setOut(new PrintStream(sysOut));

System.setErr(new PrintStream(sysErr));

}

@After public void resetStandardStreams() {     // ❺

System.setOut(originalSysOut);

System.setErr(originalSysErr);

}

@Test public void infoLevelGoesToSysOut()      /*❻❼（以下2行）*/

throws Exception {

logger.info("msg");

streamShouldContain("[INFO] msg" + EOL, sysOut.toString());

}

@Test(timeout = 100)      // ❽

public void errorLevelGoesToSysErr() throws Exception {     // ❻❼

logger.error("Houston...");

streamShouldContain("[ERROR] Houston..."

+ EOL, sysErr.toString());

}

private void streamShouldContain(String expected,     /*❾（以下2行）*/

String actual) {

assertEquals("Wrong stream content.",      /*❿（以下2行）*/

expected, actual);

}

}

❶ 通过静态导入的JUnit的Assert类获取断言方法。

❷ 类名应该表明它是一个测试（例如名称中包含Test）。

❸ 在@Before方法中设置的实例变量表示测试方法中常用的类具。

❹ 通过为一个公有方法加上@Before注解来为测试准备一个已知状态。

❺ 通过为一个公有方法加上@After注解来在测试之后执行清理操作。

❻ 所有带@Test注解的public void方法均被视为JUnit 4 TestRunner的测试用例。

❼ 测试方法可以声明任何异常——JUnit负责捕获它们。

❽ @Test注解也可以用于计时测试、测试异常等。

❾ 除测试方法外，还可以声明任意数量的辅助方法。

附录B　JUnit 3.8简明教程

import java.io.*;

public class TestConsoleLogger extends    /*❶❷（以下2行）*/

junit.framework.TestCase {

private static final String EOL =

System.getProperty("line.separator");

private ConsoleLogger logger;       /*❸（以下3行）*/

private PrintStream originalSysOut, originalSysErr;

private ByteArrayOutputStream sysOut, sysErr;

@Override protected void setUp() {     // ❹❺

logger = new ConsoleLogger();       /*❸（以下5行）*/

originalSysOut = System.out;

originalSysErr = System.err;

sysOut = new ByteArrayOutputStream();

sysErr = new ByteArrayOutputStream();

System.setOut(new PrintStream(sysOut));

System.setErr(new PrintStream(sysErr));

}

@Override protected void tearDown() {      // ❺❻

System.setOut(originalSysOut);

System.setErr(originalSysErr);

}

public void testInfoLevelGoesToSysOut() throws Exception {     // ❼❽

logger.info("msg");

streamShouldContain("[INFO] msg" + EOL, sysOut);

}

public void testErrorLevelGoesToSysErr() throws Exception {     // ❼❽

logger.error("Houston...");

streamShouldContain("[ERROR] Houston..." + EOL, sysErr);

}

private void streamShouldContain(String expected,     // ❾

ByteArrayOutputStream stream) {

assertEquals("Stream content doesn't match expected. ",     /*❿（以下2行）*/

expected, stream.toString());

}

}

❶ 类名应该表明它是一个测试（例如名称中包含Test）。

❷ 扩展JUnit的TestCase类。

❸ 在setUp()方法中设置的实例变量表示测试方法中常用的类具。

❹ 通过重写受保护的setUp()方法来为测试准备一个已知状态。

❺ 在使用Java 5的情况下，最好给setUp()和tearDown()添加@Override注解，以免出现输入错误。

❻ 通过重写受保护的tearDown()方法在测试后完成清理工作。

❼ 所有as test开头的public void方法均被视为JUnit 3.8 TestRunner的测试用例。

❽ 测试方法可以声明任何异常——JUnit负责捕获它们。

❾ 除测试方法外，还可以声明任意数量的辅助方法

❿ 扩展TestCase可以获得一批断言方法，如assertEquals()。

附录C　EasyMock简明教程

import static org.easymock.EasyMock.*;    /*❶（以下2行）*/

import org.easymock.EasyMock;

import org.junit.Test;

public class TestInternetRelayChat {

@Test

public void messagesAreOnlyDeliveredToOtherClients()

throws Exception {

String msg = "Maisk Maisk!";

Client koskela = EasyMock.createMock(Client.class);     /*❷（以下3行）*/

Client freeman = EasyMock.createMock(Client.class);

Client himberg = EasyMock.createMock(Client.class);

expect(himberg.onMessage("lasse",    /*❸（以下2行）*/

msg)).andReturn(true);

freeman.onMessage("lasse", msg);     /*❹（以下2行）*/

expectLastCall().andReturn(true);

replay(freeman, koskela, himberg);     // ❺

InternetRelayChat irc = new InternetRelayChat();      /*❻（以下5行）*/

irc.join("inhuman", freeman);

irc.join("vilbert", himberg);

Prompt prompt = irc.join("lasse", koskela);

prompt.say(msg);

verify(freeman, koskela, himberg);     // ❼

}

}

❶ 从EasyMock和类自身中导入静态辅助方法。

❷ 让EasyMock为指定接口创建模拟对象。

❸ 记录期望的交互操作。

❹ 功能上与上一条语句等价，但语法不同。

❺ 记录完期望的信息后，转换为应答模式。

❻ 一切都准备好以后，做个练习。

❼ 让模拟对象验证是否发生了期望的交互行为。

附录D　通过Ant运行测试

Apache Ant是Java开发人员使用的标准构建工具。恐怕有读者会记得，Ant中集成了JUnit。本附录讲述如何创建运行单元测试的Ant脚本，并让该脚本以美观的HTML格式生成结果报告。如果不熟悉Ant，请参考其在线文档^1或买一本这方面的书^2学习一下。

1 尤其是用户手册（http://ant.apache.org/manual）和Ant的Wiki页面（http://wiki.apache.org/ant）。

2 推荐Steve Loughram和Erik Hatcher 的Ant in Action（Manning, 2007）。这本书可以算作Java Develapment with Ant（Manning, 2002）的“第二版”。

下面就来开始我们的教程，看看到底要处理什么样子的项目。

Apache Ant和JUnit的兼容性

Apache Ant 1.7开始支持JUnit 4。换句话说，如果你的Ant还没有升级到1.7，则需使用JUnit 3.8来运行测试。不过，Ant和JUnit的版本不会影响构建脚本。

D.1　项目目录结构

从构建脚本的角度看，我们不关心Java类都会做什么。我们只关心代码编译。通过构建运行所有自动测试，以及取得测试报告从中找出测试失败的原因。通过项目的目录结构可以看出对构建脚本的要求。下面来看一看代码清单D-1中的简单的目录结构。

代码清单D-1　简单的目录结构

/SampleProject

/src

/main      /*❶（以下2行）src/main目录下的产品代码*/

/com/tddinaction/.../ProductionCode.java

/test        /*❷（以下2行）src/test目录下的测试代码*/

/com/tddinaction/.../TestCode.java

/bin       /*❸（以下5行）编译的代码紧随分开保存的库*/

/classes-main

/com/tddinaction/...

/classes-test

/com/tddinaction/...

/lib         /*❹（以下5行）产品代码和测试代码的依赖要分开*/

/main

commons-logging.jar

/test

junit.jar

build.xml      // ❺在项目根目录中创建脚本

其中源代码分为两个目录：❶ src/main和❷ src/test。分成两个目录不仅有助于区分测试代码和产品代码，也能让编写测试脚本的工作容易许多。

类文件也一样。❸ 编译后的产品代码放在bin/classes-main中，编译后的测试代码放在bin/classes-test中。不难想见，❹ 库也分别放在了lib/main和lib/test中。原因很简单，分开保存依赖的库，可以简化创建部署文档的工作，届时将只需包含编译后的产品代码和运行时依赖。

构建脚本❺build.xml则放在项目的根目录中。谈到构建脚本，我们应该讲一讲它的内容。首先从用来编译代码的部分开始。

D.2　基础：编译所有源码

本项目中，build.xml用于编译相应输出目录的所有源码，如代码清单D.2所示。

代码清单D-2　buid.xml中编译Sample Project所有代码的部分

<project name="SampleProject" default="compile">

<path id="classpath.main">          /*（以下8行）分别用于产品代码和测试代码的编译类路径*/

<fileset dir="lib/main" includes="**/*.jar" />

</path>

<path id="classpath.test">

<path refid="classpath.main" />

<pathelement location="bin/classes-main" />

<fileset dir="lib/test" includes="**/*.jar" />

</path>

<target name="clean">      // ❶删除编译文件

<delete failonerror="false" includeemptydirs="yes">

<fileset dir="bin" includes="classes-*/**" />

</delete>

</target>

<target name="compile">        // ❷编译所有源码

<mkdir dir="bin/classes-main" />

<javac srcdir="src/main" destdir="bin/classes-main"

debug="on" classpathref="classpath.main" />      // 分别用于产品代码和测试代码的编译类路径

<mkdir dir="bin/classes-test" />

<javac srcdir="src/test" destdir="bin/classes-test"

debug="on" classpathref="classpath.test" />      // 分别用于产品代码和测试代码的编译类路径

</target>

</project>

见过Ant构建脚本的读者会觉得这部分内容并不陌生。这一部分定了两个要执行的目标<target>:❶clean和❷compile。在安装Ant后，通过命令提示符窗口运行ant clean将会删除所有编译生成的类文件。而运行ant compile则会将所有.java文件都编译到bin目录中。运行ant clean compile就会先删除已有的类文件再从头编译。而且，因为将这个项目的默认目标配置成了compile，所以只运行ant就相当于运行ant compile。

那么，怎样运行这里编译的测试呢？

D.3　添加运行测试的目标

在Ant中是通<junit/>任务（task）来集成 JUnit的。为了在构建过程中运行测试，需要向build.xml中新添加一个目标。而其中就要用到<junit/>任务。代码清单D-3展示了这样一个目标。

代码清单D-3　build.xml中使用Ant的标准<junit/>任定义的test目标

<project name="SampleProject" default="test">     // 从现在起默认使用测试目标

<property name="junit.data" value="reports/junit/data" />    // ❶为输出路径使用属性，减少副本

...

<target name="test" depends="compile">

<mkdir dir="${junit.data}" />     // ❶为输出路径使用属性，减少副本

<junit printsummary="yes" fork="true" haltonfailure="no">

<classpath>      /*（以下4行）需要类路径中的所有内容，包括编译完的测试代码*/

<path refid="classpath.test" />

<pathelement location="bin/classes-test" />

</classpath>

<formatter type="xml" />

<batchtest haltonfailure="no"     /*❶（以下2行）为输出路径使用属性，减少副本*/

todir="${junit.data}">

<fileset dir="src/test"      /*❷（以下2行）收集所有与模式匹配的类*/

includes="**/Test*.java" />

</batchtest>

</junit>

</target>

</project>

不用多说，这里的<junit>任务令❷收集src/test中的所有单元测试，以便执行并在配置好的reports/junit/data目录下生成一批XML文件。这些XML文件用于描述测试执行环境、运行了哪个测试以及结果怎么样❶。但是，我们还是不太容易从这些XML文件中看懂这些信息。我们还需要一个可读性强的报告。

D.4　生成可读性强的报告

根据<junit/>的输出，可以生成简法美观的HTML报告，而这就要用到另一个内置的任务<junitreport/>了。为此，只需知道到哪里找测试结果、生成的报告放到哪里、使用什么格式（有两种）即可。

代码清单D-4展示了修改后的build.xml脚本，其中包含运行测试和生成HTML报告的部分。

代码清单D-4　build.xml中用于这行测试和生成报告的任务

<project name="SampleProject" default="test">

<property name="junit.data" value="reports/junit/data" />

<property name="junit.html" value="reports/junit/html" />

...

<target name="test" depends="-test-run, -test-report" />     /*（以下2行）测试目标整合测试执行和报告生成*/

<target name="-test-run" depends="compile">

<mkdir dir="${junit.data}" />

<junit printsummary="yes" fork="true" haltonfailure="no">

<classpath>

<path refid="classpath.test" />

<pathelement location="bin/classes-test" />

</classpath>

<formatter type="xml" />

<batchtest haltonfailure="no" todir="${junit.data}">

<fileset dir="src/test" includes="**/Test*.java" />

</batchtest>

</junit>

</target>

<target name="-test-report" depends="-test-run">      // 测试目标整合测试执行和报告生成

<mkdir dir="${junit.html}" />

<junitreport todir="${junit.data}">    // <junitreport/>在数据目录中生成聚集的XML

<fileset dir="${junit.data}"

includes="TEST-*.xml" />

<report format="frames" todir="${junit.html}" />     // 并在reports/junit/html下生成框架格式的HTML报告

</junitreport>

</target>

</project>

仅此而已。这样，我们用于编译代码、运行单元测试以及生成漂亮的结果报告的Ant构建脚本就大功告成了。

相关资源

参考资料

Alur, Deepak, Dan Malks, and John Crupi. Core J2EE Patterns: Best Practices and Design Strategies. Prentice-Hall, 2003.

Ambler, Scott W. and Pramodkumar J. Sadalage. Refactoring Databases: Evolutionary Database Design. Addison-Wesley, 2006.

Cohn, Mike. User Stories Applied: For Agile Software Development. Addison-Wesley, 2004.

Cooper, Alan. The Inmates Are Running the Asylum: Why High Tech Products Drive Us Crazy and How to Restore the Sanity. Sams, 1999.

Crane, Dave, Bear Bibeault, Jord Sonneveld, Ted Goddard, Chris Gray, Ram Venkataraman, and Joe Walker. Ajax in Practice. Manning Publications, 2007.

Crane, Dave, Eric Pascarello, and Darren James. Ajax in Action. Manning Publications, 2005.

Cunningham, Ward and Rick Mugridge. Fit for Developing Software: Framework for Integrated Tests. Addison-Wesley, 2005.

DeMarco, Tom. Slack: Getting Past Burnout, Busywork, and the Myth of Total Efficiency. Broadway, 2001.

DeMarco, Tom and Timothy Lister. Peopleware: Productive Projects and Teams. Dorset House Publishing Company, 1999.

Feathers, Michael. Working Effectively with Legacy Code. Addison-Wesley, 2004.

Fowler, Martin, Kent Beck, John Brant, and William Opdyke. Refactoring: Improving the Design of Existing Code. Addison-Wesley, 1999.

Gamma, Erich, Richard Helm, Ralph Johnson, and John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1995.

Hillenius, Eelco and Martijn Dashorst. Wicket in Action. Manning Publications, 2007.

Kawasaki, Guy. Rules for Revolutionaries: The Capitalist Manifesto for Creating and Marketing New Products and Services. Collins, 2000.

Kerievsky, Joshua. Refactoring to Patterns. Addison-Wesley, 2004.

Kuchana, Partha. Software Architecture Design Patterns in Java. Auerbach, 2004.

Larman, Craig. Agile & Iterative Development: A Manager's Guide. Addison-Wesley, 2003.

Loughran, Steve and Erik Hatcher. Ant in Action: Java Development with Ant, Second Edition. Manning Publications, 2007.

Mann, Kito. JavaServer Faces in Action. Manning Publications, 2005.

Manns, Mary Lynn and Linda Rising. Fearless Change: Patterns for Introducing New Ideas. Addison-Wesley, 2004.

Rainsberger, J. B. JUnit Recipes: Practical Methods for Programmer Testing. Manning Publications, 2005.

Ship, Howard M. Lewis. Tapestry in Action. Manning Publications, 2004.

附加资源

Ambler, Scott W. Agile Database Techniques: Effective Strategies for the Agile Software Developer. Wiley, 2003.

Beck, Kent. Test Driven Development: By Example. Addison-Wesley, 2002.

Cohn, Mike. Agile Estimating and Planning. Prentice Hall PTR, 2005.

Duvall, Paul, Steve Matyas, and Andrew Glover. Continuous Integration: Improving Software Quality and Reducing Risk. Addison-Wesley, 2007.

Goetz, Brian, Tim Peierls, Joshua Bloch, Joseph Bowbeer, David Holmes, and Doug Lea. Java Concurrency in Practice. Addison-Wesley, 2006.

Meszaros, Gerard. xUnit Test Patterns: Refactoring Test Code. Addison-Wesley, 2007.

Newkirk, James W. and Alexei A. Vorontsov. Test-Driven Development in Microsoft .NET. Microsoft Press, 2004.

Schwaber, Ken and Mike Beedle. Agile Software Development with SCRUM. Prentice Hall, 2001.

在线资源

下列URL在本书出版时都能正常访问。但随着时间推移，不排除其中某些URL会有所改变。

Agical AB. RMock: Java Mock Object Framework to Use with JUnit. http://rmock.sf.net.

Agile Finland Community. Agile Finland Community Wiki. http://wiki.agilefinland.com.

Apache Community. Apache Ant Community Wiki. http://wiki.apache.org/ant.

———. Apache Ant: A Java-Based Build Tool. http://ant.apache.org.

———. Apache Ant Manual. http://ant.apache.org/manual.

———. Apache Maven: A Java-Based Build Tool. http://maven.apache.org.

———. Continuum: A Continuous Integration Server. http://maven.apache.org/continuum.

———. Jakarta Commons Lang: A Utility Library for the java.util API. http://jakarta.apache.org/commons/lang.

———. Jakarta Commons VFS: A Virtual File System API for Java. http://jakarta.apache.org/commons/vfs.

———. Wicket: A Component-Based Web Development Framework for Java. http://wicket.sf.net.

Ashley, Nick, Graham Tackley, and Sam Newman. dbdeploy: A Database Change Management Tool. http://dbdeploy.com/.

Atlassian Software Systems Pty Ltd. Bamboo: A Continuous Integration Server. http://www.atlassian.com/software/bamboo.

Atomic Object LLC. System Testing in Ruby. http://atomicobject.com/pages/System+Testing+in+Ruby.

Bossicard, Vladimir R. PrivateAccessor: A Utility Class for Overriding Access Modifiers in Java.http://junitaddons.sourceforge.net/junitx/util/PrivateAccessor.html.

Bowler, Mike. GSBase: A JUnit Extension of Test Utilities. http://gsbase.sf.net.

Cenqua. Clover: Code Coverage Analysis Tool for Java. http://www.cenqua.com/clover.

Cunningham, Ward. Fit: Framework for Integrated Test. http://fit.c2.com.

Diehl and Associates. Mckoi Database: A Lightweight, Java-Based Relational Database. http://mckoi.com/database.

Feathers, Michael. “The ‘Self'-Shunt Unit Testing Pattern.” January 2001. http://www.objectmentor.com/resources/articles/SelfShunPtrn.pdf.

———. “The Humble Dialog Box.” August 2002. http://www.objectmentor.com/resources/articles/TheHumbleDialogBox.pdf.

———. “Working Effectively With Legacy Code.” February 2002. http://www.objectmentor.com/resources/articles/WorkingEffectivelyWithLegacyCode.pdf.

Fowler, Amy. “A Swing Architecture Overview.” http://java.sun.com/products/jfc/tsc/articles/architecture/.

Fowler, Martin. “Continuous Integration.” May 1, 2006. http://www.martinfowler.com/articles/continuousIntegration.html.

———. “Mocks Aren't Stubs.” January 2, 2007. http://www.martinfowler.com/articles/mocksArentStubs.html.

———. Passive View. July 18, 2006. http://www.martinfowler.com/eaaDev/PassiveScreen.html.

———. Supervising Controller. June 19, 2006. http://www.martinfowler.com/eaaDev/SupervisingPresenter.html.

Freeman, Steve, Tim Mackinnon, Nat Pryce, Mauro Talevi, and Joe Walnes. JMock: A Dynamic Mock Object Library for Java. http://www.jmock.org.

hsql Development Group. HSQLDB: A Lightweight, Java-Based Relational Database. http://www.hsqldb.org.

Hubbard, Charlie and Prashant Dhokte. PrivilegedAccessor: A Utility Class for Overriding Access Modifiers in Java. http://groups.yahoo.com/group/junit/files/src/PrivilegedAccessor.java (requires a Yahoo! account).

Jeffries, Ron. “Essential XP: Card, Conversation, Confirmation.” August 30, 2001. http://www.xprogramming.com/xpmag/expCardConversationConfirmation.htm.

———. List of testing-related software. http://www.xprogramming.com/software.htm.

Koskela, Lasse. Bean Inject: Dependency Injection Framework for Java. http://www.laughingpanda.org/mediawiki/index.php/Bean_Inject.

———. Introduction to Code Coverage. January 2004. http://www.javaranch.com/newsletter/200401/IntroToCodeCoverage.html.

———. JspTest: A JUnit Extension for Unit Testing JavaServer Pages. http://sourceforge.net/projects/jsptest.

Martin, Robert C. “Design Principles and Design Patterns.” January 2000. http://www.objectmentor.com/resources/articles/Principles_and_Patterns.pdf.

———. “SRP: The Single Responsibility Principle.” February 2002. http://www.objectmentor.com/resources/articles/srp.pdf.

Meszaros, Gerard. xUnit Test Patterns. http://www.xunitpatterns.com.

Mort Bay Consulting. Jetty: An Embeddable Java EE Web Container. http://jetty.mortbay.com.

Mugridge, Rick. FitLibrary: An Extension Package for Fit and Fitnesse. http://sourceforge.net/projects/fitlibrary.

NetBeans Community. Jellytools Module: Jemmy Extension for Creating UI Tests for NetBeans IDE. http://jellytools.netbeans.org.

———. Jemmy: UI Testing Library for Java. http://jemmy.netbeans.org.

Payne, Bob. Agile Toolkit Podcast. Interview with Arlo Belshee at Agile 2005: “Promiscuous Pairing and the Least Qualified Implementer.” http://media.libsyn.com/media/agiletoolkit/ArloBelsheeAgile2005.mp3.

Perrotta, Paolo. The Virtual Clock Test Pattern. March 1, 2003. http://www.nusco.org/docs/virtual_clock.pdf.

Potel, Mike. MVP: Model-View-Presenter, The Taligent Programming Model for C++ and Java. 1996. http://www.arsip.or.id/free_doc/m/mvp/mvp.pdf.

Pragmatic Programmers, LLC. “Tell, Don't Ask.” http://www.pragmaticprogrammer.com/ppllc/papers/1998_05.html.

Rothman, Johanna. “What Does It Cost You To Fix A Defect? And Why Should You Care?” 2000. http://www.jrothman.com/Papers/Costtofixdefect.html.

Ruby on Rails Community wiki. Understanding Migrations. August 1, 2007. http://wiki.rubyonrails.com/rails/pages/UnderstandingMigrations.

Saff, David, Erich Gamma, Kent Beck, and Erik Meade. JUnit: A Simple Framework for Writing and Running Automated Tests. http://www.junit.org.

Schuh, Peter and Stephanie Punke. “ObjectMother: Easing Test Object Creation in XP.” 2001. http://www.agilealliance.org/system/article/file/910/file.pdf.

SourceForge.net. Cobertura: Code Coverage Analysis Tool for Java. http://cobertura.sf.net.

———. CruiseControl: A continuous integration server. http://cruisecontrol.sf.net.

———. DbUnit: A JUnit Extension for Testing Database Code. December 28, 2006. http://dbunit.sf.net.

———. EMMA: Code Coverage Analysis Tool for Java. http://emma.sf.net.

———. Joda-Time: A Time and Date API to Replace the Standard java.util Classes. http://joda-time.sourceforge.net.

———. PMD: Static Analysis Tool for Java. http://pmd.sf.net.

———. SUnit: The Mother of All Unit Testing Frameworks. http://sunit.sf.net.

Splint Community. Splint: Static Analysis Tool for C. http://splint.org.

Tammo Freese. EasyMock: A Dynamic Mock Object Library for Java. http://www.easymock.org.

Urban{code}, Inc. AnthillPro: A Continuous Integration Server. http://www.urbancode.com.

World Wide Web Consortium. Logging Control in W3C httpd. July 1995. http://www.w3.org/Daemon/User/Config/Logging.html#common_logfile_format.

———. XML Path Language (XPath). November 16, 1999. http://www.w3.org/TR/xpath.

Yahoo! Inc. JUnit Group and Online Mailing List. http://groups.yahoo.com/group/junit.

Table of Contents

版权信息

版权声明

前言

关于本书

第一部分　TDD入门

第1章　综述

1.1　挑战：用正确的方法解决正确的问题

1.2　解决方案：测试驱动

1.3　正确地做事：TDD

1.4　做正确的事：ATDD

1.5　TDD工具

1.6　小结

第2章　TDD入门

2.1　从需求到测试

2.2　选择第一个测试

2.3　广度优先，深度优先

2.4　别忘了重构

2.5　添加错误处理

2.6　无穷尽的测试

2.7　小结

第3章　小步重构

3.1　探寻解决方案

3.2　以受控的方式修改设计

3.3　进一步延伸新设计

3.4　小结

第4章　TDD的概念与模式

4.1　如何编写及通过测试

4.2　重要的测试概念

4.3　近处观察测试替身

4.4　提高设计的可测试性的准则

4.5　单元测试模式

4.6　在遗留代码基础上工作

4.7　小结

第二部分　针对特定技术应用TDD

第5章　测试驱动Web组件

5.1　在60秒内介绍Web应用中的MVC

5.2　控制器

5.3　用测试先行的方法构建视图

5.4　在基于控件的Web框架基础上TDD

5.5　小结

第6章　测试驱动数据访问

6.1　探索问题领域

6.2　用单元测试驱动数据访问

6.3　编码前写集成测试

6.4　集成测试实战

6.5　为集成测试填充数据

6.6　使用单元测试还是集成测试

6.7　文件系统访问

6.8　小结

第7章　测试驱动不可预测功能

7.1　测试驱动时间相关功能

7.2　测试驱动多线程代码

7.3　标准同步对象

7.4　小结

第8章　测试驱动Swing代码

8.1　Swing UI中该测试什么

8.2　可测试UI代码的模式

8.3　测试视图控件的工具

8.4　测试驱动视图组件

8.5　小结

第三部分　基于ATDD构建产品

第9章　解析验收测试驱动开发

9.1　用户故事介绍

9.2　验收测试

9.3　理解过程

9.4　作为团队活动的ATDD

9.5　ATDD的好处

9.6　我们究竟要测试什么

9.7　工具概览

9.8　小结

第10章　用Fit创建验收测试

10.1　Fit是什么

10.2　三个内建夹具

10.3　FitLibrary对内建夹具的扩展

10.4　执行Fit测试

10.5　小结

第11章　执行验收测试的策略

11.1　验收测试该检测什么

11.2　实现方式

11.3　技术相关考虑

11.4　常见问题的处理技巧

11.5　小结

第12章　TDD应用

12.1　成功采用TDD的必要条件

12.2　让其他人参与进来

12.3　如何应对阻力

12.4　如何推进变革

12.5　小结

附录A　JUnit 4简明教程

附录B　JUnit 3.8简明教程

附录C　EasyMock简明教程

附录D　通过Ant运行测试

相关资源

目录