测试驱动开发-1
测试驱动开发
Java 版本
1. 多币种资金
资金报表
票据 | 股票 | 股价 | 合计 |
---|---|---|---|
IBM | 1000 | 25 美元 | 25000 美元 |
Novartis | 400 | 150 瑞士法郎 | 60000 瑞士法郎 |
总计 | 65000 美元 |
汇率表
源币种 | 兑换币种 | 汇率 |
---|---|---|
瑞士法郎 | 美元 | 1.5 |
计划清单(to-do list)
当瑞士法郎与美元的兑换率为 2:1 的时候,5 美元 + 10 瑞士法郎 = 10 美元
5 美元 * 2 = 10 美元
怎样才能产生上面经过修订的报表呢?
- 在假设已经给定汇率的情况下,要能对两种不同币种的金额相加,并将结果转换为某一种币种;
- 要能将某一金额(每股股价)与某一个数(股数)相乘,并得到一个总金额。
建立一个计划清单(to-do list),开始某一项工作用粗体表示,就像这样。完成某一项工作将其划去,就像这样。如果想起其他要做的测试,就将其加入清单。
清单中第一个测试看起来很复杂,我们需要从比较简单的开始。第二个测试不过是实现乘法功能而已,我们就从它开始吧。
测试先行,乘法功能的简单实例:
public void testMultiplication() {
Dollar five = new Dollar(5);
five.times(2);
assertEquals(10, five.amount);
}
代码问题:公共域问题、副作用问题、货币金额用整数来表示问题,等等。别急,一步一步来。我们将这些毛病记录下来,然后继续前进。显然,测试没有通过,但是我们希望测试能够尽快到达可运行状态(green bar)。
计划清单(to-do list)
当瑞士法郎与美元的兑换率为 2:1 的时候,5 美元 + 10 瑞士法郎 = 10 美元
5 美元 * 2 = 10 美元
将 "amount" 定义为私有
Dollar 类有副作用吗?
钱数必须为整数?
为了使编译通过,我们至少要做哪些工作呢?我们存在以下四个编译错误:
- 没有 Dollar 类
- 没有构造函数
- 没有 times(int) 方法
- 没有 amount 域
逐一改正。
- 我们通过定义 Dollar 类来去掉一个错误:
class Dollar
- 创建一个构造函数,但是仅为了让测试能够编译通过,不必实现任何功能:
Dollar(int amount) {
}
- times() 的存根实现(stub implementation)。同样仅做可以使测试程序通过的最少工作:
void times(int multiplier) {
}
- 需要一个 amount 域:
int amount;
测试程序没有运行通过(red bar)。希望结果是 "10",实际结果是 "0"。
让程序测试通过的最小改动:
int amount = 10;
现在测试程序运行通过。记住,这一轮工作由下列的环节组成:
- 新增一个测试。
- 运行所有的测试程序并失败。
- 做一些小小的改动。
- 运行所有的测试程序,并且全部通过。
- 重构代码以消除重复设计,优化设计结构。
依赖关系(dependency)与重复设计(duplication)
Steve Freeman 指出:测试程序与代码所存在的问题不在于重复设计,而在于代码与测试程序之间的依赖关系——你不可能只改动其中一个而不改动另外一个。我们的目标时编写另外一个对我们有用的测试而不必改动代码,而这对于当前实现而言是不可能的。
如果问题出在依赖关系上,那么其表现就是重复设计。重复设计通常表现为逻辑上的重复设计——相同的表达式在代码的多个地方出现。利用各种对象可以很好地抽象出逻辑上的重复设计。
消除程序中的重复设计就是消除依赖,这就是测试驱动开发第二条规则的由来。只有在编写下一个测试之前消除现有的重复设计,通过一处且仅仅一处改动即可让下一个测试运行通过的可能性才最大。
通常重复设计存在于两段代码之间,但是在这儿重复设计却存在于测试中的数据与代码中的数据之间。如果我们这样写会怎样呢:
int amount = 5 * 2;
这个 10 必然有它的来历,我们只顾在大脑中快速地做乘法以至于将这点忽略了。在这儿的 5 与 2 处于两个不同的地方,所以依照规则,在我们继续之前必须毫不留情地消除重复。
我们无法只通过一步就消除 5 和 2。既然如此,可以不在对象初始化时给 amount 赋值,而将这个过程移至 times() 方法中。
int amount;
void times(int multiplier) {
amount = 5 * 2;
}
测试仍然通过,测试程序保持在可运行状态。
我们可以从哪儿得到一个 5 呢?这是传给构造函数的值,所以我们用 amount 变量保存它:
Dollar(int amount) {
this.amount = amount;
}
然后我们就可以在 times() 函数中使用它:
void times(int multiplier) {
amount = amount * 2;
}
参数 "multiplier" 的值是 2,所以我们可以用这个参数来代替这个常量:
void times(int multiplier) {
amount *= muliplier;
}
计划清单(to-do list)
当瑞士法郎与美元的兑换率为 2:1 的时候,5 美元 + 10 瑞士法郎 = 10 美元
5 美元 * 2 = 10 美元将 "amount" 定义为私有
Dollar 类有副作用吗?
钱数必须为整数?
现在可以说第一个测试已经完成了,下一步我们将解决那些奇怪的副作用问题。在此之前,让我们回顾以下,我们做了以下的工作:
- 创建一个清单,列出我们所知道的需要让其运行通过的测试
- 通过一小段代码说明我们希望看到怎样的一种操作
- 暂时忽略 JUnit 的一些细节问题
- 通过建立存根(stub)来让测试程序通过编译
- 通过一些另类的做法来让测试运行通过
- 逐渐使工作代码一般化,用变量代替常量
- 将新工作逐步加入计划清单,而不是一次全部提出