详解测试驱动开发 Test Driven Development(TDD)

{TDD的目标}

Clean Code That Works

这句话的含义是,事实上只做两件事情:让代码奏效(Work)和让代码洁净(Clean),前者是把事情做对,后者是把事情做好。

 

想试用TDD的人们,请遵循下面的步骤:

编写TestCase --< 实现TestCase --< 重构
(确定范围和目标)   (增加功能)   (提升设计)

{TDD的优点}

『充满吸引力的优点』:

完工时完工;
全面正确的认识代码和利用代码;
为利用你成果的人提供Sample;
开发小组间降低了交流成本,提高了相互信赖程度; 
避免了过渡设计;
系统可以与详尽的测试集一起发布,从而对程序的将来版本的修改和扩展提供方便;

『不显而易见的优点』

逃避了设计角色;
降低了理解代码所花费的成本;

『有争议的优点』

提高了开发效率

 

{TDD的步骤}

步骤 制品
(1)快速新增一个测试用例 新的TestCase
(2)编译所有代码,刚刚写的那个测试很可能编译不通过 原始的TODO List
(3)做尽可能少的改动,让编译通过 Interface
(4)运行所有的测试,发现最新的测试不能编译通过 -(Red Bar)
(5)做尽可能少的改动,让测试通过 Implementation
(6)运行所有的测试,保证每个都能通过 -(Green Bar)
(7)重构代码,以消除重复设计 Clean Code That Works

 

{FAQ}

[什么时候重构?]

重构不是一种构建软件的工具,不是一种设计软件的模式,也不是一个软件开发过程中的环节,正确理解重构的人应该把重构看成一种书写代码的方式,或习惯,重构时时刻刻有可能发生。在TDD中,除去编写测试用例和实现测试用例之外的所有工作都是重构,所以,没有重构任何设计都不能实现。

实现测试用例时重构代码,完成某个特性时重构设计,产品的重构完成后还要记得重构一下测试用例。

 

[什么时候设计?]

设计的时机应该由开发者自己把握,不要受到TDD方式的限制,但是,不需要事先确定的事一定不能事先确定,免得捆住了自己的手脚。

 

[什么时候增加新的TestCase?]
没事做的时候。通常我们认为,如果你要增加一个新的功能,那么先写一个不能通过的 TestCase;如果你发现了一个bug,那么先写一个不能通过的TestCase;如果你现在什么都没有,从0开始,请先写一个不能通过的 TestCase。所有的工作都是从一个TestCase开始。

 

[TestCase该怎么写?]
测试用例的编写实际上就是两个过程:使用尚不存在的代码和定义这些代码的执行结果。所以一个 TestCase也就应该包括两个部分--场景和断言。 

这个世界上最难的事情也不在于如何解决问题,而在于ask the right question!

 

[TDD能帮助我消除Bug吗?]
答:不能!千万不要把"测试"和"除虫"混为一谈!"除虫"是指程序员通过自己的努力来减少bug的数量(消除bug这样的字眼我们还是不要讲为好^_^),而"测试"是指程序员书写产品以外的一段代码来确保产品能有效工作

 

[我该为一个Feature编写TestCase还是为一个类编写TestCase?]

我们的研究结果表明,通常在一个特性的开发开始时,我们针对特性编写测试用例,如果您发现这个特性无法用TestCase表达,那么请将这个特性细分,直至您可以为手上的特性写出TestCase为止。从这里开始是最安全的,它不会导致任何设计上重大的失误。但是,随着您不断的重构代码,不断的重构 TestCase,不断的依据TDD的思想做下去,最后当产品伴随测试用例集一起发布的时候,您就会不经意的发现经过重构以后的测试用例很可能是和产品中的类/方法一一对应的。

 

[什么时候应该将全部测试都运行一遍?]
Good Question!大师们要求我们每次重构之后都要完整的运行一遍测试用例。这个要求可以理解,因为重构很可能会改变整个代码的结构或设计,从而导致不可预见的后果,但是如果我正在开发的是一个ERP怎么办?运行一遍完整的测试用例可能将花费数个小时,况且现在很多重构都是由工具做到的,这个要求的可行性和前提条件都有所动摇。所以我认为原则上你可以挑几个你觉得可能受到本次重构影响的TestCase去run,但是如果运行整个测试包只要花费数秒的时间,那么不介意你按大师的要求去做。

 

