掌握TDD 之一
测试驱动开发[Kent Beck]
我希望在这里提出一些问题,供你们在将测试驱动开发(TDD) 集成到自己的开发实践过程中时思考。其中有一些是小问题,而有一些则是大问题。有的问题提供了答案,或者至少有相应的提示,而有些问题则是留经你自己去探讨的。
步伐应有多大?
你可以编写使每个都对应一行逻辑代码和少数重构的测试。你也可以编写每个都对应上百行逻辑代码和数小时重构的测试。你应当选择哪能一种方式呢?
回答这一问题的部分答案是不管哪能种方式,你都应该有能力做到。不过,经过一段时间,测试驱动开发人员都明显倾向于采用小步骤进行开发。然而有人正在单独利用应用级别的测试或者结合我们所编写的程序员级别的测试来驱动开发。
首先,当你重构时,你应当做好采用大量微小步骤的准备。手工重构很容易犯错,而犯的错误越多而且解决这些错误越靠后,你继续进行重构的可能性就越小。一旦你通过细致的步骤进行了20遍重构,那么就可以尝试着省去其中的一些步骤。
自动重构能够大大提高重构速度。曾经要手工花费20步才能完成的重构现在只要点击一个菜单就行了。当量变达到一定程序时通常会产生质变,自动重构也是如此。一旦你有了优秀工具的支持,在重构时便会更加野心勃勃、积极进取,就会尝试多得多的试验以了解怎样对代码结构进行组织。
在我编写这本书的时候,Smalltalk的重构浏览器(Refactoring Browser)仍然是最好的重构工具。很多Java集成开发环境中都出现了对Java重构的支持,而对重构的支持肯定会很快传播到其它语言和编程环境中去。
什么可以不必测试?
Phlip所提供的简略答案是:“写测试,一直写到恐惧转变为厌倦时为止。”然而这是一个带反馈的循环,答案需要由你自己来找。可是你之所以阅读本书是国为想在此找到答案而不是问题(如果是后者,那你可读错了东西,不过相互递归参照的文献可读够了),所以请参考下面这个清单。你应该测试:
怎样知道自己的测试没有疏漏呢?
测试就像是煤矿中的金丝雀一样,它们的不良反映揭示了周围可能出现的致命气体。下面是一些预示着设计存在缺陷的特征。
有一种看似矛盾实则正确的说法:不为代码的将来考虑,你的代码反而更有可能适应将来的需要。
而我从书本上学到的却恰好相反:“编码为今天,设计为明天。”而测试驱动开发看起来已经彻底推翻了这一论点:“为明天编码,为今天设计。”下面是现实中出现的一些实际情况。
那么,如果三年后,一种非同寻常的变化出现了会怎样?设计将在恰好需要的地方迅速进化而适应这种变化。我们只支在很短的时间内违反开放/封闭原则,但是你有测试在手,它给了你不会破坏任何现有功能的自信,所有违反这一原则根本不会付出高昂的代价。
在极端情况下,当你引进变化的速度非常快时,TDD与前期的设计阶段是一样的。我曾经在几个小时内开发出了一个报告框架(reporting framework),而旁观者肯定觉得这里有诀窍,“他肯定是在脑子里有了成型的框架以后才开始的”。不,你错了。我只是采用测试驱动的方式进行开发的时间太久了,以至于在你们还没有意识到以前,我就已经从大部分的错误中调整过来了。
你需要多少反馈?
你应该编写多少测试呢?这儿有一个简单的问题:有三个整数,分别代表一个三角形的三条边的长度,要求返回:
(1)这个三角形是否是等边三角形
(2)这个三角形是否是等腰三角形
(3)这个三角形是否是不等边三角形
而且当这三条边不能构成三角形时,抛出异常。
来,试着解决一下这个问题(我的Smalltalk解决方案就列在这个问题的末尾)。
我写了6个测试(这话的意思有点像“定个基调”,诸如,“对付这个问题,我只要编写四个测试”,“直接编写解决这个问题的代码”)。Bob Binder在其讲解全面的《Testing Object-Oriented Systems》一书中为同一个问题编写了65个测试。你得通过自己的经验和头脑来判断要编写多少测试。
当我考虑要编写多少个测试的时候,就想起了平均无故障时间(MBTF)。例如,Smalltalk整数的行为就像整数,而不像一个32位的计数器,因此测试MAXINT没有意义。不过,整数确实有一个最大值,但那与你的内存容量有关。我有必要编写用巨大的整数填满整个内存空间的测试吗?编写此种测试与否对程序的MTBF会有怎样的影响呢?如果我从来不打算提供一个接近那种大小的三角形的话,那么编写与不编写这样的测试对程序的健壮性而言没有什么分别。
一个测试是否值得编写取决于你对MTBF衡量的仔细程序。如果你想让让搏器程序的MTBF从10年延伸至100年的话,那么对那些极不可能发生的条件和条件组合进行测试就是有意义的,除非你可以用别的方法证明这种条件不会出现。
测试驱动开发对测试的观点就是注意实效。在测试驱动开发中,测试从某种意义上说是一种达到目的的手段---达到充满自信地编写代码的目的。如果我们对实现有充分了解,不用测试就能拥有自信的话,那么就没有必要编写测试了。在黑盒测试中,我们有意识地抛开实现细节,这样做有一定的益处。通过不考虑具体的实现细节,黑盒测试展示了一种不同的价值系统---单单测试这块儿就是有价值的。考虑某些环境因素是一种适当的态度,但那与测试驱动开发有所不同。
TriangleTest
testEquilateral
self assert: (self evaluate: 2 side: 2 side: 2 ) = 1
testIsosceles
self assert: (self evaluate: 1 side: 2 side: 2 ) = 2
testScalene
self assert: (self evaluate: 2 side: 3 side: 4 ) = 3
testIrrational
[self evaluate: 1 side: 2 side: 3]
on: Exception
do: [:ex | ^self].
self fail
testNegative
[self evaluate: -1 side: 2 side: 2]
on: Exception
do: [:ex | ^self].
self fail
testStrings
[self evaluate: 'a' side: 'b' side: 'c']
on: Exception
do: [:ex | ^self].
self fail
evaluate: aNumber1 side: aNumber2 side: aNumber3
| sides |
sides := SortedCollection
with: aNumber1
with: aNumber2
with: aNumber3.
sides first <= 0 ifTrue: [self fail].
(sides at: 1) + (sides at: 2) <= (sides at: 3) ifTrue: [self fail].
^sides asSet size
什么时候应该删除测试?
测试越多越好,但如果两个测试互为冗余,你应该保留它们两个吗?这就取决于下面两个标准。
步伐应有多大?
- 这里暗指两个问题:
- 我个测试程序覆盖的方面有多大?
你可以编写使每个都对应一行逻辑代码和少数重构的测试。你也可以编写每个都对应上百行逻辑代码和数小时重构的测试。你应当选择哪能一种方式呢?
回答这一问题的部分答案是不管哪能种方式,你都应该有能力做到。不过,经过一段时间,测试驱动开发人员都明显倾向于采用小步骤进行开发。然而有人正在单独利用应用级别的测试或者结合我们所编写的程序员级别的测试来驱动开发。
首先,当你重构时,你应当做好采用大量微小步骤的准备。手工重构很容易犯错,而犯的错误越多而且解决这些错误越靠后,你继续进行重构的可能性就越小。一旦你通过细致的步骤进行了20遍重构,那么就可以尝试着省去其中的一些步骤。
自动重构能够大大提高重构速度。曾经要手工花费20步才能完成的重构现在只要点击一个菜单就行了。当量变达到一定程序时通常会产生质变,自动重构也是如此。一旦你有了优秀工具的支持,在重构时便会更加野心勃勃、积极进取,就会尝试多得多的试验以了解怎样对代码结构进行组织。
在我编写这本书的时候,Smalltalk的重构浏览器(Refactoring Browser)仍然是最好的重构工具。很多Java集成开发环境中都出现了对Java重构的支持,而对重构的支持肯定会很快传播到其它语言和编程环境中去。
什么可以不必测试?
Phlip所提供的简略答案是:“写测试,一直写到恐惧转变为厌倦时为止。”然而这是一个带反馈的循环,答案需要由你自己来找。可是你之所以阅读本书是国为想在此找到答案而不是问题(如果是后者,那你可读错了东西,不过相互递归参照的文献可读够了),所以请参考下面这个清单。你应该测试:
- 条件部分
- 循环部分
- 操作部分
- 多态性
怎样知道自己的测试没有疏漏呢?
测试就像是煤矿中的金丝雀一样,它们的不良反映揭示了周围可能出现的致命气体。下面是一些预示着设计存在缺陷的特征。
- 过长的设置代码 --- 如果为了一个简单的断言, 需要花费上百行代码创建对象,那么肯定有哪儿不对劲儿。对象太大,需要分割。
- 冗余的设置代码 --- 如果你无法为公共设置代码找到一个存放它的公共场所的话,那么就表明有太多的对象过于紧密地联系在一起了。
- 过长的测试运行时间 --- 测试驱动开发中运行时间太长的测试就不会经常被运行,常常是过一段时间才运行一次,也许这些测试已经无法工作了。更糟糕的是,它们暗示着对应用的方方面面进行测试是困难的。这种测试困难是一种设计问题,并且需要在设计时被提出来。(十分钟的测试套件等同于9.8m/(s*s)的重力加速度。运行时间比十分钟还长的测试套件必然会被抛弃,或者对应用进行调整,使测试套件运行时间重新又回到10分钟以内。)
- 脆弱的测试 --- 意外中断的测试说明应用的某一部分出人意料地存在对另一部分的影响。你要对系统进行设计,要么打破联系,要么就两部分合并,直到这种影响消失为止。
有一种看似矛盾实则正确的说法:不为代码的将来考虑,你的代码反而更有可能适应将来的需要。
而我从书本上学到的却恰好相反:“编码为今天,设计为明天。”而测试驱动开发看起来已经彻底推翻了这一论点:“为明天编码,为今天设计。”下面是现实中出现的一些实际情况。
- 要求实现软件的每一功能。该功能的实现简单明了,很快就完成了工作,而且瑕疵很少。
- 要实现的第二个功能是第一个功能的变种。将两项功能重复的代码放在同一个地方,将不同的部分放在不同的地方(放在不同的方法或类中)。
- 要实现的第三个功能是并没有两个的变种。也许稍加改动,我们仍有可能沿用目前共有的逻辑。而互不相同的逻辑部分一般都有自己明显的归属,要么在不同的方法中实现,要么在不同的类中实现。
那么,如果三年后,一种非同寻常的变化出现了会怎样?设计将在恰好需要的地方迅速进化而适应这种变化。我们只支在很短的时间内违反开放/封闭原则,但是你有测试在手,它给了你不会破坏任何现有功能的自信,所有违反这一原则根本不会付出高昂的代价。
在极端情况下,当你引进变化的速度非常快时,TDD与前期的设计阶段是一样的。我曾经在几个小时内开发出了一个报告框架(reporting framework),而旁观者肯定觉得这里有诀窍,“他肯定是在脑子里有了成型的框架以后才开始的”。不,你错了。我只是采用测试驱动的方式进行开发的时间太久了,以至于在你们还没有意识到以前,我就已经从大部分的错误中调整过来了。
你需要多少反馈?
你应该编写多少测试呢?这儿有一个简单的问题:有三个整数,分别代表一个三角形的三条边的长度,要求返回:
(1)这个三角形是否是等边三角形
(2)这个三角形是否是等腰三角形
(3)这个三角形是否是不等边三角形
而且当这三条边不能构成三角形时,抛出异常。
来,试着解决一下这个问题(我的Smalltalk解决方案就列在这个问题的末尾)。
我写了6个测试(这话的意思有点像“定个基调”,诸如,“对付这个问题,我只要编写四个测试”,“直接编写解决这个问题的代码”)。Bob Binder在其讲解全面的《Testing Object-Oriented Systems》一书中为同一个问题编写了65个测试。你得通过自己的经验和头脑来判断要编写多少测试。
当我考虑要编写多少个测试的时候,就想起了平均无故障时间(MBTF)。例如,Smalltalk整数的行为就像整数,而不像一个32位的计数器,因此测试MAXINT没有意义。不过,整数确实有一个最大值,但那与你的内存容量有关。我有必要编写用巨大的整数填满整个内存空间的测试吗?编写此种测试与否对程序的MTBF会有怎样的影响呢?如果我从来不打算提供一个接近那种大小的三角形的话,那么编写与不编写这样的测试对程序的健壮性而言没有什么分别。
一个测试是否值得编写取决于你对MTBF衡量的仔细程序。如果你想让让搏器程序的MTBF从10年延伸至100年的话,那么对那些极不可能发生的条件和条件组合进行测试就是有意义的,除非你可以用别的方法证明这种条件不会出现。
测试驱动开发对测试的观点就是注意实效。在测试驱动开发中,测试从某种意义上说是一种达到目的的手段---达到充满自信地编写代码的目的。如果我们对实现有充分了解,不用测试就能拥有自信的话,那么就没有必要编写测试了。在黑盒测试中,我们有意识地抛开实现细节,这样做有一定的益处。通过不考虑具体的实现细节,黑盒测试展示了一种不同的价值系统---单单测试这块儿就是有价值的。考虑某些环境因素是一种适当的态度,但那与测试驱动开发有所不同。
TriangleTest
testEquilateral
self assert: (self evaluate: 2 side: 2 side: 2 ) = 1
testIsosceles
self assert: (self evaluate: 1 side: 2 side: 2 ) = 2
testScalene
self assert: (self evaluate: 2 side: 3 side: 4 ) = 3
testIrrational
[self evaluate: 1 side: 2 side: 3]
on: Exception
do: [:ex | ^self].
self fail
testNegative
[self evaluate: -1 side: 2 side: 2]
on: Exception
do: [:ex | ^self].
self fail
testStrings
[self evaluate: 'a' side: 'b' side: 'c']
on: Exception
do: [:ex | ^self].
self fail
evaluate: aNumber1 side: aNumber2 side: aNumber3
| sides |
sides := SortedCollection
with: aNumber1
with: aNumber2
with: aNumber3.
sides first <= 0 ifTrue: [self fail].
(sides at: 1) + (sides at: 2) <= (sides at: 3) ifTrue: [self fail].
^sides asSet size
什么时候应该删除测试?
测试越多越好,但如果两个测试互为冗余,你应该保留它们两个吗?这就取决于下面两个标准。
- 第一个标准就是自信。如果删除一个测试降你了你对整个系统功能的信心,那么就不要删除。
- 第二个标准就是沟通。如果你有两个测试,走的是同一条路,但对读者来说进述的是不同的情形的话,那么就应该原封不动地保留。