TDD到底美不美?

最近CoolShell上的一篇 《TDD并不是看上去的那么美》 引起了敏捷社区的高度关注和激励辩论。今天,InfoQ甚至专门举行了一个“虚拟座谈会” 《TDD有多美?》 ,几位国内敏捷社区的名人专门就此问题展开了深入地讨论。不论结果如何,这种探讨和反思的精神还是非常值得赞赏的。事件实际上可以简单地归纳为“一个有一定影响力的开发人员质疑TDD,一群敏捷社区名人对TDD进行解释和辩护”。现在,就让我坚定地站在CoolShell一边,为对TDD的质疑和批判添砖加瓦吧!

 

我们首先来看看TDD的核心理念是什么。第一是“用例即规范”(Specification by Example),即把测试用例作为需求规范的一种形式。传统的需求表达方式包括文档,Use Case等,而TDD强调通过测试用例来表达需求。另外,TDD的测试用例是黑盒的基于外部接口的,所以,它实际上又是对外部接口的设计。“不把测试用例单纯地视为测试,而从需求和设计的角度来看测试用例”是TDD与传统测试的一个重要区别。TDD的第二个重要理念是Test First,强调测试对于实现的驱动作用,先写测试用例,再实现和重构。Test First的实质是“先理解清楚需求,并做好外部接口设计,把它转化为测试用例,然后再来实现和重构”。 

 

如果说“用例即规范”还弥补了文档和Use Case在表达需求时的某些不足,具有一定的好处,那么 Test First则有很大的问题,尤其“在没有测试用例失败之前,不要写任何一行代码”的极端方式则更是极端的错误。

 

如果测试用例就是需求和设计,那么为什么不能先写出测试用例再来实现呢?这不是我们最熟悉的先需求再设计再编码吗?答案是: 不能执行的测试用例(Test First)和能执行的测试用例有着天壤之别, 你写出了测试用例不代表你就看到了运行的实际效果。不能执行的测试用例和写在纸上的文档相比对实现的指导意义不见得能好到哪里去!除非是一些很简单的情况下,在实际的软件开发中,你很难在没有执行测试用例的情况下写出真正符合最终需求的测试用例来。比如:你做一个页面,页面的效果需求和设计通常会在真正可以运行之后不断调整,在实现之前只能有一个大致的轮廓和方向,许多方面的细节要么是没想清楚,要么是完全没想到,不可能一蹴而就。如果片面强调测试对实现的驱动作用,那么实际上隐含了“需求和设计的细节可以在实现之前明确下来”的假设,这是非常不敏捷的和不现实的!

 

Test First要求写测试用例时对软件需求有精确的了解,但实际软件开发过程中用户需求和外部环境的不确定性会导致软件需求难以把握和频繁变动。

 

用户需求的不确定性是指“需求无法在用户真正能运行看到效果之前明确下来”比如:让你开发一套Wow这样大型的游戏,你能想象游戏的效果是设计者一开始就想好了精确到每一个细节吗?对于游戏这样的软件,需求和设计不可能脱离实际运行纸上谈兵地产生。游戏的设计者通常只能借助文档、草图、Use Case等非精确的方式大致提出需求,先做出原型,在看到效果之后才能逐步地细化和明确,需求设计的增加和改变会伴随整个软件开发过程。另外,还有一种极端的情况是根本不存在精确的用户需求,比如:自动化翻译软件,你能在实现之前就把翻译效果用测试用例固定下来吗?存在绝对正确的翻译方法吗?最近,我们和国外一家大公司客户谈一个项目需求的时候,客户讲了这样一句话“我们现阶段还无法提出很细致的需求,只有等你们拿出第一个版本,然后我们再逐步地调整细化”。我们的客户没有宣称自己在做敏捷,但人家的思维方式多敏捷啊,不是什么一上来就明确需求,而且还要精确写出自动化测试用例。有人说这种情况我们仍然可以先根据自己的理解进行TDD,这样做可以:1.基于测试用例和客户沟通明确需求;2.驱动实现。我对此持不同看法,能执行的测试用例和不能执行的测试用例有着天壤之别,客户从测试用例根本无法获得真实运行的体验,你能想象苹果把iPhone的测试用例写在PPT上,给用户做一个演讲,用户就能给出关于iPhone设计的反馈了吗?要真正的用户反馈,就需要实打实的软件,这不正是敏捷的“Working software over document”的思想吗?另外,既然用户无法在实际体验之前提出反馈,那么开发人员在开发初期做的需求分析和设计都只是一个探索,随时可能调整甚至被推翻,不值得在实现之前进行自动化测试设计的投资。

 