[什么时候改进一个TestCase?]
增加的测试用例或重构以后的代码导致了原来的TestCase的失去了效果,变得无意义,甚至可能导致错误的结果,这时是改进TestCase的最好时机。但是有时你会发现,这样做仅仅导致了原来的TestCase在设计上是臃肿的,或者是冗余的,这都不要紧,只要它没有失效,你仍然不用去改进它。记住,TestCase不是你的产品,它不要好看,也不要怎么太科学,甚至没有性能要求,它只要能完成它的使命就可以了--这也证明了我们后面所说的"用Ctrl-C/Ctrl-V编写测试用例"的可行性。

 

[为什么原来通过的测试用例现在不能通过了?]
这是一个警报,Red Alert!它可能表达了两层意思--都不是什么好意思--1)你刚刚进行的重构可能失败了,或存在一些错误未被发现,至少重构的结果和原来的代码不等价了。2)你刚刚增加的TestCase所表达的意思跟前面已经有的TestCase相冲突,也就是说,新增的功能违背了已有的设计,这种情况大部分可能是之前的设计错了。

 

[我怎么知道那里该有一个方法还是该有一个类?]
这个问题也是常常出现在我的脑海中,无论你是第一次接触TDD或者已经成为 TDD专家,这个问题都会缠绕着你不放。不过问题的答案可以参考前面的"什么时候设计"一节,答案不是唯一的。其实多数时候你不必考虑未来,今天只做今天的事,只要有重构工具,从方法到类和从类到方法都很容易。

 

[我要写一个TestCase,可是不知道从哪里开始?]
从最重要的事开始,what matters most?从脚下开始,从手头上的工作开始,从眼前的事开始。从一个没有UI的核心特性开始,从算法开始,或者从最有可能耽误时间的模块开始,从一个最严重的bug开始。

 

[为什么我的测试总是看起来有点愚蠢?]

不必担心这一点,事实上,大师们给的例子也相当愚蠢,比如一个极端的例子是要写一个两个int变量相加的方法,大师先断言2+3=5,再断言5+5=10,难道这些代码不是很愚蠢吗?其实这只是一个极端的例子,当你初次接触TDD时,写这样的代码没什么不好,以后当你熟练时就会发现这样写没必要了,要记住,要记住,谦虚是通往TDD的必经之路!从经典开发方法转向TDD就像从面向过程转向面向对象一样困难,你可能什么都懂,但你写出来的类没有一个纯OO的!

 

[什么场合不适用TDD?]
问的好,确实有很多场合不适合使用TDD。比如对软件质量要求极高的军事或科研产品--神州六号,人命关天的软件--医疗设备,等等,再比如设计很重要必须提前做好的软件,这些都不适合TDD,但是不适合TDD不代表不能写TestCase,只是作用不同,地位不同罢了。

 

{Best Practise}

[微笑面对编译错误]

通常,编译错误都集中在下面三个方面:
(1)你的代码存在低级错误
(2)由于某些Interface的实现尚不存在,所以被测试代码无法编译
(3)由于某些代码尚不存在,所以测试代码无法编译

请注意第二点与第三点完全不同,前者表明设计已存在,而实现不存在导致的编译错误;后者则指仅有TestCase而其它什么都没有的情况,设计和实现都不存在,没有Interface也没有Implementation。

另外,编译器还有一个优点,那就是以最敏捷的身手告诉你,你的代码中有那些错误。

[重视你的计划清单]

在非TDD的情况下,尤其是传统的瀑布模型的情况下,程序员不会不知道该做什么,事实上,总是有设计或者别的什么制品在引导程序员开发。但是在TDD的情况下,这种优势没有了,所以一个计划清单对你来说十分重要,因为你必须自己发现该做什么。

[废黜每日代码质量检查]
如果我没有记错的话,PSP对于个人代码检查的要求是蛮严格的,而同样是在针对个人的问题上, TDD却建议你废黜每日代码质量检查,别起疑心,因为你总是在做TestCase要求你做的事情,并且总是有办法(自动的)检查代码有没有做到这些事情 --红灯停绿灯行,所以每日代码检查的时间可能被节省,对于一个严格的PSP实践者来说,这个成本还是很可观的!

