理解TDD

本文的目的是以最精炼的语言,正解什么是TDD,为什么要TDD,和TDD的难点。

什么是TDD?

简单的说,TDD = 测试先行(TFD, Test First Development) + 重构(Refactoring) + 回归测试(Regression Test)。

Image

如果要实现某个功能,TDD要求在初步定义完这个功能的外部接口之后,先根据这个功能的用例写测试代码(黑盒测试),测试代码检验的是这个功能的外部接口的使用场景,而非具体的实现细节。然后,才是实现这个功能的这些外部接口,在实现的过程中,同时还会根据需要写一些单元测试,一般单元测试测试的不仅仅是外部接口,还包括实现的内部细节(白盒测试)。关于单元测试的更多讨论,请参考:UnitTesting。如果发现这些外部接口设计得有问题,则需要进行修改和重构。每次代码修改之后,或者增加新功能之前,都需要回归运行已有的所有测试(不仅仅是测试先行的这些测试,还包括所有的单元测试)。

测试先行保证了功能的所有使用场景的逻辑性和完备性;重构则在保证功能语义的前提下,尽可能安全的改进设计和实现;回归测试则保证了任何修改不破坏任何已有的功能,这样开发人员就能放心大胆地专注于实现或改进眼前的功能。这样无疑既提高了开发人员的信心和工作效率,而且时刻保证了功能的完整性和代码的正确性。

如果对TDD详细的前世今生感兴趣,请参考:TDD on WikiPedia

为什么TDD?

TDD最大的好处是时刻确保了每次添加或修改任何代码的过程中,对任何已有的功能都是安全的。尤其是对于编译和回归测试的运行时间较短的情况下,有大牛甚至建议每新增或修改超过10行可运行的代码,就运行一次回归测试。这样不仅仅能确保你写的每一行代码的正确性,而且,基本可以避免使用单步调试来发现问题,因为,问题往往就应该在最新的这10多条代码上。

TDD的另一个好处是,相对于几十页上百页的天知道和什么年代的代码同步的技术文档来说,TDD的测试代码因为描述的就是功能的实际使用场景,并且和代码一定是实时同步的,对开发人员来说,它其实是一种既准确,又易于理解,易于维护的“技术文档”。

TDD的难点

  • 并非所有类型的代码都适合TDD,尤其是那些不能由机器简单的判断对错的情形,比如图形UI和数据库设计。
  • TDD需要让管理层意识到TDD的价值,为TDD预留额外的开发时间,并且强制每个开发人员按TDD的流程来写代码,需要自上而下的管理和开发流程的支持。

英文资料

posted @ 2010-07-30 01:24 Teddy's Knowledge Base Views(2175) Comments(24) Edit 收藏

 回复 引用 查看   
#1楼2010-07-30 02:43 | 缪军      
1、从整个工业发展来看,测试工序是生产手段成熟到一定阶段的必然产物;
2、对于软件生产,测试程式和产品程式没有先后关系,
换句话说,无论有没有测试程式,一个团队不会改变代码生产手段,
只不过,在生产线上的多道测试工序可以有效的降低废品率和控制成本;
3、软件生产的一切活动都是由项目文档驱动的,
虽然测试工具可以快速实现(其实文档就可以转换成测试,而不是楼主说的测试就是文档),
但是,产品程式的开发绝不是由测试驱动的;

 回复 引用 查看   
#2楼2010-07-30 03:14 | 缪军      
由于我们的项目跟工厂打交道比较多,
所以我从工厂里的设计到生产借鉴了不少,
在我看来,没什么不适合测试的,只不过能力有限,实现不了罢了,
越是不成熟的生产领域,测试也就越少,
软件行业就是典型的

 回复 引用 查看   