外部环境的不确定性是指"当我们的系统需要和外部系统集成时,关于外部系统行为的假设也无法在实际集成运行前完全确定"。例如,要做一套股票客户端连上交易所系统,因为交易所的行为会直接影响到客户端的开发,所以只有在弄清交易所行为的情况下才谈得上开发出高质量的客户端。如果采用测试驱动,编写了各种涉及交易所行为的测试用例,比如什么情况下发什么类型的消息,消息格式如何,如何交互等等,但是这些测试用例本身是否正确却需要打一个大大的问号!这一方面是由于很多交易所提供的协议都不够清晰或者有许多未明确定义的地方;另一方面即使协议没有问题,开发人员也可能由于单纯的失误或者缺乏相应领域的基本知识而把协议理解错。实际上,要真正弄清交易所的行为明确客户端的需求,最重要的手段还是在交易所提供的测试环境中跑集成测试。对于Test First来讲,测试用例本身的错误可以说是代价最大的,不仅浪费时间和精力,更重要的是还打击开发人员的士气,谁愿意来回折腾呢?但很不幸,实际情况是在最初没有明确交易所行为的时候Test First出来的测试用例随时可能在真实集成后被推翻,并且如果是比较高层的需求分析失误,那对整个架构设计来讲会是灾难性的后果。在实际开发中,我们的软件需要和其他系统集成的情况是非常普遍的,而期望在没有进行实际集成的情况下弄清外部系统的行为都是不现实和不敏捷的。

 

所以,Test First需要对于被测系统的需求和环境有精确的了解,但由于需求不确定性和外部环境不确定性两大问题,Test First在很多时候都是不现实的。其实,Test First和瀑布式思想一脉相承,都强调需求先于实现,而忽略了软件需求的产生会受到实现的反馈,会在实际运行中不断调整探索完善。TDD无非是把需求分析的结果用测试用例表达,替代传统用文档表达需求,但从宏观上看,TDD和瀑布比是换汤不换药,这都不是真正的敏捷。除了简单情况,不存在脱离实现的需求,你能够在明确了需求之后就实现出一套Linux系统吗?既然你根本无法实现一套Linux系统,那么这样所谓的需求又有多大的意义呢?所以,能提出什么样的需求不能脱离你的实现能力。需求和实现之间不是简单的谁驱动谁,而是一种相互反馈的关系,这与需求用什么方式表达没有关系。正如瀑布模型无法在初始阶段做出完美的需求分析,TDD也无法在初始阶段做出完美的测试用例;不仅如此,自动化测试用例的开发维护成本还远高于文档。所以,在敏捷环境中,软件开发初期应该通过文档和用例等手段大致表达需求,实现之后在实际运行中体验效果,不断优化探索和明确需求和外部环境,当需求和对外部环境的认识达到一个比较稳定的程度才编写测试用例将需求固化下来。

 

上面的论述主要针对贴近最终用户的外部需求(如ATDD),下面我会进一步解释即使是在内部的单元测试级别TDD仍然有问题。我们还是首先从需求入手,思考一下单元的需求是哪里来的呢?答案是:需求来自于设计!比如,对轮胎的需求来源于汽车的设计,低层模块的需求来源于高层模块的设计。而在开发初期,这种内部设计具有很大的不稳定性,带有很多假设的成分,在没有进行集成测试的情况下,很难讲这种内部设计是否合理。实际项目开发通常会在集成运行之后不断调整内部的设计,即影响单元的需求。那么,如果是测试驱动,首先按不成熟的内部设计把一个个单元需求编写成单元测试再来实现,实际上大大推迟了能进行集成测试的时间,对于真正快速弄清高层需求稳定设计反而是不利的。假设最终还是所有单元都完成,然后开始运行集成或验收测试,这时候有两种可能:1.用户看到实际效果,决定调整需求;2.发现集成前在单元层面的假设不成立或者是有没有考虑到的情况。不论是哪一种情况发生,以前所写的单元测试都面临着被废弃或必须修改的命运。实际上,多数与业务相关的单元测试用例比起集成或验收测试用例更加不稳定,因为它会受到所有其上层模块的需求和设计变动的影响。由于我们在不稳定的单元测试上浪费了大量的时间(按我的经验编写单元测试比编写实现更耗时),这就导致了迟迟无法进行集成看到实际效果,也没有办法敏捷地应对需求的调整。也就是说具有讽刺意味的, Test First理念居然是和敏捷理念矛盾的!