[如果无法完成一个大的测试,就从最小的开始]
如果我无法开始怎么办,教科书上有个很好的例子:我要写一个电影列表的类,我不知道如何下手,如何写测试用例,不要紧,首先想象静态的结果,如果我的电影列表刚刚建立呢,那么它应该是空的,OK,就写这个断言吧,断言一个刚刚初始化的电影列表是空的。

[尝试编写自己的xUnit]
Kent Beck建议大家每当接触一个新的语言或开发平台的时候,就自己写这个语言或平台的xUnit,其实几乎所有常用的语言和平台都已经有了自己的 xUnit,而且都是大同小异,但是为什么大师给出了这样的建议呢。其实Kent Beck的意思是说通过这样的方式你可以很快的了解这个语言或平台的特性,而且xUnit确实很简单,只要知道原理很快就能写出来。

[善于使用Ctrl-C/Ctrl-V来编写TestCase]
不必担心TestCase会有代码冗余的问题,让它冗余好了。

[永远都是功能First,改进可以稍后进行]
上面这个标题还可以改成另外一句话:避免过渡设计!

[淘汰陈旧的用例]
舍不得孩子套不着狼。不要可惜陈旧的用例,因为它们可能从概念上已经是错误的了,或仅仅会得出错误的结果,或者在某次重构之后失去了意义。当然也不一定非要删除它们,从TestSuite中除去(JUnit)或加上Ignored(NUnit)标签也是一个好办法。

[用TestCase做试验]
如果你在开始某个特性或产品的开发之前对某个领域不太熟悉或一无所知,或者对自己在该领域里的能力一无所知,那么你一定会选择做试验,在有单元测试作工具的情况下,建议你用TestCase做试验,这看起来就像你在写一个验证功能是否实现的 TestCase一样,而事实上也一样,只不过你所验证的不是代码本身,而是这些代码所依赖的环境。

[TestCase之间应该尽量独立]
保证单独运行一个TestCase是有意义的。

[不仅测试必须要通过的代码,还要测试必须不能通过的代码]
这是一个小技巧,也是不同于设计思路的东西。像越界的值或者乱码,或者类型不符的变量,这些输入都可能会导致某个异常的抛出,或者导致一个标示"illegal parameters"的返回值,这两种情况你都应该测试。

[编写代码的第一步,是在TestCase中用Ctrl-C]
这是一个高级技巧,呃,是的,我是这个意思,我不是说这个技巧难以掌握,而是说这个技巧当且仅当你已经是一个TDD高手时,你才能体会到它的魅力。多次使用TDD的人都有这样的体会,既然我的TestCase已经写的很好了,很能说明问题,为什么我的代码不能从TestCase拷贝一些东西来呢。

[测试用例包应该尽量设计成可以自动运行的]
如果产品是需要交付源代码的,那我们应该允许用户对代码进行修改或扩充后在自己的环境下run整个测试用例包。既然通常情况下的产品是可以自动运行的,那为什么同样作为交付用户的制品,测试用例包就不是自动运行的呢?即使产品不需要交付源代码,测试用例包也应该设计成可以自动运行的,这为测试部门或下一版本的开发人员提供了极大的便利。

[只亮一盏红灯]
大师的建议,前面已经提到了,仅仅是建议。

[用TestCase描述你发现的bug]

用TestCase描述bug的另一个好处是,不会因为以后的修改而再次暴露这个bug,它已经成为你发布每一个版本之前所必须检查的内容了。

{关于单元测试}

单元测试的目标是

Keep the bar green to keep the code clean

这句话的含义是,事实上我们只做两件事情:让代码奏效(Keep the bar green)和让代码洁净(Keep the code clean),前者是把事情做对,后者是把事情做好,两者既是TDD中的两顶帽子,又是xUnit架构中的因果关系。

单元测试作为软件测试的一个类别,并非是xUnit架构创造的,而是很早就有了。但是xUnit架构使得单元测试变得直接、简单、高效和规范,这也是单元测试最近几年飞速发展成为衡量一个开发工具和环境的主要指标之一的原因。

多数语言和平台的xUnit架构都是大同小异,有的仅是语言不同,其中最有代表性的是JUnit和NUnit,后者是前者的创新和扩展。一个单元测试框架xUnit应该:1)使每个TestCase独立运行;2)使每个TestCase可以独立检测和报告错误;3)易于在每次运行之前选择TestCase。

下面是枚举出的xUnit框架的概念,这些概念构成了当前业界单元测试理论和工具的核心:

[测试方法/TestMethod]
测试的最小单位,直接表示为代码。

