引言
TDD 真的很敏捷吗?
测试驱动开发 TDD(Test-Driven Development)是时下一种非常“酷”、非常“靓”的软件开发方法,TDD
也是一个相当“时髦”的词(话语),它因 Kent Beck 大师的倡导而全球闻名,成为众多敏捷方法、敏捷实践中的一种重要方法。2003
年 Kent 发表了专著 Test-Driven Development (by example)。
TDD 无疑是一种创新方法,或者说是 Kent 独创的方法(当然,Kent 老是把贡献归功于自己的老搭档
Ward Cunningham),而“创新”方法的问题就在于它的“创新性” —— 除了创造者之外,过去没有人熟悉它,没有人想到它,或者说大家过去都不是那么干的,所以
TDD 才能引起轰动,才能激发大家学习的好奇与热情。
那么,问题就来了:难道过去的方法都不行了吗?世界上只有 Kent Beck 一个顶呱呱的、最聪明的程序员吗?答案肯定是否定的。道理其实也是很简单,过去十年里世界上大概有成百上千个相当成功的项目,可这些项目也没有用
TDD,Kent 也没有参加过这些项目呀。
过去十年来,我也一直在编程(从海底、平原到高原,各个层次上的),我本人也亲历过不少成功的项目,有些还是相当
tough 的。我还认识不少国内外一流的程序员,他们都远比我聪明。可在我的印象中,他们好像也不完全是像
Kent 和 TDD 那么做的,也就是说他们拥有不同的成功故事。
一两个人的最佳实践与成千上万人的最佳实践,含金量明显不同。所以,搞清楚 TDD 与我们传统的最佳开发方法(最佳实践)的真正区别就很有必要了。TDD
到底与我们这些普通人(大众成功者)过去的做法有何不同?TDD 的真正价值到底在哪里?它对传统是颠覆,还是互补?它有没有缺陷、漏洞?我们怎么用
TDD,让它发挥最大价值?我想,通过认真、严肃的比较和研究,这些问题就能逐一搞清楚。
Kent 在 TDD 这本书中一共用了 17 个章节讲述了一个 Money 测试驱动开发的故事。在 2006
年的这个暑假里,我打算用我所掌握的、熟悉的方法也把这个 Money 案例从头到尾做一遍。其实上周的某个下午,我已经在
Eclipse 上把结果(答案)做出来了,花了近三个小时。从这次试验中我获得了不少有趣的结果,剩下的工作就是如何用可亲的文字把我的研究成果告诉大家,与大家分享。
我把我所采用的这个常规方法叫做 UDD(Use Case-Driven Development)。现在外面叫“用例驱动”的方法有很多,所以我只好再加一个前缀,叫
ZX-UDD(暂定名)。我有可能把这个案例分析做成一本电子书(open book with source)。当然,这本书还暴露了我所拥有的,也是天下所有程序员所共有的?——
证明我们不是总是比大师笨,即使诸葛亮也有不敌三个臭皮匠的时候(joking)。
UDD vs TDD
在开始案例讨论之前,让我们先做一下简短的理论性分析。
既然测试可以驱动开发,那么请问,什么驱动测试呢?答案是:软件需求,具体来说就是反映用户使用目标的 Use
Case(用例)。我们知道,test case 是用例的一个实例(instance),与用例执行的一个情节(scenario,又译场景)相对应。
好奇的读者可能会问,那又是什么驱动软件需求呢?
我判断,Kent Beck 大师讲给我们娃娃程序员们听的 TDD 故事好像是不大完整的。在开发一个软件之前,你连怎么用或者你想怎么用都不知道,又如何测试,测些啥东西呢?需求不清,需求错误,将会使所谓的“测试驱动”开发成为无的之矢。软件开发一定是需求驱动的,而且是用户目标驱动的。Kent
好像没有直说,在 XP 中实际上驱动测试的是“用户故事”(User Story)。 不管用例驱动,特性驱动,还是用户故事驱动,本质上、根源上必然是需求驱动,TDD
方法也不能例外,否则就要出大问题。
那么,到底什么是 TDD?让我们回到术语的定义上。大师特意把这种开发方法叫做 TDD 必然有他的考虑。从名称上看,Test-Driven
Deveolpment 中有两个关键词:Test 和 Development。
首先,Development 是 TDD 的核心内容,TDD 讲的是一种“开发”方式,注意,测试仅仅是软件“开发”中一部分工作,TDD
本身并不是一种测试方法”是有道理的,TDD 讲的是如何通过编写“测试”,尤其是单元测试,来驱动软件的设计和编程。
“测试驱动” 在 TDD 惺嵌ㄓ?修饰语,是用来修饰 Development 模?Kent 心目中的
Test 应该包括单元测试和系统(Acceptance)测试。
TDD 的本质究竟是什么?
我们知道 XP 中的需求是以“用户故事”(User Story)的形式描述的,而用户故事实质上就是一种软件“特性”(Feature),所谓的“用户故事”不过是特性的一个别名而已。为什么
Kent 不把 TDD 叫做 F(eture)DD 呢?大概是因为 FDD(Feature Driven
Development)这个名称已经被别人用去了,FDD 是另一种知名的敏捷方法。当然,Kent 的用户故事与
FDD 的 Feature 是两种略有区别的特性。
国内外好多人把 TDD 简化成:“先写单元测试,再写实现程序,让单元测试通过”。问题是,在需求不稳定的情况下,这样的
TDD 会有什么问题?会不会带来许多冗余的工作?
TDD 与传统方法之间的区别大致可以用下图表示。对于一名以传统方式工作的普通程序员来说,他首先会明确需求:在今天上午的这几个小时之内,自己要做些什么,为正在开发中的软件、系统添加哪些新功能。然后,他会根据已经明确的需求和开发任务,快速设计出一个巧妙的解决方案来满足需求,包括可行的算法、数据结构和软件结构(模式),这样的设计通常可以在大脑中完成。接着,他就开始编写代码,以便实现自己既定的设计方案。在编程的过程中,他可能调整自己的设计方案。源码写好后,他会对程序进行编译、链接(build,建立)和集成(integration),目的是在试验平台上跑一下新鲜出炉的程序,看看新功能是否做到了。这样的设计、编码、建立过程,往往会反复好几次,直到程序员确认需求已实现、自己的任务已完成为止。
根据以上这幅关系图,我们可以得出一些重要的推论。需求不稳定,必然会带来单元测试的不稳定。
对于程序员的单兵战术而言,等需求大致稳定下来之后,再补写单元测试也不迟。在获得稳定的系统架构之前,系统测试远比单元测试重要。
ZX-UDD 推荐的做法:先写 Use Case,有了用例,我们就可以在编写任何代码之前得到一批系统黑盒测试的
Test Case,然后通过系统开发、系统测试(和集成测试)稳定需求,等到用例需求和软件架构稳定下来之后,再完成重要部件的单元测试。
如果一开始就把过多的精力全部集中到单元测试中,那么你很可能选错了主战场,本末倒置,得不偿失。
下图是 UDD 推荐的程序员开发流程。
把 UDD 与 TDD 进行比较,我们可以看到两者的明显不同。
ZX-UDD 并不是什么全新的方法或技术。
根据以上分析,我相信 UDD 是比 TDD 更为敏捷、更为自由、更为优化、效率更高和更为可靠的方法,当然也是更加充满人性、以人为本(人性化)、闪耀着夺目的人道主义、人本主义光芒的方法。我将用一本书的篇幅来验证我的这个判断。
ZX-UDD 的另一个重要组成部分是利用 UML 对软件的设计进行建模,这部分建模当然是敏捷的(agile),没有人喜欢笨拙的建模。简单的只需几秒钟可以迅速在人的大脑中完成,复杂的则可以画在纸上、白板上,记录在建模工具生成的电子文档中。
贡献一个我的“面向对象建模十六字口诀”:
由外而内,
层次分明。
动静结合,
逐步求精。
从第 1 章开始吧。一开头 Kent 给了我们一份非常简单的需求 —— 为客户制作一份多币种的证券(债券)清单:
证券(Instrument) 份数(Share) 单价(Price) 总价(Total)
IBM 1,000 份 25 USD 25,000 USD
GE 400 份 150 CHF 60,000 CHF
资产总额(Total) 65,000 USD
* 汇率(exchange rate)是 1.5 : 1(法郎 CHF -> 美元 USD)
按照 TDD 的做法,首先是创建测例(test case),运行测试,这时的测试当然是失败的,然后根据测例做出设计,创建类,实现方法,让测试通过。在编写真实代码之前,首先写出测试(尤其是单元测试),这恐怕是过去世界上大部分程序员所不习惯的做法。
那么,UDD 或者常规的方法如何做呢?我们从需求也就是 Use Case 开始,走的是需求、设计、编码、测试这条路径。
用例名称:打印证券清单!
主用角(Primary Actor):客户
范围:系统(可能小到只有几个类)
层次:用户目标层(海平面)
后置条件:系统打印该客户帐号下已购买的所有币种的证券清单,包括每支证券的单价、份数和总价以及该客户账户(Account)下的资产总额(转换成用户指定的币种,缺省情况下为美元)。
前置条件:
1)用户已登录,用户的账户、帐号已知。
触发事件:
用户选择打印证券清单。
基本流:
1. 用户根据系统提示选择结算币种(用于计算资产总额),缺省值为美元。
2. 系统读取该用户帐号下的所有证券信息,打印每支证券的名称、份数、单价和总价(即证券余额,等于单价 *
份数),总价用原币种表示。
3. 系统根据该账户下的证券币种读取相应的结算汇率,把所有证券余额都统一转换成结算币种。
4. 系统通过累加该账户下所有证券余额,计算出以结算币种表示的客户资产总额并显示。
扩展流:
3a. 某支证券的币种到结算币种的转换汇率不存在:
3a1. 系统告知用户结算汇率不存在,无法计算资产总额。
3a2. {end 用例结束}
情节实例:
假设用户 Charlie 的帐户下有两支债券,分别为 IBM 和 GE。
[情节 1] Charlie 登录系统后,选择“打印证券清单”;系统要求输入结算币种,Charlie 选择了缺省币种
—— 美元;系统打印出两支证券的信息:IBM 1,000 份,现价 25 美元/份,总价为 25 * 1,000
= 25,000 美元;GE 400 份,现价 150 法郎,总价为 150 * 400 = 60,000
法郎。系统查询到法郎到美元的汇率是 1.5 : 1,于是把 GE 债券 60,000 法郎转换为 60,000
/ 1.5 = 40,000 美元,显示 Charlie 账户下的资产总额为 25,000 + 40,000
= 65,000 美元。
好了,用例写完了,是不是对我们要做什么很清楚了?是啊,这个需求很简单,你需要多少时间来完成?
接下去,你会做什么?“由外而内,动静结合”,“动”的部分(动态行为)勾勒完了,下一步当然就是“静” (静态结构)了。读到这里,你是否已经按捺不住,不肖我讲,你的脑海里是否早就出现了与下面类似的一幅类图?如果是的,那么恭喜你:这说明你是一个标准的
OO 程序员 —— 满脑子都是活蹦乱跳的对象,它们总是会抑制不住地蹦出来!
我想至少应该有这样几个类。我们要打印一份客户的资产清单/报告,显然应该有 Report 这个类,有 Report.print()
这个操作,我打算把 Report 作为程序 main 函数的入口,我们的目的不就是为了打印一份报告吗?打印的内容是证券信息,Kent
给我们的那张表格已经提示了,那么显然应该有 Instrument 类,这是一个核心类,表格当中的名称(name)、份数(shares)、单价(price)和总价(total)都可以作为它的属性。这些证券属于谁?当然是银行客户(证券投资者),它所购买的证券都存放在一个账户(Account)下,账户应该有一个帐号(id)。货币的汇率(rate)放哪里,它显然不属于
Report、Instrument、Account 这三个类当中的任何一个,姑且作为银行(Bank)这个类的属性吧。
你会问我,这些类里的操作是从哪儿来的?来自算法。这么简单的应用题,打印这份报告的算法大概早就在你的大脑里现身了,而且用不了一秒钟。我用
UML 协作图把它画了出来与你核对,是不是和你想的一样?
可能你要着急了,我们不是在讨论 Money 案例吗,怎么我们的主角 —— Miss Money (玛尼小姐)到现在还不隆重登场?我们的测试又在哪里呢?从这张图中我们可以看出,有三个关键的任务:两次乘法(一次是计算单支证券的总价,一次是货币转换)以及一次加法(把所有证券的价格加起来)。那么,如何来表示证券的价格(price)呢?显然,证券价格
price 不是一个简单类型,它由数额和货币单位(美元、法郎、加元、人民币等等)两部分组成,因此应该用一个单独的类来表示它,我们叫它
Money。
看到这里,你可能会问:Kent 在写完第一个测例 testMultiplication() 后,首先创建的是
Dollar 类,为什么我们没有创建 Dollar 反而马上就能提取出 Money 类?这个问题问得太好了
—— 这正好是 UDD 与 TDD 的一个显著不同之处!
如果按照 Kent 的做法,先出来一个 Dollar,那么 150 法郎怎么表示?必然还得有一个 Franc
类,人民币又怎么办呢?我们还得建一个 RMB 类。其他外币呢?可以依次类推。有一个问题,Instrument
怎么访问这些外币类,因为每支证券都需要获得单价(price)?大概有两种做法,见上图的方案 b 和方案
c。你觉得这两个方案怎么样?
把 Money 以及其他类主要的属性,操作参数、返回值的数据类型添上后,类图如下:
这叫什么?请记住,这叫“先构”(prefactor)—— 一种重要的软件设计方法!在没有写一行 Java
代码之前,我的脑子里竟然就有了一张类图和用例说明,虽然它们还不完整,还不成熟,甚至有点丑陋。为了证明我做到了,我把它们写了出来,画了出来。读者朋友,你看到了吗,这多么神奇啊!你是不是对我画的类、属性、操作和关系不太满意,它们还很不完整,很不准确,比如缺少属性的数据类型、操作的返回类型和参数定义等。没关系,别急、慢慢来,我们还可以不断补充、调整和重构(refactor)!至少目前程序的大致框架已经浮现出来了,不外乎就是那么几个类,而且这些类看起来都是需要的。
下一步,我们做什么?先写测试,还是先写代码?ZX-UDD 建议你马上编码(实现业务逻辑代码),用最短的时间实现你的用例!
Class Report {
protected static Account _acc;
public static void main(String[] args) {
init();
print();
}
public static void init() {
// 对 bank、_acc、instruments 初始化
Bank bank = new Bank();
// 添加汇率
bank.addRate("CHF", "USD", 1.5);
// 在某银行下创建账户
_acc = new Account(bank);
Money m1 = new Money(25, "USD");
Money m2 = new Money(150, "CHF");
Instrument ibm = new Instrument(m1, 1000, "IBM");
// 1000 份 IBM 债券
Instrument ge = new Instrument(m2, 400, "GE");
// 400 份 GE 债券
// 向账户添加证券
_acc.addInstrument(ibm);
_acc.addInstrument(ge);
}
public static void print() {
...
}
}
目前这段代码编译显然是通不过的 |