所以,我认为Test First不符合敏捷开发的基本假设,而真正符合敏捷的理念是“需求和设计依赖于实现的反馈,需要在实际运行过程中根据效果不断探索调整得来的,不可能脱离实际运行写出真正符合最终需求的测试用例来”。所以,我们真正应该做的是尽快看到实际运行的效果,而自动化测试作为固化的需求和设计是在看到效果之后。在集成之前花太多精力进行测试驱动只会导致迟迟看不到实际运行效果(尤其是基于开发人员自己的假设编写大量单元测试用例),看到效果需要调整需求又会废掉或改掉一大堆的测试用例。实际上,越是外部的需求其变更带来的影响和代价越大,越是需要尽早明确。从宏观上看,TDD所谓的快速反馈实际上是加快内部反馈,延迟了外部反馈,这无异于本末倒置。而大量需要修改或作废的测试用例其实是一种很大的浪费,这和消除浪费的精益思想也是矛盾的!

 

TDD到底美不美?_第1张图片

 

 

上面这幅cost/length_of_feedback_cycle图是我们常见的用于说明敏捷方法比传统方法具有更短的反馈周期,更小代价的应对变化。从图中我们可以清晰的看到在验收测试中发现的需求错误导致的代价是最高的。如果验收测试往后推迟一点,发现错误的代价将按非线性地增长。上面我们已经论述了,任何方法都不可能消除验收测试后对需求的调整,因为这是需求产生的正常过程。我们唯一可以做的是尽可能地缩短验收测试的反馈周期,但是很不幸TDD大量的内部测试只会导致推迟验收测试的时间,从而大大增加代价。在实际开发中,我提倡在第一次集成运行测试之前不要写单元测试用例;自动化的验收测试用例则视编写和维护的代价而定,如果代价比较高,则应该采用文档和Use Case来描述需求,因为这两种方式比自动化的验收测试更容易维护。编写单元测试一定是在集成以后,这样才能首先得到外部反馈,尽量先保证做正确的事情,再正确地做事

 

下面这段话来自于InfoQ文章 《Mock不是测试的银弹》 :“在使用JMock框架后测试编写起来更容易,运行速度更快,也更稳定,然而出乎意料的是产品质量并没有如我们所预期的随着不断添加 的测试而变得愈加健壮,虽然产品代码的单元测试覆盖率超过了80%,然而在发布前进行全面测试时,常常发现严重的功能缺陷而不得不一轮轮的修复缺陷、回归 测试。为什么编写了大量的测试还会频繁出现这些问题呢? ”这描述的情况和我在实践中遇到的情况类似,不过很可惜文章并没有找到问题真正的原因。真正的原因不是什么Mock不Mock,而是TDD的单元测试是基于开发人员的假设,这些假设的测试即使全部通过代码覆盖率100%,到了集成测试发现假设根本不成立或者原先在单元层面很多情况没有考虑到,这又怎能保证高质量?在TDD的实践者中我见到过不少类似这样的,他们很认真,编写了很多单元测试用例,代码覆盖率也很高,但他们其实是有意无意在 先正确地做事(单元测试),再做正确的事(集成测试),这就是本末倒置。

 

当然,我不是全盘否定TDD。TDD在某些需求比较固定的场合是适用的,尤其是与具体业务关系不大的需求,比如:写一个通用的数据结构,实现一个通用算法。TDD的先关注需求和思考外部接口设计的理念也对促进开发人员的抽象思维有很大益处。另外,TDD通常也具有较高的代码覆盖率。本文的主要观点在于:实际项目中,由于用户需求不确定性和外部环境不确定性,不要期望可以在实现之前完全明确需求,需求是在实际运行看到效果之后才逐步明确的;我们的开发过程必须能够敏捷地适应需求的变化,而TDD的Test First理念恰好与之矛盾。所以,对于TDD不了解的朋友,我建议应该学习和实践TDD,从而获得其益处;同时我也提醒TDD存在理论上的缺陷,这是在实践中需要特别留意的。

 

你可能感兴趣的:(TDD)