[测试用例/TestCase]
由多个测试方法组成,是一个完整的对象,是很多TestRunner执行的最小单位。

[测试容器/TestSuite]
由多个测试用例构成,意在把相同含义的测试用例手动安排在一起,TestSuite可以呈树状结构因而便于管理。在实现时,TestSuite形式上往往也是一个TestCase或TestFixture。

[断言/Assertion]
断言一般有三类,分别是比较断言(如assertEquals),条件断言(如isTrue),和断言工具(如fail)。

[测试设备/TestFixture]
为每个测试用例安排一个SetUp方法和一个TearDown方法,前者用于在执行该测试用例或该用例中的每个测试方法前调用以初始化某些内容,后者在执行该测试用例或该用例中的每个方法之后调用,通常用来消除测试对系统所做的修改。

[期望异常/Expected Exception]
期望该测试方法抛出某种指定的异常,作为一个"断言"内容,同时也防止因为合情合理的异常而意外的终止了测试过程。

[种类/Category]
为测试用例分类,实际使用时一般有TestSuite就不再使用Category,有Category就不再使用TestSuite。

[忽略/Ignored]
设定该测试用例或测试方法被忽略,也就是不执行的意思。有些被抛弃的TestCase不愿删除,可以定为Ignored。

[测试执行器/TestRunner]
执行测试的工具,表示以何种方式执行测试,别误会,这可不是在代码中规定的,完全是与测试内容无关的行为。比如文本方式,AWT方式,swing方式,或者Eclipse的一个视图等等。

{实例:Fibonacci数列}

下面的Sample展示TDDer是如何编写一个旨在产生Fibonacci数列的方法。
(1)首先写一个TC,断言fib(1) = 1;fib(2) = 1;这表示该数列的第一个元素和第二个元素都是1。

public void testFab() {
assertEquals(
1, fib(1));
assertEquals(
1, fib(2));
}

(2)上面这段代码不能编译通过,Great!--是的,我是说Great!当然,如果你正在用的是Eclipse那你不需要编译,Eclipse 会告诉你不存在fib方法,单击mark会问你要不要新建一个fib方法,Oh,当然!为了让上面那个TC能通过,我们这样写:

public int fib( int n ) {
return 1;
}

(3)现在那个TC亮了绿灯,wow!应该庆祝一下了。接下来要增加TC的难度了,测第三个元素。

public void testFab() {
assertEquals(
1, fib(1));
assertEquals(
1, fib(2));
assertEquals(
2, fib(3));
}

不过这样写还不太好看,不如这样写:

public void testFab() {
assertEquals(
1, fib(1));
assertEquals(
1, fib(2));
assertEquals(fib(
1)+fib(2), fib(3));
}

(4)新增加的断言导致了红灯,为了扭转这一局势我们这样修改fib方法,其中部分代码是从上面的代码中Ctrl-C/Ctrl-V来的:

public int fib( int n ) {
if ( n == 3 ) return fib(1)+fib(2);
return 1;
}

(5)天哪,这真是个贱人写的代码!是啊,不是吗?因为TC就是产品的蓝本,产品只要恰好满足TC就ok。所以事情发展到这个地步不是fib方法的错,而是TC的错,于是TC还要进一步要求:

public void testFab() {
assertEquals(
1, fib(1));
assertEquals(
1, fib(2));
assertEquals(fib(
1)+fib(2), fib(3));
assertEquals(fib(
2)+fib(3), fib(4));
}

(6)上有政策下有对策。

public int fib( int n ) {
if ( n == 3 ) return fib(1)+fib(2);
if ( n == 4 ) return fib(2)+fib(3);
return 1;
}

(7)好了,不玩了。现在已经不是贱不贱的问题了,现在的问题是代码出现了冗余,所以我们要做的是--重构:

public int fib( int n ) {
if ( n == 1 || n == 2 ) return 1;
else return fib( n - 1 ) + fib( n - 2 );
}

(8)好,现在你已经fib方法已经写完了吗?错了,一个危险的错误,你忘了错误的输入了。我们令0表示Fibonacci中没有这一项。

public void testFab() {
assertEquals(
1, fib(1));
assertEquals(
1, fib(2));
assertEquals(fib(
1)+fib(2), fib(3));
assertEquals(fib(
2)+fib(3), fib(4));
assertEquals(
0, fib(0));
assertEquals(
0, fib(-1));
}