#3楼2010-07-30 08:41 | 倪大虾      
引用TDD最大的好处是时刻确保了每次添加或修改任何代码的过程中,对任何已有的功能都是安全的。尤其是对于编译和回归测试的运行时间较短的情况下,有大牛甚至建议每新增或修改超过10行可运行的代码,就运行一次回归测试。这样不仅仅能确保你写的每一行代码的正确性,而且,基本可以避免使用单步调试来发现问题,因为,问题往往就应该在最新的这10多条代码上

这点不同意.TDD最大的好处应该是作为一种设计手段体现的,用来反复验证软件是否存在严重的逻辑问题,而不会去关心细节.
简单的说,TDD是用来测试接口的,做TDD的时候接口可以没有完全实现.

 回复 引用 查看   
#4楼2010-07-30 08:44 | Steven Chen      
@缪军
不知道你的观点是否合适,但是读到你的观点让我从另外的一个角度去理解软件.谢谢.

 回复 引用 查看   
#5楼2010-07-30 08:54 | LanceZhang      
引用倪大虾:
引用TDD最大的好处是时刻确保了每次添加或修改任何代码的过程中,对任何已有的功能都是安全的。尤其是对于编译和回归测试的运行时间较短的情况下,有大牛甚至建议每新增或修改超过10行可运行的代码,就运行一次回归测试。这样不仅仅能确保你写的每一行代码的正确性,而且,基本可以避免使用单步调试来发现问题,因为,问题往往就应该在最新的这10多条代码上

这点不同意.TDD最大的好处应该是作为一种设计手段体现的,用来反复验证软件是否存在严重的逻辑问题,而不会去关心细节.
简单的说,TDD是用来测试接口的,做TDD的时候接口可以没有完全实现.


“最大的好处”本来就见仁见智,不过在此我认为Teddy兄的描述只是UnitTest的好处,而没有突出TDD的优点。相比之下,更赞同倪大虾的观点

UnitTest对有NoBug强迫症的人来说是个极好的东西,每觉得心里不太踏实的时候,就把相关的unittest跑上两圈,感觉很爽

 回复 引用 查看   
#6楼2010-07-30 09:08 | virus      
TDD要求在初步定义完这个功能的外部接口之后

那就是说TDD也是要先写代码的,至少是接口代码

 回复 引用 查看   
#7楼2010-07-30 09:09 | virus      
接口就需要设计了,那就是说还是有预先设计的,可是为什么有些人说什么都不要了,不要预先设计,不要先写代码
 回复 引用 查看   
#8楼2010-07-30 09:11 | Todd Wei      
楼主对TDD的理解与我大致相同,不过如果要用最简练的话来描述TDD的精华,我会用这样的方式:

TDD的每次迭代:[Requirement]->[Use Case]->Test Case->Coding->Test

方括号内的可能是非可见的,比如:未文档化的需求和用例。TDD中的Test Case是一种可见可执行的Use Case。

 回复 引用 查看   
#9楼2010-07-30 09:12 | virus      
我就不理解了,为什么有那么多人来曲解它呢?有目的,还是无意的,又或者是为了省事找理由?
 回复 引用 查看   
#10楼[楼主]2010-07-30 09:12 | Teddy's Knowledge Base      
@缪军
对工业生产领域我也并不是很了解。TDD的思想毕竟是来源于XP,进一步发扬于Agile,虽然没有证据表明他就不适合于其他开发方式,但至少可以说,它绝对适合于敏捷开发方法。

 回复 引用 查看   
#11楼[楼主]2010-07-30 09:16 | Teddy's Knowledge Base      
@倪大虾
关于"超过10行可运行的代码,就运行一次回归测试",其实不是我说的,见6. Why TDD? in http://www.agiledata.org/essays/tdd.html, 是大大牛Scott W. Ambler说的。我的文字其实尽可能避免主观的意见,更多的是一些收集于网络尤其是大牛们的常规的理解,再尽可能用精炼的中文来表述。

 回复 引用 查看   
