西西河

主题:【原创】闲聊敏捷编程——测试驱动开发(一) -- 代码ABC

共:💬55 🌺131 新:
分页树展主题 · 全看 下页
  • 家园 【原创】闲聊敏捷编程——测试驱动开发(一)

    测试驱动开发(二)

    几乎所有的敏捷编程方式都会采用测试驱动开发过程。所以我先聊聊测试以及测试驱动开发。

    软件测试有一句名言——就算是非计算机专业大多也曾听过。原话是:“程序测试是表明存在故障的非常有效的方法,但对于证明没有故障,测试是很无能为力的”。这句话是图灵奖——计算机界的炸药奖——获得者Edsger Wybe Dijkstra(这个名字饶舌得很)说的。而魔鬼的翻译就是再严密的测试也不能证明程序的正确性。魔鬼接着说,但是,为了避免一些愚蠢的错误,你必须进行测试!当然,那些无法避免的错误也是愚蠢的。

    无可奈何的是所有软件工程的书都会强调测试的重要性,同时会花相当的篇幅讲解各种测试方法。当你按照软件工程要求组织测试的时候,你会发现你走进了魔鬼的乐园。测试是要花时间的,花人力的,也就是说成本高昂。一般项目的测试开销和开发的开销是同一量级的。精力花出去了,而回报是不能证明程序是正确的,这无论如何都是一件令人沮丧的事情。当你准备咬牙认命的时候,你有可能听到魔鬼嘲讽的声音——你会测试吗?事实上许多程序员(包括有些年头的程序员)是不懂如何写测试的,或者不懂得如何写出方便测试的代码。

    其实魔鬼是有弱点的,弱点就在那句关于测试的名言。测试驱动开发用一种智慧的手段推翻了那句话,也就是测试是用来证明程序是正确的!它的逻辑很简单:所有需求都是可以测试的,凡是不能测试的需求都是无法实现的。所以只要程序通过了这些测试那么程序就是正确的。那么如果测试不严密怎么办?很好办,这证明了测试是错的,程序没错!这实在是一个无法打败的逻辑,很伪科学,是吧。其实这是一种智慧。

    我曾经写过一篇文章(编程随想)说写程序就是一种翻译过程,是将人们的想法翻译成代码的过程。由于用来描述想法的语言是有冗余的,而代码是无冗余的。那么在翻译过程就不可避免地会引入错误。为了减少这种错误,计算机的专家们开发许多工具和描述方式。软件工程学则规定了翻译的步骤以及每个阶段成果的格式。比如一个想法——或者说软件功能,首先要变成需求文档,再变成概要设计文档,再变成详细设计文档最后才变成代码。在这种过程中自然语言变成代码需要好几个步骤,每个步骤都可能引入错误。所以我们可以指责需求分析错了、概要设计错了、详细设计错了、代码错了。而测试驱动开发不同,它没有所谓的需求文档,而是直接把需求变成测试代码。因此如果程序通过了测试,出现错误的地方就只能是测试。注意这里测试就是需求。搞明白了这点就算是对测试驱动开发入门了。

    有人会说,这和一般的软件工程的测试没有什么本质的区别嘛。软件工程是根据需求文档来编写测试的。而测试驱动开发只不过是将需求文档编写步骤省去而已。而且这样还增加了测试编写的难度。

    在前一篇闲聊我说过从软件工程到敏捷开发是一个螺旋上升的过程——或者文绉绉地说叫扬弃,也就是说不少软件工程的概念被继承下来了,但他们不是一成不变的拷贝而是进化成另一种形式。比如需求分析现在进化为测试代码的开发。而测试则和代码开发以一种新的方式结合起来。

    我们知道许多想法描述起来是复杂的,甚至很难用一个定义表述清楚。文科生别拍我,这里要求定义是清晰无歧异的,并且所使用的概念和规则是自洽的。不难吗?那么谁能定义西西河的认证用户是什么?

    啊!认证用户!谁捞我出来?——某个很有残念的定义。

    认证用户是一群不容易惹麻烦的用户——铁手的定义。

    认证用户是由不小于15个认证用户赞成,不大于8个用户反对的用户。——循环定义。

    认证用户是一种特权用户。——嗯,有点味道,但是什么是特权用户呢?

    认证用户回帖不用验证。——好了,说到点子上了。

    通常这些想法的描述会变成对概念外延的描述,也就是说像上面的定义可以描述为:如果一个用户回帖不用验证,那么这个用户就是认证用户。注意这个句式和最后那个描述的不同,这里是一个条件判断描述。也就是我们把一个想法——功能需求变成了一个测试。我们通过转换概念外延的描述构造出一系列的判断,据此定义一个概念或者一个功能。通常为了定义一个概念或功能需要若干条判据。因此我们可以写下若干个测试。不过在测试驱动开发中这些测试不是一股脑写出来的,而是逐个写出。每写出一个就要写出能够通过这个测试的代码。这样做的目的是降低分析的难度,并且在构造代码的过程中保持思路的清晰。比如完成回帖验证的判断后,我们会想起认证用户还需要在其页面上加一个图标。这样我们的程序就在这一个个测试中丰满起来。

    打个有点火药味的比方。以前的方法就像开炮:方位0110,距离16000,榴弹发射!——这是需求描述。然后看看命中了没有——这是测试。没命中!向左修正10,距离修正100,发射!这还是固定目标——需求没变。移动目标就更费劲了。

    测试驱动则向导弹,发射的时候只要大致对准就可以了,在飞行过程中不断有修正指令——逐步发掘出来的测试。这样不管固定目标还是移动目标,通杀。

    好吧,我承认现实不是那么美好的。

    我承认这种测试也是不完美的,它也会漏掉一些关键的内容。甚至本身就是错误的。但至少我们把可能出错的地方限制在一个仅此一个地方上了。这就是进步。至于如何解决上面的问题,我们下回分解。

    测试驱动开发(二)

    关键词(Tags): #敏捷编程#测试驱动开发元宝推荐:铁手,

    本帖一共被 2 帖 引用 (帖内工具实现)
    • 家园 讲测试驱动开发,怎么不把常用的测试工具列上?

      或者这是单独的一部分?

    • 家园

      完全看不懂是啥意思?偶是工科生,弄机械的,看的晕呼呼的,楼主能弄得更容易点吗?加点糖衣,炮弹就免了:)

      • 家园 我也晕

        写代码在外行人看来就是一个鬼画符的过程,也许大夫的病历和这个类似。您确定您感兴趣?

    • 家园 测试驱动开发(二)

      软件开发组织过程中需要完成两个任务:第一是让需求描述更精确,第二是让代码能够准确反映需求。现实的矛盾是需求会发生变化,而变化导致代码难以准确反映需求。于是人们引入测试和设计。测试用于验证和检查(更多的是检查)代码和需求的契合程度,设计则希望代码可以更好地适应变化。

      对比传统的软件开发模型和敏捷方式我们可以看出,前者更强调第一个任务,文档、字典、审核、会议等各种过程都是为了发掘需求、描述需求和规范需求变更而做的。其代价是需求变更的反应时间变长。作为程序员的本能对变更代码有强烈的抵触,而客户则不可能在一开始就将需求描述清楚,因此在实际项目中这些软件工程的工具常常不自觉地(有时是故意的)被用作开发团队和客户扯皮的工具。这样就偏离了这些过程原本设计的目的。在保持需求稳定和代码返工的选择上大多数开发团队会本能地选择前者,虽然最终大多需要屈服。因为需求变更反映的是软件的价值。

      敏捷方式选择了另一个解决方法,让代码以最大的灵活性来适应需求的变更。敏捷的一个含义就是代码的灵活性。如果我的代码可以随时修改,那么我自然不担心需求发生变更,也不太担心需求定义不准确。这样做法的代价是什么呢?我觉得最大的代价是程序员必须用一种全新的观点来看待设计和测试,对于大多数程序员来说这是一个挑战。

      让代码随时保持变更的能力的一种方法就是测试驱动开发。首先测试驱动开发的过程本身就让我们不断地修改代码,即使需求是确定的。因为在开发过程中有一个原则——保持未完成的工作最大化。具体的要求是让代码只能通过已写出的测试,令下一次测试就会击败现有的代码。或者说不允许超前设计,哪怕你知道下一分钟就就需要变更需求也不要为这个变更去修改设计。其过程就像雕刻,每一刀要求不多也不少,每一刀之后再回头看看需要再哪里下一刀(测试)。在Robert C.Martin所写的《敏捷软件开发 原则、模式与实践》一书中有一个开发保龄球计分的例子就很好地说明了这个过程。一般的开发方法会在一开始就将保龄球的规则全部考虑进来,如补中、全中等等,然后以此做一个设计。然后编码,最后用一系列测试来验证代码。然而在极限编程(敏捷开发的一种)中则是每写出一个测试就写一段代码实现,和以往的开发方式最重要的区别在于实现的代码只考虑已经写出的测试而不去理会真实的保龄球计分。这就是测试驱动开发中“驱动”两字的含义。代码的变更只受测试变更的影响。这样的代码是很简单的——至少开始是很简单的。

      简单的代码和简单的设计可以随时抛弃和变更——这就是敏捷的核心!而对程序员的挑战也在于此。我们——尤其是有经验的程序员总会被设计所诱惑。我们试图在一开始就写出一个包罗万象的架构,其中包含了大量可能并不需要的东西。最终的结果就是这个设计在多次变更后变得僵化,不容易修改,导致我们不愿意放弃,这也是我们抵触需求变更的基础。也许有人会反对,高手们反对会更加的激烈。因为他们知道良好的设计本来就是为了对付变更的,许多大牛可以在项目开始不久就设计出一个灵活的架构,将每个模块间的耦合降到最低,这样的架构对变更也是友好的。我承认这是事实,不谦虚地说我也是这样的人——曾经。

      有一句话:把复杂的事情搞简单是一件很困难的事情,反过了则很容易。体会最深的时候就是我被敏捷思路彻底洗脑的时候。首先作为一个老程序员——或者说大龄程序员,我知道实现一个功能通常不止一种方法,每次都能在一开始找出最佳方法的概率太小了。事实上我经常在后来发现有更简单的方法,而这些简单方法基本都是在需求大部清晰的时候才发现的,而我的复杂方法得复杂性在于我过多地考虑了如何应付客户不可能发生的需求上。这些设计的复杂性常常阻止我去对现有代码进行修改,带着遗憾交付或者根本不知道存在遗憾的交付发生的次数是很多的。还有一些情况,这个复杂设计未必能完全覆盖客户的需求变更——这种情况更多,那么我们的选择是什么呢?一般来说我们只能对原有的设计进行修补而不会推倒重来,因为那意味着大量的返工。当这种修补(尤其是在进度压力下)积累到一定程度之后,我们的代码必然变得僵化再难以适应新的变更,最后就陷入了修补——僵化——修补的恶性循环中。有经验的程序员会在适当的时候对设计进行重大修改——这需要勇气。大牛们通常不缺乏这种勇气,事实上有这种勇气的人才叫大牛。因为大牛知道如何最快地完成这种修改。敏捷的模式是不需要大牛的,如果一开始就不做超前设计,而是让设计在构建过程中逐步建立反而容易形成优化的设计。在这个过程中测试驱动开发经常是推倒原有的设计——小范围的推倒,让程序员习惯对自己的代码动手动脚。在潜移默化下水准上的程序员也会具备推倒的勇气和技巧。这样我们的开发团队就逐渐具备了随时修改代码的能力,从而进化到敏捷团队。

      也许有人觉得这种不断推倒重来的过程效率很低,但是别忘记再测试驱动开发中,测试是不断建立的,也就是就项目进度而言需求已经是在不断地完善,推倒的主要是一些拙劣的设计而不是项目本身。

      以上我讲的范畴基本都局限在如何写代码这个过程上,很少涉及项目、客户的问题。这其实很正常,因为题目是测试驱动,这个过程主要的精力是如何写代码。在敏捷开发中代码是关键,如果不能掌握敏捷代码的编写其他敏捷思路都是空中楼阁。在写了那么多年的代码之后再来学习怎么写代码的确是一件令人汗颜的事情。测试驱动开法还有不少内容,比如我是怎么理解设计、怎么理解测试和进度的关系等等。真是一个没完没了地话题。

      关键词(Tags): #敏捷编程#测试驱动开发

      本帖一共被 1 帖 引用 (帖内工具实现)
      • 家园 TDD虽然不错,实际做下来还是很多问题

        比如我现在最大的问题就是基于某些框架和方案,这往往是难以做白合性质的单元测试,只能用一些框架去做自动化的确认测试,维护成本非常高。而且我感觉也失去了tdd的本意。最适合开发人员写的,应该是百盒才对。

        不知道这方面你有什么好建议? 特别是ui这块,很多web 框架都是基本不可测试的。 httpunit或selenium那样的东西,感觉上有些跑题了,而且对应的测试用列的维护台痛苦。

        另外测试代码和实际代码的比例达到一个什么程度为好,也值得讨论。tdd虽然给出了一些结果导向的标准,但是实际做下来感觉还是不够清晰。我们有些程序员基本上都只写大方法的,这样的测试覆盖率太低。

        说到这,又要提重构了。但是基于历史原因,很多代码也是比较难用勇气做重构的。比如今天就是,2个人pp 重构完了别人的代码,提交以后cc服务器变红。虽然自己基本确定不是改动那部分的任务, 但是因为test case的覆盖率不高,还是需要手工去检查核实,挺痛苦的。这也再次说明没有完善测试用列的重构是非常危险的。

        agile开发说起来真是一言难尽,目前看到最接近agile本质的团队还是在国外的时候。国内的所谓agile圈子里做的,很多都是挂个皮而已,有些所谓专家翻译了一堆书,真跟他们讨论起来,发现也只知道皮毛而已。

        举个最简单的例子,勇气这一点,国内程序员就很难做到。昨天发现一个资深程序员居然不明白数据库的索引是如何使用的,对基本概念有完全错误的认识,而且对我的提醒还很是自信,特意写了个长邮件调侃了他一下,就是这样,他也只是答复说忘记加索引了,不肯承认自己对索引概念的认识是错误的。国人做事将情面,但是技术人员如果没有勇气把这些面子的屏障脱去,就很难真正有效的进行沟通。

        最近跟经理闹矛盾,其中一点就是个人发现几个大牛对数据库的认识很肤浅,由着他们去倒腾性能优化很危险,希望能强制对他们进行一下基础普及教育,结果上下都不感冒。程序员不乐意承认自己不懂基础,经理觉得我是浪费时间,也在打压大家工作积极性。 这算是文化背景的巨大差异了。

        算了,比起国内某著名技术人员,号称作了n年的dba,居然连数据库范式都不明白,我们这些大牛也算不错了,用经理的话来说,可以解决问题么,你折腾啥,又不是火箭工程。但是本质上,就少了国外程序员那种不断追求细致,追求细节,追求完美的精英作风。这点我看来也是敏捷的本质之一,敏捷中的程序员不要求是大牛,但是要有追求完美的愿望,而且乐意从小事做起,从细节做起,而我们太多人只喜欢做那些炫耀技巧性质的东西。

        通宝推:李根,
        • 家园 个人的一些意见

          UI测试是一个难点,我个人的做法是将UI先抽象成输入/输出模块,然后用Mock技术模拟UI操作完成非UI部分的测试。到具体的UI部件则使用控件技术将复杂UI分割为简单模块,可以覆盖大部分UI功能。最后使用LoadRunner之类的东西做传统的功能测试(黑盒)实际效果是95%以上的代码可以在白盒内完成测试。

          我个人不喜欢框架,在不得不用框架的时候我会把框架作为一个软件包。通过Adapter,FADE之类的东西封装一下。把我的代码和他们隔离,只做自己的代码测试。

          测试覆盖率其实和你的开发方法有关,一定要记住TDD的一个原则——让未完成的工作最大化。覆盖率不够经常是由于预先设计引起的,这些设计使得程序员忽略的一些必须的测试用例——他认为代码中已经实现了不需要测试。这个原则其实是最难把握的,没有太好的方法。只能在实践过程中慢慢领会。同时XP的结对编程可以在很大程度上缓解这个问题。无奈,结对编程也是一个不太容易掌握的方法。同样的问题也会造成重构的麻烦,在TDD中重构是在每实现一个测试后进行的,也就是你必须保证你的代码一直处于合理的设计状态。

          敏捷说起来很好,但实践起来并不容易。由于其中的东西是一环扣一环的,你说的很对,单看其中一个实践其缺陷都很明显,但是这些缺陷都会被其他实践覆盖。而反过来的意思是只要你有一个实践不做,就可能引起其他实践的缺陷。

          不懂范式的DBA我见多了(这句话在河里说,恐怕要挨砖)我在讲数据库优化的课程里,下面坐的都是N年的DBA,能说出来的不到一半。

      • 家园 对程序员来说,不能过分强调测试驱动,

        因为程序执行过程是动态的,你不可能写出全覆盖的测试.还不如把时间花在写好代码,检查代码上面.

        个人理解,测试驱动主要还是用在明确需求,验证需求实现上(Tester,Client).

        重构,我们每天都在做,只不过不像现在上升到理论高度.

        • 家园 对于xp来说,全覆盖的测试是不可能,也是不需要的

          xp的peer review是通过结对进行的,设计是通过频繁的沟通和暗喻来做的。这比通常的pr和设计review更有效。xp很容易造成误解的地方就是单个看他的某个过程或实践都是有重大缺陷的,他强调的就是通过所有实践的联合使用来弥补彼此的缺陷,以最小的成本交付产品。

        • 家园 测试不仅仅是用来验证和明确需求

          在测试驱动开发中测试还起到其他作用,这些作用和明确需求一样重要。比如设计,你必须设计出一个可以测试架构,否则你无法写出测试代码,这样就迫使你设计出松耦合的结构。重构也是测试驱动的,为什么重构,重构的方向是什么这些都是通过测试指出来的。

          由于我用的是极限编程的方法,根本无法绕开测试驱动,不过就我所知其他的敏捷模式也很难绕开。这是保持代码灵活性的基本手段。

          • 家园 写测试代码,可以帮你理清需求,进一步的,就知道How .

            to do it.

            松耦合的结构,模块与模块之间在功能上是独立的,正交的。你在明确需求,明确功能模块的划分之后就可以做到.

            敏捷也强调不可过度设计.一开始编码也不可过分追求松耦合,只针对我能看得到的需求变化进行合理的设计.

            与其说重构是测试驱动的,还不如说是需求驱动的。假如说程序中原来已经使用一种图像A格式的编码库,现在需求要增加一种新的图像格式B的编码库,不过两者调用接口不一样,那么只有重构,增加一个共同中间层或给B增加一个中间层.

            PS:看过几个java程序的代码,对需求的变化十分敏感

            ,导致中间层一层套一层,接口太多.

            软件工程的目的,是将复杂东西分解,做到简单化,模块化。在遇到具体的需求时,具体对待。

            • 家园 一开始的时候我也是这么想的

              有很多原则需要动手实践之后多次对比才能领会到那些敏捷的意思。

              敏捷反对过度设计,反对超前设计,但是不反对为了测试而进行设计,而且提倡这种设计。另外要注意测试驱动开发是每写一个测试就写一段实现,而不是将测试全部写出来再写代码。一方面降低设计难度,另一方面细化需求的发掘。也就是设计是逐步变化的。

              重构的概念不是因为需求变化而修改程序,重构的意思是在不改变程序运行结果的前提下,修改和优化代码。象你举的例子那样的修改不是重构而是重新设计。重构在测试驱动开发中用来让代码能够灵活地适应新的需求变更,比如根据图像格式B的要求对代码进行修改后。再考虑如果再有新的图像格式怎么办,根据这个思路对代码的变更才是测试驱动中的代码重构。

              这两个概念——设计和重构我还没想好怎么写。。。。

              • 家园 refactor vs redesign

                refactor比喻象是汽车5年一改款之前的每年一升级时的小改,是循序渐进的,是不伤大雅的。

                redesign是5年一次的改款,市场定位或有重大缺陷等,需求出现重大改动,而使原来的底盘,引擎,外观等必须重新设计。

    • 家园 测试驱动也有适用范围

      测试驱动则向导弹,发射的时候只要大致对准就可以了,在飞行过程中不断有修正指令——逐步发掘出来的测试。这样不管固定目标还是移动目标,通杀。

      这主要是针对飘忽不定的“小需求”而言,有人曾经根据需求范围把需求分为兔子、奔马、大象三个级别,针对大象级的需求用敏捷方法、测试驱动就有些力不从心了,灵活有余,而力道不足。况且由于需求范围的扩大,势必造成,测试的用例剧增,抓住问题域核心的难度加大。这也是域驱动开发提倡的重点,认为用户才是真正的行业专家,通过和用户的接触,捕捉问题的核心,然后辅以成型的业务模型和开发框架,搭起系统架构,然后利用敏捷方法,局部进行重构,避免过度设计

      • 家园 是啊,这就是软件开发的复杂性了

        我这里讲的都是局部问题,归根到底是程序员的事情。但软件开发不仅仅是程序员的事。一发便携式对空导弹可以干掉一架直升机,但是要对付一枚洲际导弹就不仅仅是一发标准3的事情了。

分页树展主题 · 全看 下页


有趣有益,互惠互利;开阔视野,博采众长。
虚拟的网络,真实的人。天南地北客,相逢皆朋友

Copyright © cchere 西西河