【老万】我看编程(一):失败真的是成功的母亲吗?
(题图来自 forbes.com)
我又有一个悲伤的故事。
在做一个Java项目的时候,有一回我需要改动一个隔壁组写的函数。在这之前我看了他们的代码,百思不得其解:这看起来有很严重的逻辑错误,不可能正确工作啊!这样的代码怎么还被提交了,难道他们从来没有发现这个错误?测试干啥去了?打开测试代码一看,我靠这个根本就不像能编译通过的样子啊?难道我学的是假Java?或者这是我从来没有见过的最新最炫的高级功能?
好奇之下我深挖原因,最后发现即便是把这个测试代码的内容换成一句“卧室达春绿”编译器也不会报错。
原来,它压根就从来没有被编译过!
是这样的:在构造脚本(build script)里面,这位仁兄图省事用通配符来指定测试代码的文件名,然而他不小心写错了一个字符,造成的结果是没有一个文件匹配上了。所以他的这些个测试案例根本就没有被编译。他的测试程序啥也不干就直接返回成功,整个就是一个 true 。一旦我把他们的构造脚本改好了,编译器马上报告测试程序有语法错误。
这个测试程序已经提交进代码库大半年了,这大半年来还改过好几次。作者想必对自己也是佩服得不行:每次改完测试都是一次通过,从来没有任何编译错误或者运行时错误,我是不是全宇宙独一无二的没错代码编写专家呀?
确实,他是宇宙最二的程序员没错。
后来呢?后来因为等不及,我撸起袖子花了几个小时把这个测试和原来的功能修好了,再开始写自己要的功能。而这些额外的时间,本来应该是测试的原作者来付出的。
测试的目的是在代码出现错误的时候能够抓住错误报警,所以必须确保一个测试程序有失败的能力。不能失败的测试,没有任何作用。
文件名模式写错造成整个源文件被漏过只是一个个例,我还见过其它很多测试程序意外通过的情况。比如有人在测试程序当中调用了一个函数,而这个函数在某种情况下会直接调用exit(0),导致整个测试程序提前结束并返回胜利。这样很多测试案例根本没有机会执行就提前返回了。而且因为返回值是零,在运行测试的程序来看,测试成功了。这是因为 Unix 的惯例是通过程序的最终返回代码来决定其是成功还是失败的。
还有的测试本身就有逻辑错误,根本达不到想要验证的效果。比如:本来想要验证一个 bug fix,但是同样的测试在 bug 没有 fix 的时候也能通过。那么如果 bug 又回来了,测试还是通过。你又怎么知道你辛辛苦苦码的 fix 还有效呢?
之所以出现这些情况,根本上是因为这些测试都没有失败的能力,也就是说不管被测试的代码干啥,这些测试都会很高兴的说你好厉害呀,你正确得很,你真是伟大。这就如同袁世凯看他儿子袁克定伪造的《顺天时报》,上面登的都是哄老袁开心的消息。结果,袁大头认定他称帝是天下民心所向,到死都不知道是怎么死的。这袁克定可谓近代史上坑爹之第一人,水平之高,远胜李将军之子海淀银枪小霸王。程序员写的测试,如果不能失败的话,那也是够坑爹的。这样的程序员如何能够成功?
是啊,如何能够保证成功呢?这就要涉及到一个悖论了:若要成功,必先失败。用测试驱动开发(Test-Driven Development,简称TDD)的方式写代码,要求:
在写功能之前先写一个测试去验证这个还没有实现的功能。因为功能还不存在,这个测试只能失败,不可能成功。
然后再去写功能,让这个失败的测试通过;注意步子不要太大,写到测试可以通过就行了,千万别再接着写没有测试的功能。
重构(refactor)代码,去除冗余。
以上三点,循环往复,直到所有的功能都实现。
这样做,可以保证两件事情:第一,如果功能没有正确实现,会有测试报错;第二,每个测试都覆盖了一些新的东西,这样可以防止添加无用的重复性测试,让自己和机器做无用功。
说老实话,我对测试驱动开发一开始是持怀疑态度的,觉得这不是自己找虱子往头上爬吗?功能都没写好,先花时间写测试是不是轻重不分?还有每次都要先写一个测试才能再写一段功能,会不会影响开发的连贯性?另外,这种带着强烈目的性的测试,会不会对具体实现的针对性太强,以至于适用范围太窄,不能发现预料之外的错误?
这些顾虑有一定的道理。狭隘的人往往基于自己以前的经验,在这时候就会断定TDD是扯淡,是一种形式主义,然后有些人就会写文章嘲弄这种做法的愚蠢。其实各种软件开发方法论都有其产生的原因和适用的背景,虽然我们不可能都把每种方法都心领神会熟练掌握,但是如果一言不合就轻而易举地把它们否决,那就是视前人的经验不见,非得去把别人踩过的坑再去踩一遍,殊为不智。
我觉得学习一种编程方法最有效的途径是和这种方法的专家一起做配对编程(pair programming):两个人坐在一起,拿到一个问题,分析好了之后,你负责敲键盘,专家负责动嘴皮,然后大家一边商量一边写。一旦你处于惯性偏离了这种编程方法,专家就会马上叫停,把你拽回来。多几次之后,你就不太会再跑偏了。用这种方法,很容易学到新的编程方法的精髓,否则纸上读来终觉浅,绝知此事翻白眼。像我歌最早的两位院士,也是我歌现在仅有的两位资深院士的杰夫迪恩(Jeff Dean)和桑杰格马瓦(Sanjay Ghemawat),他们就是精通配对编程的好基友。在谷歌事业的早期,他两人合作开发了很多至今还在谷歌以及业界广泛使用的系统软件,包括 protocol buffer、BigTable 和 MapReduce 等等。据他们介绍,他们如此高效地造出了这么多重要系统,很大程度上是因为二人配合默契,亲密无间,取长补短,好比周星驰遇见吴孟达,又像郭德纲配上于谦哥,令人艳羡。
我到谷歌之后第一个项目是C++的测试框架。其实我做这个是半路出家的,以前对测试并没有特别深入的了解,只是公司需要担子落在了我的肩上,于是现学现卖恶补了一阵前人的做法和一些测试方法学,贩卖了一些常用的术语像单元测试、集成测试、mock、fake、stub,等等。虽然在小白面前好像是个专家,其实心里是没底的,因为自己并没有真正的长期经验。好在我那时年轻不怕吃苦,通过和测试小分队的同事们一起工作,暗中留意,偷师学到了不少经验。要知道他们中有不少人是具有多年测试开发经验的老司机,编程高手。这是我觉得在一个优秀的团队工作最好的福利:可以学习到很多优秀的思想和做事方式。后来我的测试经验逐渐丰富一些,当初那些幼稚的看法也逐渐发生了改变。
虽然我以前读过一些关于 TDD 的文章和书,真正入门是我在 C++ 编译器小组工作的时候。那时组里面有位德国同事 Manuel Klimek 是测试驱动开发的重度用户和热心倡导者。有一次我们俩参加一个 hackathon(项目突击),要写一个工具分析谷歌自己的全部 C++ 代码库,我们用的方法就是配对编程。Manuel 建议使用 TDD 开发,手把手地教会了我怎么做这件事情。真是不试不知道,世界真奇妙。单身汉要知道妹子的滋味,还真得去亲自交一个女朋友,否则读一百本把妹方法学也体会不到妹子的精髓。
真正做测试驱动开发的人是从编译错误开始的。比如你需要写一个 Widget 类,是先写 class Widget {} 吗?幼稚了。TDD 的做法,是直接写一个测试去使用这个类,比如创建一个 Widget 类型的变量:
Widget w;
因为这个类还不存在,编译自然是无法通过的咯?是的,然而这至少验证了你的测试代码会被编译到,对不对?这样就不会出现文首故事中测试程序被构造脚本漏掉这样的低级错误了。其次,现在的集成开发环境有很多神奇的针对编译错误的自动修复功能。比如编译器报告一个类型不存在,系统会进一步提问:你要不要建立这个类啊?你只需点一下 yes,相应的源文件和类的框架就会被建立好,你只需要往里面填内容就好。与此类似,如果你用到一个没有定义的函数,系统也可以提示你自动建立这个函数,等你往 { } 里面加函数体。是不是很省力啊?
TDD这种倒着开发的方式还有一个好处,那就是极大地减少了一个库设计得难测又难用的可能性。经常我们听见有人抱怨:不是我不想写测试,而是这个库太难测了,无处下爪啊!为什么会出现这种情况呢?很多时候是因为设计这个库的人没有真正从用户的角度去考虑,做东西只是自己实现起来怎么方便怎么来。架构错了,测试的时候就是百爪挠心。反之,如果先写测试,那么自然保证这个库的接口用起来方便。要知道一个库只有一份实现,而它的用户可以是好多个,所以优化的时候要用从用户的体验角度去优化。
最后有人要问我:你写代码都是测试先行吗?嗯,这个还真不一定。有时候在摸索阶段,系统的很多具体技术细节都没有把握,需要先做出一个原型来论证可行性。或者时间特别紧,一个项目不及时上线会有很严重的后果,那就只能先搭一个能够凑合用的东西再说。但这都是技术债务,出来混总是要还的。只要时间不是那么紧,我都会尽量先写测试,这样开发起来的时候心里特别踏实,因为我知道我所写的所有业务逻辑都有测试覆盖,不小心改坏了系统会报错。这样我就可以放手地干,大胆修改,最后效率反而高。因为赶进度造成测试不足的情况,一旦发生,我得空会把测试再补回去,这样才能保证后续工作的正确和高效。
通过一开始失败的测试,来保证最终程序的成功。这就是测试驱动开发的精髓。对程序员来说,失败,还真的可以是成功的母亲呢。
往期文章推荐:
长按 - 识别二维码关注“老万故事会”公众号: