TDD可以应用在多个层级上。表11-1列出了通常会采用TDD的四个测试层级。
表11-1 TDD的测试层级
BigDecimal
类中的方法集成测试通过测试验证类之间的交互测试Currency
类以及它如何跟BigDecimal
交互系统测试通过测试验证运行的系统从UI到Currency
类测试会计系统系统集成测试通过测试验证运行的系统,包括第三方组件测试会计系统,包括它与第三方报表系统间的交互在单元测试中使用TDD是最容易的,如果你对TDD不熟悉,这一层就是个很好的起点。本节主要讲述如何在单元测试层中使用TDD。后续章节会讨论其他层级,包括第三方组件和子系统的测试。
提示 处理没有或只有很少测试的遗留代码是个恐怖的任务。我们几乎不可能把所有测试都追加上,因此,应该只是为添加的新功能加上测试代码。请参阅Michael Feathers的Working Effectively with Legacy Code1(Prentice Hall,2004)获取更多帮助。
1 中文版《修改代码的艺术》已由人民邮电出版社于2007年出版(更多信息请参见http://www.ituring.com.cn/book/536)。——编者注
我们一开始会简单介绍一下TDD的基本前提——红—绿—重构循环——用JUnit测试计算剧院门票销售收入的代码2。只要遵照红—绿—重构循环,基本上就可以使用TDD!之后我们会探究一下红—绿—重构循环背后的思想,让你对为什么应该采用这种技术有更清楚地认识。最后我们将介绍JUnit这个公认的Java开发者测试框架,讲解它的基本用法。
2 销售剧院门票在我的家乡伦敦是个大生意,最起码在我们写这本书的时候是。
让我们开始吧,先来一个TDD三步(红—绿—重构)测试计算剧院门票销售收入的实际例子。
11.1.1 一个测试用例
如果你有TDD方面的经验,可以自行决定是否跳过这一节,不过这个小例子中有些新东西。假定有人要你写一个坚若磐石的方法来计算剧院门票的销售收入。剧院会计最初给出的业务规则很简单:
- 门票的底价是30美元;
- 总收入=售出票数*价格;
- 剧院有100个座位。
因为剧院工作人员不懂软件,所以他们现在还必须手工录入门票的销售数量。
如果你做过TDD,应该知道它的三个基本步骤:红、绿、重构。如果刚接触TDD,或者想复习一下,那就请看一下Kent Beck在《测试驱动开发》中对这些步骤的定义:
- 红,写一些不能用的测试代码(失败测试);
- 绿,尽快让测试通过(通过测试);
- 重构,消除重复(经过细化的通过测试) 。
为了让你了解TicketRevenue
应该达到什么效果,请先看一下这些伪代码。
estimateRevenue(int numberOfTicketsSold)if (numberOfTicketsSold is less than 0 OR greater than 100)then Deal with error and exitelse revenue = 30 * numberOfTicketsSold; return revenue;endif
注意,千万别太深入。测试最终会驱动设计,也会部分影响实现。
注意 我们在11.1.2节会涉及开始失败测试的办法,但在这个例子中我们准备写一个甚至还无法编译的测试!
接下来我们先用JUnit写一个失败单元测试。如果你不了解JUnit,请跳到11.1.4节,然后再回来。
1. 编写失败测试(红)
这一步的要点是以一个会失败的测试开始。实际上,这个测试甚至无法编译,因为你还没有TicketRevenue
类!
在跟会计开过一个简短的白板会议后,你意识到测试代码需要覆盖五种情况:售票数量为负数、0、1、2~100,还有大于100。
提示 编写测试代码(特别是牵扯到数值时)有一个很好的经验法则,要考虑值为0/null、1和很多(N)的情况。再进一步考虑N上的其他限制,比如数量为负或超出上限。
我们决定先写一个测试覆盖销售一张门票收入的情况。测试代码看起来应该如代码清单11-1所示(记住这个阶段不用编写完美的通过测试)。
代码清单11-1 为TicketRevenue
编写的失败单元测试
import java.math.BigDecimal;import static junit.framework.Assert.*;import org.junit.Before;import org.junit.Test;public class TicketRevenueTest { private TicketRevenue venueRevenue; private BigDecimal expectedRevenue; @Before public void setUp { venueRevenue = new TicketRevenue; } @Test public void oneTicketSoldIsThirtyInRevenue { //销售一张票的情况 expectedRevenue = new BigDecimal("30"); assertEquals(expectedRevenue, venueRevenue.estimateTotalRevenue(1)); }}
测试期望销售一张门票得到的收入等于30。
但这个测试不能编译,因为有estimateTotalRevenue(int numberOfTicketsSold)
方法的TicketRevenue
类还不存在呢。为了运行测试,可以先随便写一个让测试可以编译的实现。
public class TicketRevenue { public BigDecimal estimateTotalRevenue(int i) { return BigDecimal.ZERO; }}
现在测试代码能编译了,你可以在自己喜欢的IDE中运行它。每种IDE都有自己运行JUnit测试的办法,但一般都能在选中测试类后,从右键弹出菜单中选择运行测试。一旦运行,IDE一般都会更新窗口告诉你测试失败了,因为你所期望的30和estimateTotalRevenue(1);
返回的值不符,它的返回值是0。
失败测试有了,接下来该做通过测试了(变绿)。
2. 编写通过测试(绿)
这一步的要点是让测试通过,但没必要把实现做到完美。给TicketRevenue
类一个更好的estimateTotalRevenue
实现(不会只返回0),可以让测试通过(变绿)。
记住,这一阶段只要让测试通过就行,没必要追求完美。代码可能如代码清单11-2所示:
代码清单11-2 第一版通过测试的TicketRevenue
import java.math.BigDecimal;public class TicketRevenue { public BigDecimal estimateTotalRevenue(int numberOfTicketsSold) { BigDecimal totalRevenue = BigDecimal.ZERO; if (numberOfTicketsSold == 1) { totalRevenue = new BigDecimal("30"); //通过测试的实现 } return totalRevenue; }}
现在再运行测试,通过了!而且在大多数IDE中,会用一个绿条或对勾来表示测试通过。图11-1是在Eclipse中通过测试的界面。
图11-1 Eclipse IDE中表示测试通过的绿条,纸质版印刷出来是中度灰色
接下来的问题是你能不能说“我搞定了”,然后去做下一项工作?我们可以负责任地告诉你:“不是!”你会忍不住想完善前面的代码,那现在我们就开始吧。
3. 重构测试
这一步的要点是看看为了通过测试写的快速实现,确保你遵循了通行的惯例。代码清单11-2中的代码明显可以更清晰、更整洁。你肯定要重构,以减轻自己和他人的技术债务。
技术债务 Ward Cunningham发明的说法,指我们现在临时凑合出来的设计或代码将来会让我们付出更多的成本(工作)。
记住,有了通过测试,可以放心大胆地重构。应该实现的业务逻辑不可能会被忽视。
提示 编写最初的通过测试代码的另一个好处是开发进度可以更快。团队中的其他人可以马上用第一版代码跟更大的代码库一起测试(集成测试及更大范围的测试)。
在代码清单11-3中,我们不想再用魔法数字了——要让票价(30)出现在代码中。
代码清单11-3 通过测试的TicketRevenue
重构版
import java.math.BigDecimal;public class TicketRevenue { private final static int TICKET_PRICE = 30;//不用魔法数字了 public BigDecimal estimateTotalRevenue(int numberOfTicketsSold) { BigDecimal totalRevenue = BigDecimal.ZERO; if (numberOfTicketsSold == 1) { totalRevenue = new BigDecimal(TICKET_PRICE * numberOfTicketsSold); //重构的计算 } return totalRevenue; }}
经过这次重构,代码得到了改善,但很明显它还没有涵盖所有情况(售票数量为负值、0、2~100和大于100)。你不能只是拼命地猜其他情况下的实现应该是什么样,而应该做更多测试驱动的设计和实现。下一节会继续按照测试驱动设计的方式,带你看更多的测试用例。
11.1.2 多个测试用例
按照TDD风格,应该继续为门票销售数量为负值、0、2~100和大于100的情况依次添加测试用例。但还有一种办法,一次写一组测试用例也行,特别是在它们跟最初的测试有关的时候。
注意,这次仍然要遵循红—绿—重构的循环周期。在把这些用例都加上之后,你应该会得到一个带有失败测试(红)的测试类,如代码清单11-4所示。
代码清单11-4 TicketRevenue
的失败单元测试
import java.math.BigDecimal;import static junit.framework.Assert.*;import org.junit.Test;public class TicketRevenueTest { private TicketRevenue venueRevenue; private BigDecimal expectedRevenue; @Before public void setUp { venueRevenue = new TicketRevenue; } @Test(expected=IllegalArgumentException.class) public void failIfLessThanZeroTicketsAreSold { //销量为负值 venueRevenue.estimateTotalRevenue(-1); } @Test public void zeroSalesEqualsZeroRevenue { //销量为0 assertEquals(BigDecimal.ZERO, venueRevenue.estimateTotalRevenue(0)); } @Test public void oneTicketSoldIsThirtyInRevenue { //销量为1 expectedRevenue = new BigDecimal("30"); assertEquals(expectedRevenue, venueRevenue.estimateTotalRevenue(1)); } @Test public void tenTicketsSoldIsThreeHundredInRevenue { expectedRevenue = new BigDecimal("300"); assertEquals(expectedRevenue, venueRevenue.estimateTotalRevenue(10)); } @Test(expected=IllegalArgumentException.class) public void failIfMoreThanOneHundredTicketsAreSold { //销量大于100 venueRevenue.estimateTotalRevenue(101); }}
为通过所有测试(绿)写的基本实现版看起来应该如代码清单11-5所示。
代码清单11-5 通过测试的第一版TicketRevenue
import java.math.BigDecimal;public class TicketRevenue { public BigDecimal estimateTotalRevenue(int numberOfTicketsSold) throws IllegalArgumentException { BigDecimal totalRevenue = null; if (numberOfTicketsSold < 0) { throw new IllegalArgumentException("Must be > -1"); ﹃异常情况 } if (numberOfTicketsSold == 0) { totalRevenue = BigDecimal.ZERO; } if (numberOfTicketsSold == 1) { totalRevenue = new BigDecimal("30"); } if (numberOfTicketsSold == 101) { throw new IllegalArgumentException("Must be < 101"); ﹄异常情况 } else { totalRevenue = new BigDecimal(30 * numberOfTicketsSold); //销量为N } return totalRevenue; }}
有了刚刚完成的实现,现在你的测试就变成通过测试了。
按照TDD循环周期,现在该重构这个实现了。比如说,可以把不合法的numberOfTicketsSold
情况(负数或者大于100)放到一个if
语句中,并用公式(TICKET_PRICE * numberOfTicketsSold)
返回所有合法numberOfTicketsSold
的收入。代码清单11-6应该跟重构之后的代码很像。
代码清单11-6 重构后的TicketRevenue
版本
import java.math.BigDecimal;public class TicketRevenue { private final static int TICKET_PRICE = 30; public BigDecimal estimateTotalRevenue(int numberOfTicketsSold) throws IllegalArgumentException { if (numberOfTicketsSold < 0 || numberOfTicketsSold > 100) { throw new IllegalArgumentException ("# Tix sold must == 1..100"); //异常状况 } return new BigDecimal (TICKET_PRICE * numberOfTicketsSold); //所有其他情况 } }
新的TicketRevenue
类更加紧凑,并且还通过了所有测试!现在你已经完成了整个红—绿—重构循环,可以信心满满地开始实现下一个业务逻辑了。另外,如果你(或会计)发现漏掉了任何边界情况,比如有浮动票价,也可以再次开始一个循环。
我们强烈建议你弄明白红—绿—重构的TDD方式背后的原理,也就是我们接下来要讨论的内容。但如果你没什么耐心,可以直接跳到11.1.4节学习JUnit,或11.2节了解用来测试第三方代码的测试替身。
11.1.3 深入思考红—绿—重构循环
这一节会在前面例子的基础上探索TDD背后的一些思想。我们会再次谈论红—绿—重构循环,你应该还记得第一步是写失败测试。但这也有几种不同的方式。
1. 失败测试(红)
一些开发人员真的喜欢编写编译失败的测试,喜欢等到绿色步骤才提供实现代码。也有一些开发人员喜欢先把测试调用的方法存根写出来,这样虽然测试代码能编译,但还是会失败。我们觉得怎么样都行,随意就好。
提示 这些测试代码是实现的第一个客户,所以应该认真考虑该怎么设计它们:方法定义看起来应该是什么样的。还应该问自己几个问题:该传什么参数进去?期望的返回值是什么?会不会有异常情况?另外,不要忘了测试重要领域对象的
equals
和hashCode
方法。
一旦写完失败测试,就该进入下一阶段了:让它通过。
2. 通过测试(绿)
这一步应该尽量少写代码,只要保证测试通过就行。也就是说你不用把实现做到完美,那是重构阶段的工作。
测试通过之后,你就可以告诉同事,你的代码已经实现了它应该实现的功能,他们可以拿去用了。
3. 重构
在这一步中应该重构实现代码。可以重构的地方数不胜数,但有几个应该重点关注的,比如去掉硬编码的变量或把大方法拆分开。如果是面向对象的代码,则应该遵循SOLID原则。
SOLID原则是Bob大叔(Robert Martin)提出来的,请参见表11-2。要了解更详细的信息,可以参考他的文章“The Principles of OOD”(http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod)。
表11-2 面向对象代码的SOLID原则
提示 我们还要向你推荐Checkstyle和FindBugs这两个静态代码分析工具(第12章还有更多)。Joshua Bloch的Effective Java, Sencond Edition1(Addison-Wesley, 2008)也是好资源,其中有很多Java语言的技巧和窍门。
1《Effective Java中文版》由机械工业出版社于2003年出版。——编者注
测试代码本身的重构是个容易被人遗忘的角落。你可以把通用的设置和拆卸代码提取出来,可以重命名测试以更准确地反应它的测试意图,还可以根据静态分析工具的分析结果做些小修订。
现在你已经能跟上TDD的三步走了,该去熟悉一下JUnit了,它可是在Java里写TDD代码的默认工具。
11.1.4 JUnit
JUnit是公认的Java项目测试框架。当然,除了JUnit还有其他测试框架,比如拥有不少追随者的TestNG,但目前JUnit还是Java测试界的主流。
注意 如果你熟悉JUnit,可以跳到11.2节。
JUnit有三个主要特性:
- 用于测试预期结果和异常的断言,比如
assertEquals
; - 设置和拆卸通用测试数据的能力,比如
@Before
和@After
; - 运行测试套件的测试运行器。
JUnit用简单的注解模型提供了很多重要的功能。
大多数IDE(比如Eclipse、IntelliJ和NetBeans)都内置了JUnit,如果你用的正好是其中之一,就不用自己去下载、安装或配置JUnit了。如果你的IDE没有安装JUnit,可以访问www.junit.org查看它的下载和安装指导1。
1 第12章会讲到JUnit和Maven的集成。
注意 我们用的是JUnit 4.8.2。如果你要练习本章中的例子,建议也用这个版本。
一个基本的JUnit测试包含下面这些元素:
- 用
@Before
标记设置方法,在每个测试运行前准备测试数据; - 用
@After
标记拆卸方法,在每个测试运行完成后拆卸测试数据; - 测试方法本身(用
@Test
注解标记)。
为了多了解一下上面这些元素,我们来看几个非常基本的JUnit测试。
比如OpenJDK团队要你给BigDecimal
类写个单元测试。第一个测试是检查加法(1.5 + 1.5==3.0);
,第二个测试是检查用非数字值创建BigDecimal
实例时会抛出NumberFormatException
异常。
注意 我们在本章的例子中经常同时给出多个失败测试,实现(绿)和重构。这违背了纯粹的TDD 单个测试贯穿红—绿—重构循环的原则,但却可以让我们在本章中放入更多例子。不过在你编码时,应该尽可能地遵守单个测试循环的开发模型。
要运行代码清单11-7,可以在IDE里的源码文件上点击右键,选择运行或测试选项(三个主流IDE中都有显眼的Run Test或Run File选项)。
代码清单11-7 JUnit测试的基本结构
import java.math.BigDecimal;import org.junit.*;import static org.junit.Assert.*; //标准的JUnit导入public class BigDecimalTest { private BigDecimal x; @Before public void setUp { x = new BigDecimal("1.5"); } //❶每个测试之前的设置 @After public void tearDown { x = null; } //❷每个测试之后的拆卸 @Test public void addingTwoBigDecimals { assertEquals(new BigDecimal("3.0"), x.add(x)); } //❸执行测试 @Test(expected=NumberFormatException.class) //❹处理意料中的异常 public void numberFormatExceptionIfNotANumber { x = new BigDecimal("Not a number"); }}
在每个测试运行之前,x
在@Before
区域中被设置为BigDecimal("1.5")
❶。这会确保每个测试处理的都是已知值x
,而不是被之前运行的测试修改过的中间值。在每个测试运行之后,在@After
区域中确保x
被设为null
❷(以便x
可以被垃圾收集)。然后用assertEquals
(JUnit众多静态assertX
方法之一)测试BigDecimal.add
的返回结果是否符合期望❸。为了处理预期的异常,在@Test
上加上了可选的expected
参数❹。
进入TDD最佳状态的最好办法就是动手实践。把TDD原则牢牢印在你的脑海里,把JUnit框架搞明白,你就可以开始了!通过这些例子你也能看出来,单元测试级的TDD很容易掌握。
但所有TDD从业者最终都要测试使用依赖项或子系统的代码。下一节就会讲到那些代码的测试技术。