#12楼[楼主]2010-07-30 09:23 | Teddy's Knowledge Base      
@virus
定义外部接口本身不一定需要写代码,因为外部接口属于high level的设计,并不涉及具体的实现语言。但是,你既然写测试代码,就已经是具体的语言层面了,无论如何,被测试的组件的这些外部接口也需要可以用语言来表述的。

 回复 引用 查看   
#13楼[楼主]2010-07-30 09:29 | Teddy's Knowledge Base      
@Todd Wei
对于你说的需求和用例在整个流程中的作用,我完全同意。不过,我觉得需求和用例是整个开发流程的组成部分,TDD同样也是。TDD关注的更多的都是具体的语言实现层面的流程,而需求和用例则一般和具体的语言实现没有直接关联。

 回复 引用 查看   
#14楼2010-07-30 09:30 | madbyte      
TDD的先T是一个设计活动,只不过穿了身测试的马甲。
1.A模块没有设计方案的时候你怎么写测试?还不是假设A应该有哪些接口,并且假设这些接口应该以什么流程去调用,然后把测试写出来。这个假设的思考过程不就是对A做设计吗?一方面设计了A的接口,捎带脚隐含设计了一部分A的内部逻辑。
2.TDD为什么是设计、检验、重构三部曲,而不是设计、检验二踢脚?因为测试只能从A模块的外部特性观察模块的设计,A模块的内部结构质量如何提升?只能靠重构。
3.重构了以后A模块的外部特性变化没?只能靠测试。
上面三步一环扣一环,最终就迭代起来了。TDD,TDD,归根结底是个D。TDD是设计,不是测试。在TDD中,只有按下“running test”按钮那一个动作,勉强算测试。

 回复 引用 查看   
#15楼[楼主]2010-07-30 09:36 | Teddy's Knowledge Base      
@madbyte
关于TDD的设计功能,你说的非常好。不过关于其测试作用,你没提到“回归测试”对TDD的重要性。“回归测试”,即使只是说TDD测试先行的这些测试用例的回归测试,对于设计和重构过程中保证所有模块的功能,至少在外部接口上不被破坏,还是相当必要的。

 回复 引用 查看   
#16楼2010-07-30 09:41 | LanceZhang      
引用Teddy's Knowledge Base:
@Todd Wei
对于你说的需求和用例在整个流程中的作用,我完全同意。不过,我觉得需求和用例是整个开发流程的组成部分,TDD同样也是。TDD关注的更多的都是具体的语言实现层面的流程,而需求和用例则一般和具体的语言实现没有直接关联。


我认为一般情况下,TDD的粒度适宜与Use Case的粒度相匹配,不少UML的文档也建议根据设计好的Use Case直接产生Test Case。

http://www.ibm.com/developerworks/rational/library/content/RationalEdge/jun01/GeneratingTestCasesFromUseCasesJune01.pdf

 回复 引用 查看   
#17楼2010-07-30 09:50 | madbyte      
@Teddy's Knowledge Base


你说的很对。我本意是想强调TDD是设计。因为很多coder被前面的T迷惑了,以为只是测试。TDD本身是设计、实现、测试3位一体的架构。为什么要3位1体?传统产品的设计为啥不TDD?因为传统产品的设计变更代价很大,导致设计节奏必然缓慢,你得容人变更前先数数兜里钱带够了吗。但软件设计变更代价小,所以设计节奏就快,反正没成本嘛,改改就改改。但节奏一快,设计、实现、测试就脱节了,模型对不上代码、代码对不上测试、测试对不上模型。这时TDD大神出现了,我给你们揉一块,所以当当当当...设计-测试-重构三部曲诞生.

 回复 引用 查看   
#18楼[楼主]2010-07-30 09:51 | Teddy's Knowledge Base      
@LanceZhang
“TDD的粒度适宜与Use Case的粒度相匹配” - 我完全同意。如果用UML来做设计,那么用例描述的正是模块的使用场景,正式TDD的测试应该服务的对象。

