測試的道理

663 阅读16分钟
原文链接: www.yinwang.org

在长期的程序语言研究和实际工作中,我摸索出了一些关于测试的道理。然而在我工作过的各个公司,我发现绝大多数人都不明白这些道理。很多人把测试当成一种主义和教条,进行过度的测试,不必要的测试,不可靠的测试。本来目的是提高代码质量,结果不但没能达到目的,反而降低了代码质量,增加了工作量,大幅度延缓工程进度。

我也写测试,但我的测试方式比“测试教条主义者”们的方式聪明很多。我不会本末倒置,过分重视测试,我并不推崇测试驱动开发(TDD)。我知道该测试什么,不该测试什么。什么时候该写测试,什么时候不该写,什么时候应该推迟测试,什么时候完全不需要测试。因为这个原因,我多次完成别人认为在短时间不可能完成的任务,并且制造出质量非常高的代码。

现在我就把这些自己领悟到的关于测试的道理总结一下。

  1. 不要以为你处处显示出“重视代码质量”的态度,就能提高代码质量。总有些人,以为自己知道“单元测试”,“集成测试”这样的名词,就很懂编程,就可以教育其他人。可惜,光有态度和口号是不解决问题的,你还必须有智慧,必须切实地知道应该怎么做。代码的质量不会因为你重视它就得到提升,也不会因为你因此采取了措施(比如测试,静态分析)就一定会得到提高。你必须知道什么时候该写测试,什么时候不该写测试,需要写测试的时候,要写什么样的测试。其实,提高代码质量唯一可行的手段不是写测试,而是反复的提炼自己的思维,写简单清晰的代码。如果你想真的提高代码质量,我的文章『编程的智慧』是一个不错的出发点。

  2. 真正的编程高手不会被测试捆住手脚。是的,你身边那个你认为“似乎不在乎测试”的家伙,也许是一个比你更好的程序员。我喜欢把编程比喻成开赛车,而测试就是放在路边用来防撞的轮胎。一个好的赛车手,会选择最优雅而简单的路径,速度力道恰到好处。路边摆放的少许轮胎,只是用来防止他疏忽而犯下低级错误。在通常情况下,他都根本碰不到这些轮胎的。相比之下,一个很差的赛车手,他经常撞到赛道外面去,所以他开车的时候为了防止犯错,要在他经过的路径两边密密麻麻摆上轮胎,甚至把轮胎摆到了赛道中间,以确保自己的转弯幅度正确。他在这轮胎之间跌跌撞撞,最后只能算是勉强到达终点。鼓吹测试驱动开发的人,就是这种三流赛车手,这种人写再多的测试也不可能写出可靠的代码。

  3. 在程序和算法定型之前,不要写测试。TDD 的教条者喜欢跟你说,在写程序之前就应该写好测试。为什么写代码之前要写测试呢?这只是一种教条。这些人其实没有用自己的脑子思考过这个问题,而只是觉得这样“很酷”,符合潮流,或者以为这样做了自己就是高手。实际上在程序框架完成,算法定型之前,你都不需要写测试。过早的写测试会捆住你的手脚,让你无法自由的修改代码和算法。如果你不能很快的修改代码,不能用直觉感觉到它的变化和结构,而是因为测试而处处受挫,你的头脑里就不能产生所谓“flow”,就不能写出优雅的代码来。只有在程序不再需要大幅度的改动之后,才是逐渐加入测试的时候。

  4. 不要为了写测试而改变本来清晰的编程方式。很多人为了满足“覆盖”(coverage)的要求,为了可以测试到某些模块,或者为了使用 mock,而把代码改成更加复杂而混淆的形式,甚至采用大量 reflection。这样一来其实降低了代码的质量。本来很简单的代码,一眼看去就是正确的,可是现在你一眼看过去,到处都是为了方便测试而加进去的各种转接插头。这些辅助测试的代码,阻碍了你对代码进行直觉思维,而如果你不能把代码的逻辑完全映射在头脑里(进而产生直觉),你是很难写出真正可靠的代码的。

    有些 C# 程序员,为了测试而加入大量的 interface 和 reflection,因为这样可以在测试的时候很方便的把一片代码替换成 mock。结果你就发现里面的每个类都需要有一个配套的 interface,还需要写另外一个 mock 类,去实现这个 interface。这样一来,不但代码变复杂难以理解,而且还损失了 Visual Studio 的功能,你不能再很直接的跳转到方法的定义,而是需要先跳到 interface 方法,然后再找到正确的实现。这种方便性的损失,会大幅度降低头脑产生 flow 的机会。reflection 的大量使用,也会让编译器的静态类型检查失效,导致运行时出错,得不偿失的后果。

  5. 避免过于详细的测试。测试应该只描述程序需要符合的“高级特征”(比如 sqrt(4) 应该等于 2),而不是去描述具体的作法(比如具体的开平方算法的步骤)。有些人的测试过于详细,甚至把代码的每个实现步骤都兢兢业业的进行测试:第一步必须做A,第二步必须做B,第三步必须做C…… 还有些人喜欢测试 UI,他们的测试里经常这样写:如果你浏览到这个页面,那么你应该在标题栏看见这行字……

    仔细想一下,这种作法其实只是把代码写了两遍。本来代码里面明明白白就写着:先做A,再做B,再做C。UI 框架里面本来明明白白写着:标题栏是这些内容。你有什么必要再在测试里把它们全都又描述一遍呢?这根本没有增加代码的信息含量。这种做法非但不能保证代码的正确,反而给修改代码制造了障碍。当然了,你把同一段代码谢了两遍,每当你要修改代码,你就得修改两次。这样的测试就像紧箍咒一样,把代码压得密不透风。代码的每一次修改,都会导致很多测试失败,以至于这些测试都不得不重写,本质上就是把代码修改了两遍。

  6. 并不是每修复一个 bug 都需要写测试。很多公司里都有一个常见的教条,就是认为每修复一个 bug,都需要写一个或者好几个测试,用于保证这个 bug 不会再发生。甚至有人这样教你如何修复一个 bug:你先写一个测试,重现这个 bug,然后修复它,确保测试通过。这种思维其实是一种生搬硬套的教条主义,它会严重的减慢工程的进度,而代码的质量却不会得到提高。写测试之前,你应该仔细的思考一个问题:这个 bug 有多大可能性再次发生?很多错误一旦被看出来之后,它就不再可能发生。在这种情况下,你只需手工验证一下 bug 消失了就可以。为这样的 bug 大费周折,写出 reproducer,构造各种数据结构,保证它下次不会再出现,其实是多此一举。这不但浪费你很多时间去写测试,而且这测试在每次 build 的时候都会消耗时间。如果你真的觉得需要为一个 bug 写测试,那么这个测试的内容不应该是防止 bug 再次发生,而是确保 bug 所反映出来的程序的“特征”得到保证。

  7. 避免使用 mock,特别是多层的 mock。很多人写测试都喜欢用很多 mock,堆积很多层,以为只有这样才能测试到路径比较深的模块。其实这样不但非常繁琐费事,而且多层的 mock 往往不能为需要测试的模块产生足够大范围的输入,不能照顾到各种边界情况。如果你发现测试需要进行多层的 mock,那你应该考虑一下,也许你需要的不是 mock,而是改写代码,让它更加模块化。如果你的代码足够模块化,你不应该需要多层的 mock 来测试它。你只需要为每一个模块准备一些输入(包括边界情况),确保它们的输出符合要求。最后你把这些模块连接起来,形成一个更大的模块,然后测试它也符合输入输出要求,就可以了。

  8. 不要过分重视“测试自动化”。写测试,这个词往往隐含了“自动运行”的含义,也就是假设了我们总是需要不经人工操作,完全自动的测试,打一个命令,它过一会就会告诉你那些地方有问题。然而,人们往往忽略了“人工测试”,没有意识到,人工去试验,去观察,也是一种测试。所以你就发现这样一种情况,由于自动测试在很多时候非常难以构造(比如,如果你要测试一个网络协议代码或者一段GUI代码的响应),很多人花了很多时间,利用各种测试框架和工具,结果却无法测到很多东西。而其实他们只需要花不到十分之一的时间,就可以用人工的方式观察到很多方面的问题。过分的重视测试自动化,不但延缓了工程进度,让程序员恼火效率低下,而且其实损失了本来可以用人工方式保证的代码质量。

  9. 避免写太长,太耗时的测试。很多人写测试写很长一串,到后来再看的时候,他们已经不记得自己当时想测什么了。有些人本来用很小的输入就可以测试到需要的特征,他却总喜欢给一个很大的输入,下意识的以为这样更加靠谱,结果这测试每次都会消耗大量的 build 时间,而其实达到的效果跟很小的输入没有任何区别。

  10. 一个测试只测试一个方面,避免重复测试。有些人一个测试测很多内容,结果每次那个测试失败,都搞不清楚到底是哪个部件出了问题。有些人喜欢在多个测试里面“附带”测某些他认为相关的部件,结果每次那个部件出问题,就发现好多个测试失败。如果一个测试只测试一个方面,不重复测试同一个部件,那么你就可以很快的根据失败的测试,发现出问题的部件和位置。