then change the method fib to make the bar grean:

public int fib( int n ) {
if ( n >= 0 ) return 0;
if ( n == 1 || n == 2 ) return 1;
else return fib( n - 1 ) + fib( n - 2 );
}

(9)下班前最后一件事情,把TC也重构一下:

public void testFab() {
int cases[][] = {
{
00}, {-10}, //the wrong parameters
{11}, {21}}; //the first 2 elements

for (int i = 0; i > cases.length; i++)
assertEquals( cases[i][
1], fib(cases[i][0]) );

//the rest elements
for (int i = 3; i > 20; i++)
assertEquals(fib(i
-1)+fib(i-2), fib(i));
}

(10)打完收工。

测试驱动开发全功略(精)

测试驱动开发优缺点

——————————————————————————————————————————————

优势:

1. 有助于设计简单清晰而易用的接口。因为总是先有测试代码,才编写实现代码,意味着总是从使用者的角度设计接口,只有简单易用的接口才方便测试时调用,所以我几乎是“被迫”去努力设计简单易用的接口,因为我就是第一个使用者。

2. 模块切分的足够小但是模块间保持极低的耦合度。为了方便测试,我总是尽力把重复的逻辑剥离出来,单独构建模块进行测试;并且尽量减少模块间的耦合,保持模 块相对独立和功能完备。如果模块过大,或者模块间强耦合,写测试用例与代码时就会困难重重,笨重复杂。因为总是先写测试代码,眼前的利益高于一切,将来的 实现代码必然要迁就目前测试的需要。于是,我又“不知不觉”设计出小粒度模块,且模块间耦合度低的实现。

3. 肆无忌惮的重构。因为有测试用例和测试代码作担保,我终于能够从小心翼翼心惊胆战的重构中解脱出来,只要是更好的实现和设计,我都愿意尝试,管它呢,反正 多跑几遍测试就知道重构的结果如何了。可以说,测试驱动开发鼓励代码的不断进化,即使测试已经全部通过,也可以通过大胆重构来改进设计与实现。尽管此时是 否还需要再重构见仁见智,至少提供了一个可能,我是很喜欢这一点。

4. 测试代码是“活”的软件文档,它硬性规定了实现代码必须满足的需求,达不到就报错。传统的文本文档比之就苍白无力多了,“应该”,“必须”,这些字眼对程 序员有多少约束力?而且测试代码总是能与实现代码保持新鲜同步,传统文档写完后经常被上传服务器束之高阁,很少人问津,随着开发组内人员的变动,往往最后 就湮没在服务器的故纸堆之中了。

缺陷:

1. 测试驱动开发不可能让人立即具有设计出优美解决方案的能力,或者说是优秀的分析与解决问题的能力。TDD不是Test Driven Design。它只是一个过程,也许可以帮助你发现并帮助你实现优美的解决方案,但是它不能变魔术一样,只要学会了就变出一个优美的设计出来,优秀的分析 问题与解决问题的能力还是要靠不断地学习与借鉴他人成就才能得到提高。


2. 测试驱动开发不能节省开发投入,也很少能够节省开发周期。测试开发所编写的大量测试代码都是要投入时间与精力的,我现在的代码统计显示,测试代码与实现代 码的比例基本在3:2,即使因为测试驱动开发能得到一个简洁的设计,也不能弥补测试代码的工作量。当然,测试代码可以一定程度保证高质量的实现代码,从而 减少后期软件测试与修正缺陷的工作周期,并进一步在软件发布后减少代码修正维护的工作量。但至少在开发阶段,两相抵消,开发周期并不能有明显改善,如果是 第一次采纳测试驱动开发,甚至会延长开发周期。


3. 测试驱动开发不能杜绝所有的软件缺陷。尽管测试驱动开发通过测试约束,减少了程序员犯错和遗忘的可能,但是这只是把问题从实现代码部分地转移到了测试代 码。测试用例的完备与否,测试代码本身逻辑的正确与否都依赖于程序员,糟糕的测试用例设计和测试代码实现可能自顾不暇,也就失去了监督实现代码的能力。我 就见过有程序员在测试代码中读取实现代码生成的数据,再直接拿之来验证实现代码生成的数据,x必然恒等于x,这样的测试逻辑必然成功,但是毫无意义。

猜你喜欢

转载自blog.csdn.net/wangdd_199326/article/details/81137083