当然,我们也该注意到,基于UML的方式,并非唯一的有效描述模块使用场景的方式,有很多公司,很多人可能更习惯用Excel,Word,PPT等这些工具。但是,万变不离其宗,把“用例”换成“使用场景”,可能更具通用性。

 回复 引用 查看   
#19楼2010-07-30 10:37 | Ivony...      
我觉得TDD和敏捷一样,都是一种思想和指导,一旦细化成步骤,落实到行动。就变味了,很馊。。

我现在正在做的CSS3选择器,就是先按照我要实现的选择器功能,做一个测试页面,然后实现这个选择器,看看是否符合预期,然后增加更多的功能,再在页面上增加更多的测试。原有的测试并不删除,避免我在新增功能时使得原有的测试失败。最终我会得到一个完整的选择器实现和一个完整的选择器测试页。在这样做或者说在做这个之前,我从未想过我要去TDD。这是开发行为的自然表达,我也没有费劲去考虑什么单元测试,只是根据W3C的文档描述,在页面上随手写一些HTML罢了。

这给我带来了帮助,但没有困扰。

 回复 引用 查看   
#20楼[楼主]2010-07-30 10:52 | Teddy's Knowledge Base      
@Ivony...
请注意TDD测试的目标是“模块的使用场景”,而不是所有接口的细节。你的这个case,假如应用TDD,对它的期待,也只应该在于验证其对主要的“使用场景”的在整个开发实现过程中不被破坏。另一方面,如果应用TDD,你需要做自动化的回归测试。

因为TDD关注的是模块的外部接口,对一个孤立的模块来说,它的作用确实不明显,但是如果你的这个模块身处其他很多模块之中,互相直接由复杂的依赖和引用,那么在你开发,改进的过程中,可能就需要时刻注意,你的修改不会影响到其他依赖于这个组件的模块了。

另外,问个问题,即使不考虑TDD,你的这个case,开发过程中做自动化的回归测试吗?如何保证实现一个功能不破坏另一个功能呢?当你写完第10个功能,对于之前写的9个功能,是否有信心他们还是没问题的呢?你的信心来源于什么?

 回复 引用 查看   
#21楼2010-07-30 11:10 | Ivony...      
引用Teddy's Knowledge Base:
@Ivony...
请注意TDD测试的目标是“模块的使用场景”,而不是所有接口的细节。你的这个case,假如应用TDD,对它的期待,也只应该在于验证其对主要的“使用场景”的在整个开发实现过程中不被破坏。另一方面,如果应用TDD,你需要做自动化的回归测试。

因为TDD关注的是模块的外部接口,对一个孤立的模块来说,它的作用确实不明显,但是如果你的这个模块身处其他很多模块之中,互相直接由复杂的依赖和引用,那么在你开发,改进的过程中,可能就需要时刻注意,你的修改不会影响到其他依赖于这个组件的模块了。

另外,问个问题,即使不考虑TDD,你的这个case,开发过程中做自动化的回归测试吗?如何保证实现一个功能不破坏另一个功能呢?当你写完第10个功能,对于之前写的9个功能,是否有信心他们还是没问题的呢?你的信心来源于什么?



软件开发从根本上来说就不是一个可以标准化和工业化的过程。换言之测试永远也不能确保你的东西没有问题,只能保证你测试的时候没有问题而已。测试的确是提升软件质量的重要手段之一,但如果过分的依赖测试就会造成一种“没有问题”的假象。安全的假象比真正的不安全是更为可怕的事情。

任何事情我们要去考虑成本,就比如说CSS选择器,我完全没有必要去用机器去做什么自动化的测试。我只需要写下大量选择器语法,使得一个列表第一行是什么第二行是什么。然后用肉眼去辨识一下,确实如此,就能达到很好的测试效果。为什么我们一定要去依赖程序而不是眼睛?当然我也可以在页面上写下一小段脚本来验证我们的输出第一行的确是这样第二行的确是那样,如果不符跳出一个警告框告诉我,Hey,你个BC又把事情弄砸了。