案例分析

我这些经验有什么成功或者失败的案例呢?现在来讲讲我做过的几个东西。

很多人可能听说过我在 Google 做的 PySonar。当时 Google 的队友们战战兢兢,说这么复杂的东西要从头做起,几乎是不可能的,而且某位女队友一开头就吵着要我写测试,一直吵到最后,烦死我了。他们为什么这么担心呢?因为对 Python 做类型推导是非常高难度的代码,需要非常复杂的数据结构和算法,需要精通 Python 的语义实现。我没有在乎他们的咋呼,没有信他们的教条,按照自己的方式组织代码,进行精密的思考和推理,最终在三个月之内做出了非常优雅,正确,高性能,而又容易维护的代码。PySonar 到现在仍然是世界上最先进的 Python 类型推导和索引系统,被多家公司采用。如果我当时按照 Google 队友的要求,采用已有的代码,或者过早的写了测试,我恐怕无法在三个月的实习时间之内完成。

这种思维方式最近的成功实例,是给 Shape Security 做的一个高级的 JavaScript 混淆器(obfuscator)和对集群(cluster)管理系统的改进。不要小看了这个 JS 混淆器,它的强度跟 uglify 之类的开源工具比,是天上地下的。它不但包含了 uglify 的换名等功能,而且含有专门针对人类和优化器的复杂化,使得没有人能看出一点线索这个程序到底要干什么,让最先进的 JS 编译器也无法把加进去的混淆代码优化掉。

