第1章
Chapter 1
多币种货币实例
我们以在WyCash系统中Ward创建的多币种对象作为引子,假设有如下一张报表:
要制作一张多币种报表,我们需要添加货币名称:
当然,对应汇率也需要指明:
$5 + 10 CHF = $10 if rate is 2:1
$5 * 2 = $10
为了生成改进的报表,需要怎样做呢?换句话说,什么样的测试集,当其中的测试全部通过时,可以表示已有的程序代码令我们有信心说它正确地生成了报表呢?
我们能够添加两种不同币种的金额,并且以给定的比率可以相互转换结果。
我们能够用金额(每股价格)乘以一个数字(股票数目)得到总金额。
我们还将列出待办事项提醒我们都需要做哪些事情,用以保持精神集中,并且告诉我们何时应该完工。在开始进行第一项的时候,会将它标黑。当完成的时候将它划掉。当我们考虑写出另一个测试的时候,将测试添加到测试列表中。
就像你从列表中看到的那样,首先可以从乘法算式着手。那么,最先需要的是什么样的对象呢?这可真是个难题。我们还是不从对象而从测试入手吧(我一直这样提醒自己,所以我假设你也和我一样地不开窍吧)。
让我们再试一次。首先需要的是什么样的测试呢?请看列表,首个测试看起来比较复杂。要么从小处着手,要么是一筹莫展。不就是乘法吗,能难到哪里去呢?我们首先就从乘法开始。
在写测试代码的时候,我们会想象出利于操作而近乎完美的接口。并且自说自话地描述从外部看操作是什么样的情况。可是我们设想的场景并不是总能成为现实,因而最好以合适的API作为开始,然后事后再加工,而不要一开始就把事情弄得复杂、难看,以及“真实”。
下面是一个乘法运算的示例:
public void testMultiplication() {
Dollar five = new Dollar(5);
five.times(2);
assertEquals(10, five.amount);
}
(我懂的,我懂的。这里面涉及public字段,副效应,用整数来表示货币金额等问题。这才刚开始嘛。我们会将这些缺点记录下来,然后继续前进,我们会得到一个结果为失败的测试,然后尽可能快地让它变成通过。)
$5+10 CHF=$10 if rate is 2:1
$5*2=$10
Make “amount”private
Dollar side-effects?
Money rounding?
刚刚敲进去的测试代码(在稍后讨论JUnit测试框架的时候,我会解释在哪里以及如何输入这些测试)甚至不能通过编译。这很容易修复。即使在测试无法运行的情况下,我们怎么能够做最少的工作而让测试通过编译呢?现在有以下4个编译错误:
缺少“Dollar”类的定义。
缺少构造方法。
没有找到“Times(int)”方法。
没有找到“amount”字段。
让我们一个个地来解决上述问题(我在找一些可以数字化的进度测量值)。通过定义Dollar类,就能够消灭其中一个错误:
Dollar
class Dollar
3个错误了。现在我们需要一个构造方法,它只需使测试能够通过编译,还不用做其他任何事情:
Dollar
Dollar(int amount){
}
2个错误了。我们需要一个times()方法的哑实现。再强调一下,我们将做尽可能少的工作来使测试通过编译:
Dollar
void times(int multiplier) {
}
1个错误了。这需要我们最终添加一个amount字段:
Dollar
int amount;
搞定!现在我们可以运行这个测试并且看到它给出了失败的运行结果,如图1-1所示。
图1-1 失败的运行结果
你现在看到了令人担忧的红色指示条。测试框架(在这里指JUnit)运行了最初的那小段测试代码,而我们注意到了,期望的结果是“10”,实际的结果却是“0”。这是让人高兴不起来的。
不能这么说,其实不是这样的,失败也是一种进步。现在我们有了对失败的具体判断。这总比只是含糊地知道我们的测试失败了要好。编程问题也从“提供多币种”变成了“让测试代码运行起来,而后让其他测试也运行起来”。更简单了。也不必有太多的担心,我们能够让这个测试运行起来了。
也许你不太喜欢目前的解决方法,但是现在得到的并不是最佳答案,目标仅是通过当前的测试。稍后,我们会把事情做得更完美、更漂亮。
下面是我可以想到的一点改动,它能使测试获得通过:
Dollar
int amount = 10;
我们现在看到了令人感到愉悦的绿色指示条,如图1-2所示。
图1-2 测试通过
哦,快乐吧!哦,惊喜吧!别高兴得太早,淘气鬼。事情还没完呢。世界上很少能有什么输入会使得如此受限的、糟糕的、幼稚的实现就能通过测试。我们在继续前要将它泛化。请记住,周期如下:
1. 添加一个测试。
2. 运行所有的测试并查看失败的测试。
3. 对测试进行微小的改动。
4. 运行所有的测试并看到其通过。
5. 通过重构去掉重复部分。
依赖和重复
Steve Freeman指出,测试和编码所包含的问题并不在于重复(重复的问题还没有解释,后面会尽快讲清楚)。问题在于编码与测试之间的依赖性—你不能更改它们其中之一而对另外的部分视而不见。我们的目的在于不对程序进行修改并且能够写出“讲得通”的测试,而这个用现在的实现方法是不可能做到的。
依赖是各种规模的软件开发都会遇到的重要问题。如果某家供应商的数据库产品所对应的SQL语句实现细节散布于整个代码中,而你决定使用其他的数据库产品时,你会发现你的代码依赖于数据库产品供应商,你无法做到不改变程序代码而对数据库进行修改。
如果依赖性是症结所在,那么重复则是所表现的症状。重复问题多数是以重复的逻辑呈现的,即相同的表达式在代码的多个地方重现。对象在将重复逻辑抽象掉这方面是很出色的。
与生活中的大多数问题不同,后者将症状消除反而使问题在其他地方以更严重的形式出现,但在程序中消除重复部分的同时也可以消除依赖性。这也是测试驱动开发的第二条准则何以现身的原因。在继续下一个测试前,通过消除冗余,仅仅通过这一个步骤就让我们获得了使下一个测试得以通过的最大可能性。
我们已经做了1~4项。现在准备去掉重复部分。但哪里是重复部分呢?你常常看到两段代码之间存在重复部分。而这里所讲的重复部分是测试中的数据与程序代码中的数据之间的重复。没看到吗?我们就来写一段如何呢?
Dollar
int amount = 5 * 2;
上面的乘法结果的“10”一定来自于某个地方。在头脑中所做的乘法运算如此之快,我们甚至没有意识到。“5”和“2”现在位于两个不同的地方,在继续推进之前,我们要坚决地去掉重复部分。这就是规则。
这里并不存在将“5”和“2”去掉的某个步骤。然而,如果我们将变量amount的设置从对象的初始化代码换到times()方法中会怎么样?
Dollar
int amount;
void times(int multiplier) {
amount = 5 * 2;
}
测试仍然可以通过,指示条还是绿色的。我们感觉也还可以。
你是不是感觉这些改进微不足道?请记住,测试驱动开发的要点不在于采取了哪些措施,而在于能够取得这些微小进步本身。我们需要日复一日地编码从而完成这些细小的改进吗?当然不是,但当事情稍有不妥的时候,我很高兴我可以这样做。请自己选择一个示例并尽力对其完成细小的改进。如果你能够做到很小的改进,也一定可以完成你所需要规模的改进。如果只是做后者,那么你将永远不知道更小的改进是否合适。
防御性的问题先放一边,我们刚才说到哪里了?哦,对,我们正在消除测试代码和程序代码之间的重复部分。从哪里可以得到“5”呢?如果我们把它存到amount变量中,那就成了传递给构造方法的值:
Dollar
Dollar(int amount) {
this.amount= amount;
}
可以在times()方法使用变量amount:
Dollar
void times(int multiplier) {
amount= amount * 2;
}
参数multiplier的值是2,那么我们可以用常量来替换这个参数:
Dollar
void times(int multiplier) {
amount= amount * multiplier;
}
为了全面地体现Java语法知识,我们准备使用“*=”操作符(这样做其实可以减少重复的内容):
Dollar
void times(int multiplier){
amount *= multiplier;
}
$5 + 10 CHF = $10 if rate is 2:1
$5 * 2 = $10
Make “amount” private
Dollar side-effects?
Money rounding?
我们现在可以将第一个测试标记为完成。接下来要做的事情是关注这些奇怪的副效应。不过,首先需要检查已完成的事情:
使一连串的已知并且所需的测试运行起来。
描述了如何观察代码段所对应的操作。
暂时忽略关于JUnit相关的细节。
使测试代码的哑实现通过编译。
通过极端的手段使测试运行。
泛化程序代码,使用常量代替变量。
将待办事项添加到列表中,而非毕其功于一役。
……
展开