TDD到底美不美?
最近CoolShell上的一篇《TDD并不是看上去的那么美》引起了敏捷社区的高度关注和激励辩论。今天,InfoQ甚至专门举行了一个“虚拟座谈会”《TDD有多美?》,几位国内敏捷社区的名人专门就此问题展开了深入地讨论。不论结果如何,这种探讨和反思的精神还是非常值得赞赏的。事件实际上可以简单地归纳为“一个有一定影响力的开发人员质疑TDD,一群敏捷社区名人对TDD进行解释和辩护”。现在,就让我坚定地站在CoolShell一边,为对TDD的质疑和批判添砖加瓦吧!
我们首先来看看TDD的核心理念是什么。第一是“用例即规范”(Specification by Example),即把测试用例作为需求规范的一种形式。传统的需求表达方式包括文档,Use Case等,而TDD强调通过测试用例来表达需求。另外,TDD的测试用例是黑盒的基于外部接口的,所以,它实际上又是对外部接口的设计。“不把测试用例单纯地视为测试,而从需求和设计的角度来看测试用例”是TDD与传统测试的一个重要区别。TDD的第二个重要理念是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也无法在初始阶段做出完美的测试用例;不仅如此,自动化测试用例的开发维护成本还远高于文档。所以,在敏捷环境中,软件开发初期应该通过文档和用例等手段大致表达需求,实现之后在实际运行中体验效果,不断优化探索和明确需求和外部环境,当需求和对外部环境的认识达到一个比较稳定的程度才编写测试用例将需求固化下来。
所以,我认为Test First不符合敏捷开发的基本假设,而真正符合敏捷的理念是“需求和设计依赖于实现的反馈,需要在实际运行过程中根据效果不断探索调整得来的,不可能脱离实际运行写出真正符合最终需求的测试用例来”。所以,我们真正应该做的是尽快看到实际运行的效果,而自动化测试作为固化的需求和设计是在看到效果之后。在集成之前花太多精力进行测试驱动只会导致迟迟看不到实际运行效果(尤其是基于开发人员自己的假设编写大量单元测试用例),看到效果需要调整需求又会废掉或改掉一大堆的测试用例。实际上,越是外部的需求其变更带来的影响和代价越大,越是需要尽早明确。从宏观上看,TDD所谓的快速反馈实际上是加快内部反馈,延迟了外部反馈,这无异于本末倒置。而大量需要修改或作废的测试用例其实是一种很大的浪费,这和消除浪费的精益思想也是矛盾的!
上面这幅cost/length_of_feedback_cycle图是我们常见的用于说明敏捷方法比传统方法具有更短的反馈周期,更小代价的应对变化。从图中我们可以清晰的看到在验收测试中发现的需求错误导致的代价是最高的。如果验收测试往后推迟一点,发现错误的代价将按非线性地增长。上面我们已经论述了,任何方法都不可能消除验收测试后对需求的调整,因为这是需求产生的正常过程。我们唯一可以做的是尽可能地缩短验收测试的反馈周期,但是很不幸TDD大量的内部测试只会导致推迟验收测试的时间,从而大大增加代价。在实际开发中,我提倡在第一次集成运行测试之前不要写单元测试用例;自动化的验收测试用例则视编写和维护的代价而定,如果代价比较高,则应该采用文档和Use Case来描述需求,因为这两种方式比自动化的验收测试更容易维护。编写单元测试一定是在集成以后,这样才能首先得到外部反馈,尽量先保证做正确的事情,再正确地做事。
当然,我不是全盘否定TDD。TDD在某些需求比较固定的场合是适用的,尤其是与具体业务关系不大的需求,比如:写一个通用的数据结构,实现一个通用算法。TDD的先关注需求和思考外部接口设计的理念也对促进开发人员的抽象思维有很大益处。另外,TDD通常也具有较高的代码覆盖率。本文的主要观点在于:实际项目中,由于用户需求不确定性和外部环境不确定性,不要期望可以在实现之前完全明确需求,需求是在实际运行看到效果之后才逐步明确的;我们的开发过程必须能够敏捷地适应需求的变化,而TDD的Test First理念恰好与之矛盾。所以,对于TDD不了解的朋友,我建议应该学习和实践TDD,从而获得其益处;同时我也提醒TDD存在理论上的缺陷,这是在实践中需要特别留意的。
posted on 2011-02-23 22:42 Todd Wei 阅读(9591) 评论(57) 编辑 收藏 举报