其实混淆器也是一种编译器,只不过它把 JavaScript 翻译成更加难读的形式。在这个项目中,我采用了从 Chez Scheme 编译器学过来的测试方法。对每一个编译器的步骤(pass),我都给它设计一些正好可以测到这个步骤的输入代码(比如,含有函数定义的,for循环的,等等)。Pass 输出的代码,经过 JavaScript 解释器执行,然后把结果跟原来程序的执行结果对比。每一个测试程序,经过每一个 pass,输出的中间结果都跟标准结果进行对比,如果错了就表明那个 pass 有问题。遵循小巧,不冗余,不重复的原则,我总共只写了40多个非常小的 JavaScript 程序。由于这些测试涵盖了 JavaScript 的所有构造而且几乎不重复,它们能够准确的定位到错误的改动。最后,这个 JS 混淆器能够正确的转换像 AngularJS 那么大的项目,确保语义的正确,让人完全无法读懂,而且能有效地防止被优化器(比如 Closure Compiler)简化掉。

相比之下,过度鼓吹测试和可靠性的人,并没能制造出这么高质量的混淆器。其实在我进入团队之前,团队里的两三位高手已经做了一个混淆器,项目延续了好多个月。这片代码一直没法用,因为它的换名部件总是会在某些情况下输出错误的代码。不是100%的正确,这对于程序语言的转换器来说,是不可接受的。换名只是我的混淆器里的一个步骤,我的混淆器包含了大概10个类似的步骤。在构造换名步骤的时候,队友们让我直接拿他们以前写的换名代码过来,把 bug 修好就可以。然而看了代码之后,我发现这代码没法修,因为它采用了错误的思路,缝缝补补也不可能达到100%的正确,所以我决定自己重写一个。由于轻车熟路,我只花了一下午的时间,不费吹灰之力,就完成了一个正确的换名器,它完全符合 JavaScript 各种奇葩的作用域规则,而且结构非常的简单。

队友们听说我自己重写了一个换名器,非常紧张,咋呼样地跟我说:“你知道我们的换名器是花了多少个月的时间,写了多少测试做出来的吗?你现在一下午做出来一个新的,如何能保证它的正确!” 然而事实是,他们花了这么多个月,耗费这么多人力(包括一个早年从 Cornell 编译器领域毕业的 PhD,曾在 Apple 做编译器工作的元老),写了这么多的测试,做出来的换名器却仍然有 bug。当我把我的测试和几个大型一点的 open source 项目(AngularJS, Backbone 等)放进他们的换名器之后,就发现有些地方出问题了。而所有的测试和 open source 项目通过我的换名器,却得到完全正确的代码。

Shape Security 的产品(ShapeShifter)里面包含一个高可靠(HA)集群管理系统,ShapeShifter 可以通过网络,选举 leader,构建一个高容错的并行处理集群。这个集群管理系统一直以来都是最复杂,却是可靠性要求最高的一个部件。确实,它当时可靠性非常高,从来没有出过问题。但当时它的代码过度复杂而缺乏模块化,以至于无法应付新的客户需求。我进入这个新团队的任务,就是对它进行大规模的优化和简化。在这个项目中,由于代码的改动幅度巨大,在领导和同事的理解和支持下,我们决定直接抛弃已有的测试,完全靠严格而及时的 code review,逻辑推理和讨论来保证代码的正确。在我修改代码的同时,一位更熟悉已有代码的队友一直监视着我的每一次改动,根据他自己的经验来判断我的改动是否偏离了原来的语义,及时与我交流和讨论。由于这种灵活而严格的方式,工程不到两个月就完成了。改进后的代码不但更加模块化,更可扩展,适应了新的需求,而且仍然是 ShapeShifter 里面最可靠的部件。如果遇到某些领导是“测试教条主义者”,不允许抛弃已有的测试,这样的项目是绝对不可能如期完成的。