测试的道理在长期的程序语言研究和实际工作中,我摸索出了一些关于测试的道理。然而在我工作过的每一个公司,我发现绝大多数人都不明白这些道理,很多团队集体性的采用错误的做法而不自知。很多人把测试当成一种主义和教条,进行过度的测试,不必要的测试,不可靠的测试,并且把这些错误的做法传授给新手,造成恶性循环。本来目的是提高代码质量,结果不但没能达到目的,反而降低了代码质量,增大了工作量,大幅度延缓工程进度。 我也写测试,但我的测试方式比“测试教条主义者”们的方式聪明很多。在我心目中,代码本身的地位大大的高于测试。我不忽视测试,但我不会本末倒置,过分强调测试,我并不推崇测试驱动开发(TDD)。我知道该测试什么,不该测试什么,什么时候该写测试,什么时候不该写,什么时候应该推迟测试,什么时候完全不需要测试。因为这个原因,再加上高强的编程能力,我多次完成别人认为在短时间不可能完成的任务,并且制造出质量非常高的代码。 测试的道理现在我就把这些自己领悟到的关于测试的道理总结一下,其中有一些是鲜为人知或者被误解的。
举一个亲身的例子。我在 Google 做出 PySonar 之后,最后一个测试都没写。第二次我回到 Google,我的上司 Steve Yegge 对我说:“你走了之后,我改了一些你的代码,真是太清晰,太好把握了,修改你的代码是一种快乐!” 这说明什么问题呢?我并不是说你可以不写测试,但这个例子说明,测试对于后来人的作用,并不是你有些人想象的那么大。创造清晰的代码才是解决这个问题的关键。 这种怕人突然走了,代码无法维护的想法,导致了一些人对测试过分的重视,但测试却不能解决这种问题。相反,如果测试太繁琐,做不必要的测试,反而容易让员工不满,容易走人,去加入在这方面更加有见地的公司。有些公司以为有了测试,就可以随便打发人走,这种想法是大错特错的。你需要明白的一个事情是,代码永远是属于写出它的那个人的,就算有测试也一样。如果核心人物真的走了,就算你有再多的测试也没用的,所以解决的方法就是把他们留住!一个有远见的公司总是通过其他的手段解决这个问题,比如优待和尊重员工,创造良好的氛围,使得他们没那么快想走。另外,公司必须注意知识的传承,防止某些代码只有一个人理解。 案例分析有人会疑问,我凭什么可以给别人讲这些经验,我自己为此有什么成功的案例呢?所以现在来讲讲我做过的几个东西,以及我亲眼目睹的测试教条主义者们的失败案例。 很多人可能听说过我在 Google 做的 PySonar。当时 Google 的队友们战战兢兢,说这么高难复杂的东西要从头做起,几乎是不可能的。特别是某位队友,一开头就吵着要我写测试,一直吵到最后,烦死我了。他们为什么这么担心呢?因为对 Python 做类型推导是非常高难度的代码,需要相当复杂的数据结构和算法,需要精通 Python 的语义实现。 作为一个训练有素的专家,我没有在乎他们的咋呼,没有信他们的教条。我按照自己的方式组织代码,进行精密的思考,设计和推理,最终在三个月之内做出了非常优雅,正确,高性能,而又容易维护的代码。PySonar 到现在仍然是世界上最先进的 Python 类型推导和索引系统,被多家公司采用,用于处理数以百万计的 Python 代码。, 如果我当时按照 Google 队友的要求,采用已有的开源代码,或者过早的写了测试,别说无法在三个月的实习时间之内完成这个东西,就算折腾好几年也没有可能。 Shape Security这种思维方式最近的成功实例,是给 Shape Security 做的一个先进的 JavaScript 混淆器(obfuscator)和对集群(cluster)管理系统的改进。不要小看了这个 JS 混淆器,它的混淆能力要比 uglify 之类的开源工具强很多,也快很多。它不但包含了 uglify 的变量换名等基本功能,而且含有专门针对人类和编译器的复杂化,使得没人能看出一点线索这个程序到底要干什么,让最先进的 JS 编译器也无法把它简化。 其实这个混淆器也是一种编译器,只不过它把 JavaScript 翻译成不可读的形式。在这个项目中,由于失之毫厘就可以差之千里,我采用了从 Chez Scheme 编译器学过来的,非常严密的测试方法。对每一个编译器的步骤(pass),我都给它设计一些正好可以测到这个步骤的输入代码(比如,具有函数定义的,for循环,try-catch的,等等)。Pass 输出的代码,经过 JavaScript 解释器执行,把结果跟原来程序的执行结果对比。每一个测试程序,经过每一个 pass,输出的中间结果都跟标准结果进行对比,如果错了就表明那个 pass 有问题,出错的小程序会指出大概是哪一个部分出了问题。遵循小巧,不冗余,不重复的原则,我总共只写了40多个非常小的 JavaScript 程序。由于这些测试涵盖了 JavaScript 的所有构造而且几乎不重复,它们能够准确的定位到错误的改动。最后,这个 JS 混淆器能够正确的转换像 AngularJS 那么大的项目,确保语义的正确,让人完全无法读懂,而且能有效地防止被优化器(比如 Closure Compiler)简化掉。 相比之下,过度鼓吹测试和可靠性的人,并没能制造出这么高质量的混淆器。其实在我进入团队之前,里面的两三位高手已经做了一个混淆器,项目延续了好多个月。这片代码一直没能发布给客户用,因为它的换名部件总是会在某些情况下输出错误的代码,修改了好多次仍然会出错。不是100%的正确,这对于程序语言的转换器来说,是不可接受的。换名只是我的混淆器里的一个步骤,它还包含大概十个类似的步骤,可以把代码进行各种转换。 在实现换名器的时候,队友们让我直接拿他们以前写的换名代码过来,把 bug 修好就可以。然而看了代码之后,我发现这代码没法修,因为它采用了错误的思路,缝缝补补也不可能达到100%的正确,而且明显效率低下,所以我决定自己重写一个。由于轻车熟路,我只花了一下午的时间,就完成了一个正确的换名器,它完全符合 JavaScript 的语义,各种奇葩的作用域规则,而且结构非常简单。说白了,这个换名器也是一种解释器。对解释器的深刻理解,让我可以很容易的写出任何语言的换名器。 不幸的是,历史再次重演了 ;) 队友们听说我花一下午重写了一个换名器,非常紧张,咋呼地跟我说:“你知道我们的换名器是花了多少个月的时间做出来的吗?你知道我们写了多少测试来保证它的正确性吗?你现在一下午做出来一个新的,你如何能保证它的正确!” 我不知道他们怎么好意思说出这样的话来,因为事实是,他们花了这么多个月,耗费这么多人力,写了这么多的测试,做出来的换名器却仍然有 bug,没法用。当我把我写的测试和几个大点的 open source 项目(AngularJS, Backbone 等)放进他们的换名器之后,就发现有些地方出问题了,而所有的测试和 open source 项目通过我的换名器,却得到完全正确的代码。另外经过性能测试,我的换名器速度要快四倍的样子。所以就像 Dijkstra 所说:“最优雅的程序往往也是最高效的。” 结束这个项目之后,我换了一个团队(cluster团队),这个团队的人要好很多,低调而且幽默。Shape Security 的产品(Shape Shifter)里面包含一个高可靠(HA)集群管理系统,它可以通过网络,选举 leader,构建一个高容错的并行处理集群。这个集群管理系统一直以来都是公司里很复杂,却是可靠性要求最高的一个部件,一旦出问题就可能有灾难性的后果。确实,它当时可靠性非常高,从来没出过问题。但由于历史原因,它的代码过度复杂而缺乏模块化,以至于很难扩展来应付新的客户需求。我进入这个新团队的任务,就是对它进行大规模的简化,模块化和扩展,让它满足新的需求。 在这个项目中,由于代码的改动幅度很大,在同事和部门领导的理解,信任和支持下,我们决定直接抛弃已有的测试,完全靠严格而及时的 code review,逻辑推理,推敲讨论,手工试验来保证代码的正确。在我修改代码的同时,一位更熟悉已有代码的队友一直通过 git 默默监视着我的每一次改动,根据他自己的经验来判断我的改动是否偏离了原来的语义,及时与我交流和讨论。由于这种灵活而严格的方式,工程不到两个月就完成了。改进后的代码不但更加模块化,更可扩展,适应了新的需求,而且仍然非常可靠。假设部门领导是“测试教条主义者”,不允许抛弃已有的测试,这样的项目是绝对不可能如期完成的。然而在当今世界遇到这样领导的机会,恐怕十个人里面不到一个吧。 Coverity最后,我举一个由于测试方式不当而非常失败的案例,那就是 Coverity 的 Java 静态分析产品。我承认 Coverity 的 C 和 C++ 分析器也许是非常好的,然而 Java 的分析器,很难说。当我进入 Coverity 的时候,同事们已经忍受了整整一年的管理层的威逼和高压,超时过劳工作,写出了基本的新产品和很多的测试。可是由于技术债太多,再多的测试也没能保证产品的可靠性。 我的任务就是利用我深入的 PL 知识,不停的修补前人留下来的各种蹊跷 bug。有些 bug 需要运行20多分钟之后才出现,一次还看不出是怎么回事,所以修起来非常耗时。有时候我只好趴在电脑前面养神,时不时的睁眼看看结果。Coverity 是如此的在乎测试,他们要求每修复一个 bug 你就必须写出新的测试。测试必须能够如实的重现 bug 的现象,修复之后测试必须能够通过。这看似一个很在乎代码质量的做法,然而它不但没能保证产品的稳定可靠,而且大幅度的减慢了工程进度,并且造成员工的疲惫和不满。 有一次他们分配给我一个 bug:在分析一个中型项目的时候,分析器似乎进入了死循环,好几个小时都不能完成。因为 Coverity 的全局静态分析,其实就是某种图遍历算法。当这个图里面有回路的时候,你就必须小心,如果不问青红皂白就递归进去,就可能进入死循环。避免死循环的办法很简单,你构造一个图节点的集合(Set),然后把它传递到函数里面作为参数。 每当访问一个节点,你先检查这个节点是否已经在这个集合里,如果在你就直接返回,否则你就把这个节点加入到集合里,然后递归处理这个节点的子节点。它的 C++ 代码大概就像这个样子:
查看代码之后我发现,代码其实没有进入“死循环”,而是进入了指数复杂度的计算,所以很久都不能完成。这是因为写这函数的人不小心,或者没有理解 C++ 的函数参数缺省是传值(做拷贝)而不是传引用,所以他忘了打那个“&”,所以函数被递归调用的时候不是传递原来的集合,而是做了一个拷贝。每一次递归调用 本来很明显的一个图论算法问题,加一个“&”就修好了,手工试验也发现问题消失了。然而 Coverity 的测试教条主义者们(包括写出这 bug 的那人自己),吵着闹着,严肃命令我必须写出测试,构造出可以造成这种后果的数据结构,确保这个 bug 不会再重新出现。 为一个我根本不会犯的错误写测试,而且它不可能再次发生,这不是很搞笑吗?就算你写了测试,也不能保证同样的事情不再发生。如果你不小心漏掉“&”,下次同样的问题还会发生,并且发生在另外的地方,而你却没有给那块代码写测试,所以给这个 bug 写测试,并不能防止同样的问题再次发生。这就像一个技术不过关的赛车手,他在别人不大可能撞车的地方撞了车,然后就要求赛场在那个地方装上轮胎护栏。可是下一次,这个车手又会在另一个其他人都不会撞车地方撞车…… 稍微有点图论常识,熟悉 C++ 基本概念的人,都不会犯这种错误。防止这种问题,只有靠个人的技术和经验,而不能靠测试。防止它再次发生的最好办法,恐怕是开个会把这个问题讲清楚,让大家理解,下次不要再犯。所以给这个 bug 写测试,完全是多此一举。跟队友们讲解了这个原理,他们听了之后,仿佛什么都没有听到一样,仍然强硬的要求:“可是你还是得写这个测试,因为这是我们的规定!你知道要是出了 bug,送一个销售工程师去客户那里,要花多少钱吗……” 无语了。 Coverity 的 Java 分析,就是经常因为这种测试教条主义,使得项目进展及其痛苦和缓慢,却仍然 bug 百出。Coverity 的其他的问题,还包括我上面指出的,写重复的测试,一个测试测太多东西,使用字符串比较来做测试,等等。你恐怕很难想象,一个制造旨在提高代码质量的产品的公司,自己代码的质量是这样维护的 :P 完由于绝大多数人对测试的误解如此之深,测试教条主义的流毒如此之广,导致许许多多优秀的程序员沉沦在繁琐的测试驱动开发中,无法舒展自己的长处。为了大家有一个轻松,顺利又可靠的工作环境,我希望大家多多转发这篇文章,改变这个行业的陋习。我希望大家在工程中理性的对待测试,而不是盲目的写测试,只有这样才能更好更快的完成项目。 (由于这篇文章包含了我很多年的经验和深入的见解,希望你觉得有收获的话为此付费。建议价格是5美元,或者30人民币。【付费方式】) |