那么我们来衡量一下这两种方式的成本,首先编写一个这样的脚本需要工作量,这比我在这里发一两篇回复的时间都要多。其次,所有的程序都可能存在Bug,即使是你的测试用例也是如此。可是很多所谓TDD的“狂热爱好者”完全不记得这回事情,他们总是认为自己的测试用例是完美无缺的,好吧这个跑题了。我必须采取一些手段确保我的测试脚本也是正常的,事实上综合考虑下来,这种自动化测试的方式远不如我的眼睛来的有效和敏捷。

因为我会把第一行写上first文本,而最后一行写上last文本,奇数行写上2n+1而偶数行写上2n。这样的列表是非常和谐的,当任何一点不和谐的因素出现时,我的大脑中的和谐神经就能迅速的捕捉到。至少我认为这是非常有效的测试方式。

最后回答一下如何保证实现一个功能不破坏另一个功能,这是程序员的基本功,就是充分的松耦和隔离。解决这个问题的办法不是TDD,测试只能告诉我们面临灾难,而不能告诉我们怎么去解决这个灾难。兜里多装点钱找一个很NB的技术经理,他能够使得这种灾难降低到可接受的程度。

当然我不是说测试不重要,他可以帮助发现那些意料之外的意外。

 回复 引用 查看   
#22楼2010-07-30 11:13 | Ivony...      
我的眼睛很轻易地就发现了这个列表很不和谐:
列表2
•first
•first
•first
•first
•first

而一个程序员写的测试脚本:
<script type="text/javascript">
 if ( document.getElementById( "#list2" ).firstChild.innerTEXT != "first" )
    window.alert( "你个BC又搞错了" );
</script>


则可能视而不见。

 回复 引用 查看   
#23楼[楼主]2010-07-30 12:05 | Teddy's Knowledge Base      
@Ivony...
我文中提到“并非所有类型的代码都适合TDD,尤其是那些不能由机器简单的判断对错的情形”。你的这个case中,那些“不能由机器简单的判断对错的情形”,当然是需要人工参与的。而且TDD和人工参与本身其实并不矛盾。至少,apply TDD从代码质量的角度好处更多不是吗?

当然,任何开发方法,流程的选择,都要根据项目的实际resource情况,做一个取舍和平衡,不能把TDD神话,也不必弱化。

 回复 引用 查看   
#24楼2010-08-03 18:46 | 浪子      
引用倪大虾:
这点不同意.TDD最大的好处应该是作为一种设计手段体现的,用来反复验证软件是否存在严重的逻辑问题,而不会去关心细节.
简单的说,TDD是用来测试接口的,做TDD的时候接口可以没有完全实现.


TDD可以用来规划漂亮的,自然的外部接口(fluent api), 从设计初期就开始关注外部逻辑的展现形式,同时可以促进内部的实现。

我目前在写一个template engine, 但是我不知道该如何做一个high level的design,这个我就从我希望它怎么样被使用一步步开始。

比如我只知道我想实现“不引入新语法,使用html的语义化作为绑定的表达式。”, 我就可以先规划外部的api,然后进而驱动出一个design。

test1:
"<div><label for='username'>UserName</label><input name='username' type='text'></input><lable for='blog'>Blog</lable><input name='blog' type='text'></input></div>".AsHtmlTemplate().Bind(new { UserName='dayi',Blog='http://walkingboy.cnblogs.com'}).RenderHtml();

我可以预期它的最终实现是什么,写个Assert,然后继续下一个test。

"<ul><li><span name='username'><span><a name='blog'></a> </li><li><span name='username'><span><a name='blog'></a></li></ul>".AsHtmlTemplate().Bind(new []{new {UserName='dayi',blog='http://walkingboy.cnblogs.com'},new {UserName='teddy',blog='http://teddyma.cnblogs.com'}}).RenderHtml();

next.....

最终就能驱动出一个好的high level design。