架构之路(三) 单元测试
======================
实事求是的讲,写《【野生程序员】:优先招聘》的时候,是带着情绪的。其后也有反思,是不是我杞人忧天了?尤其是下面开始的几条评论,如“都是混口饭吃的不容易”,“何以内外之分,中华儿女非山倾河泄而不能一气前指,千年亦是如此”等,让我感觉可能是我过于敏感了。但随后一些人长篇大论,让我明白,这篇博客还是有意义的。
想一想,招聘启示里,你们要求“计算机专业本科以上学位”,我“无计算机专业相关专业文凭”优先;然后,你们就炸了!我们没有歧视,你这才是歧视!你自卑你愤青你酸你难成大器……我无力反驳,只是想说,每个人的言行都是他心灵的镜子。谢谢你们!
其实,我没有想挑起科班/非科班之争(虽然可能结果会超出我的预料),我的本意是想给“非科班”的同学鼓气,缓解他们身上的压力,让他们看到希望,给他们力量,让他们相信,完全可以在更艰苦的环境下自学成才,而且结果不会比“科班”的差!但你一定要委下身段踏踏实实的去学,一步一个脚印的去做,自卑自大争吵辩驳都无助于你的成长。请牢记:言语没有力量!
另外,愿意听一句的“科班”同学,“无计算机专业相关专业文凭”优先,并非完全出于义愤。都是筑基,你是名门大派用资源用丹药堆出来的,他是一路苦修战斗领悟突破的,你觉得谁更有潜力?所以啊,放下那些虚荣骄傲,真正的去战斗吧!毕业三年以后,是没人再看你的学历的。
另外声明一点,对老赵没有任何意见,除了景仰。他针对的是培训机构我完全明白,但仍然不能赞同。所以我说,“每一次看到这一段文字,我的心里就会有一种难以言表的复杂情绪”,至于如何复杂,不是说了吗?难以言表啊。
======================
好,心平气和之后我们继续讨论技术问题。在带队的过程中,性能的问题还比较好解决,最消极的想法,“好啊,多一事不如少一事,你让我不管还不简单?”,但要求写测试代码,那就炸锅了!以我的经历,“测试驱动”是一个最具争议的话题,没有之一。吹捧者和反对者泾渭分明,而且都有大量的论据和证明。记得博客园曾经有一篇文章,大意是:“公司付钱给你不是让你写测试代码的”,下面一片狂赞。
在我自己的项目开始的时候,我是放弃了测试驱动的(呵呵,还找到了原文),里面总结得很准确,最大的原因是“懒”。但最后让我下定决心开始“测试驱动”实践的,是我一次花了两天一夜都没调出一个Bug,垂头丧气筋疲力尽之后,无可奈何的接受了这个现实:测试还是很有用的——即使是自己写的代码。我之前的系列博客,也已经反复的强调,架构是一种“无奈”,是现实是问题驱使你去做一些其实你本来不想做的事情。你无法理解一些看起来像“脱了裤子放屁”一样的行为,通常只是因为你没有遭遇过那些现实那些问题。(看看,大学能教你这些东西么?)
即使你没有多少开发经验,你也应该能够想象,单元测试最大的问题,就是它需要花时间花精力去写,那么这个花费是否值得呢?这还是由你架构的目标决定的,或者你的需求决定的。如果系统是一次成型交付使用,此后几乎不会更改的,那么一次性的手工测试就够了;但如果你的系统是会被“千锤百炼”的不断折腾修改的,那么这个测试就是很有必要的。最简单的考虑:每一次更改,我都要手工测试一次;那还不会如我多花点时间,弄个“自动化”的东西出来。单元测试,其实就可以理解为一种自动化的测试工具。
但是“自动化”的理由还远远不够。因为你马上想到的,每一次需求变更代码调整,测试代码也得相应的改呀?没有测试代码,我就只需要改开发代码;现在有了单元测试,我还得再改测试代码。本来我只维护一套代码,现在我凭空增加了一套代码也需要维护,这不是增加了维护成本,不是和你“可维护性”的架构目标背道而驰了么?是一套代码好维护呢,还是两套代码更好维护?
这是一个非常好的问题,适用于很多情景(比如分层架构,你说分层解耦,实际上还不是一改就得从UI层改到数据库,每一层都得改?)。我能给出的回答大概有:
一、无论有无单元测试,开发代码进行修改之后,是不是都要进行测试?没有单元测试,并不代表你的代码就不需要测试了,只不过是你手工的去测试了一遍而已。切记:你的工作并不只是把代码写出来而已!
二、进行手工测试,和更改单元测试,两者的耗费比,会根据测试重用的次数而变化。一次手工测试可能需要5分钟跑完,更改单元测试代码可能需要20分钟,但如果这测试会跑100遍,单元测试完胜手工测试。
三、你说,哪里哟?什么功能会改100遍?我没说你的功能会改100遍,我说的是测试会跑100遍。有区别么?你可能还在犯迷糊,是吧?好吧,我们讲个故事。
有一个小伙子,他很不情愿写测试代码。老板拿他没辙啊,也没那么多精力和他磨牙,于是老板自己写单元测试。这小伙子的代码提交之前要review,老板总能一次次的找出它代码的问题。他改的是登录,老板告诉他积分系统被他改出了问题;他又去改积分,老板又告诉他消息通知系统被他改坏了;他又去改消息系统,老板告诉他登录还是有问题……于是他崩溃了,“这TM什么一个烂系统”?最终他终于回过神来了,为什么老板总能知道这里的改动会影响那里呢?老板的思维有这么严谨?老板躲在一旁偷笑,就不告诉你,“其实我就是跑了一遍单元测试而已”。
这个老板就是我。我故意的,就不一次性的告诉他所有的问题,就要这样一次次的折磨他,让他的痛苦能刻入骨子里去。最后,我还要问他:
- 你现在对你的代码是不是还那么自信?
- 如果没有我的review(我也是靠单元测试),你能不能发现这些问题?
- 如果我们的项目已经部署到生产环境,而且你的改动带来的破坏没有被发现就上线了,会带来什么样的后果?
这一次,他服气了。后来他用NUnit用得麻溜麻溜的。每一次改动,如果有意想不到的未通过test case,他都会很激动的给我张截图,顺便发发牢骚。我微笑不语,那种满屏绿灯通过的踏实,和意外爆出红灯之后的惊喜,没有经历过的人,是无法体会的。
所以其实当对象间的关系变得越来越错综复杂,像一张密密麻麻的网一样之后,一个局部的改动就很有可能会触发极其复杂的连锁反应。所以为了保险起见,所有可能相关的组件都应该进行测试(所谓的“回归测试”)。这时候如果只有纯粹的手工测试,会面临两个问题:
- 难以确定测试的边界(那些部分可能会被影响),这得我们脑袋凭空硬想啊,兄弟!
- 极大的测试耗费。而且这种耗费是相当的无聊繁琐伤人心的——没人愿意做这种事。据说所知,现在很多公司测试人员的工资已经比开发人员还高了。为什么?简单枯燥无聊,没人愿意做啊!
好的,我假设你已经认识到了单元测试的重要性,并开始摩拳擦掌,跃跃欲试。接下来我得给你泼一大瓢冷水:单元测试不是那么好写的!从某种程度上讲,写单元测试比写开发代码还难。难得我工作的所有公司,没有一家有过成功的案例。
大概是几年前,我在公司修bug,老大告诉我,“你这个功能比较核心,跑一下单元测试吧”。
“哇塞!我们有单元测试?”一种高大上的感觉迅速弥漫全身,终于见到传说中的Unit了!
捣鼓了一会,能跑了,试试看——我的个妈呀?怎么这么多红灯?我真被吓住了,这都是我的改动造成的?
老大就是老大,不慌不忙,“数一下有多少个通不过?”
“啊?”我以为我听错了,数多少个通不过有什么用?得把他们全部弄通过啊?!
搞了一会儿,才终于弄明白了,把我改动前后的代码分别跑一遍,对照一下通过失败是不是一样的,只要是一样的,就OK了。比如,以前是8个通不过,现在还是8个通不过,这样就可以了!
我一直不明白,为什么不把那8个通不过的单元测试给弄成通过呢?这样摆着究竟算什么?直到我自己开始写单元测试。坑爹啊!到处都是坑,跳出小坑进大坑,大坑下面还连着小坑,前面是坑后面是坑,一堆一堆的连环坑……
单元测试写出来容易跑过难!而且跑不过的原因还不是你的开发代码逻辑错了,而是测试环境/数据出问题。要测试,一定要有数据,这个数据的构建,完全不是我们所想象的那么简单。以我们创业家园项目里的积分系统为例,假设一个简单的需求:博客被点赞,博客的作者应该获得一定积分,该积分数量是由点赞人目前所有的可用币转换而得来的(已简化,具体可参考文档:积分)。要准备的数据就有:博客一篇,要有作者,作者已有积分;点赞人一名,有一定数量可用币。如果只是这样,还可以接受,但其实下面会有一堆的问题:
- 作者的积分从哪里来?我们的开发代码,出于封装的考虑,用户的积分是只读的,你单元测试怎么设这个值?
- 要么写代码,模拟作者通过其他行为(发布文章回答问题等)获得积分,这将开启新一轮噩梦;
- 如果用Mock或者反射强行设置,事实上省略了作者获得积分的历史,所以用户“积分历史”为null,之后对其“加积分”时,就会报异常。
- 更坑的是,你以为你什么都处理好了的时候,你突然悲哀的发现,这个博客得首先“被发布”,而博客一经发布,其作者就获得了一定数量的积分,所以你以前设置的积分又变了!
- ……
- 点赞人的可用币,同样可能遇到类似的问题。可用币怎么设置,设置之后会不会在跑测试时被意外更改?
- 点赞的行为,被封装成一个方法,运行这个方法,会检查点赞人之前是否已经对该文章点过赞,所以还应该有一个“点赞历史记录”,哪怕是空的,都得new一个,否则就空异常
- ……
反正当时是写得我直接摔了鼠标!写得憋屈啊!而且我还是完全隔绝了数据库的,真不知道那些要从数据库里取数据来跑单元测试的,是怎么做的?这时候我一下子就明白了,实际工作中加班赶进度,一个接一个的填坑,连重构的时间都没有,怎么可能还挤得出时间来写单元测试?就算开始雄心勃勃的写了,随着系统日益复杂,维护单元测试的成本也与日俱增,甚至复杂度更甚开发,所以放弃也就成了绝大多数项目的唯一选择。
在公司上班么,大多数人都是这样的,能推就推。我们开发写完了代码,基本上能跑了,就该交给测试人员了呀!天经地义的嘛,是不是?而且测试的时间是不会计算到我的项目开发时间里的,我总算是按时完成了开发任务。累坏了,休息一下,让测试的忙活去吧,哈哈……
但我是个光杆司令,我没测试人员啊!曾经有那么一两个时候,我真准备招一两个测试人员的。但好在我天生的节俭美德(也就是“抠”啦)让我冷静下来。我就想啊:测试只能告诉你出了bug,不能告诉你根源啊。没有单元测试,我单步调试,不也折腾了两天了么?这是系统本身的复杂性,或者代码组织的不合理造成的,不能归咎于单元测试。不还是有这么多开源代码都有详尽的单元测试么?他们是怎么做到的呢?在单元测试上的付出,最终一定会获得超值回报!想想没有单元测试的公司,那超级庞大的测试团队,或者四处冒烟的系统,你愿意走这么一条路么?
所以我不断的告诫自己,不要着急,冷静细致。终于一步步抽丝剥茧,把这一团乱麻一点点的归纳整理,最终还真被我找到了一条路子,一个个的单元测试都慢慢完成通过了,开发代码里潜在的一些问题也浮出水面,被我一个个的消灭。最后再跑一遍单元测试,一路绿灯,哈哈!更奇迹的是,困扰我两天的bug不知道什么时候消失了?
后来,我看到这样一种说法:可测试的代码不一定是好代码,但坏代码几乎是不可能被测试的。深以为然!深度耦合的代码,写他们的单元测试,难于上青天;但反过来,我们可以以可测试为标准,不断的完善重构开发代码,只要这样坚持下来,最终代码的质量怎么都不会差到哪里去。
所以,于我而言,单元测试是否有价值的争论可以休矣!不如换个角度,想一想,怎样才能把单元测试坚持下去。
最后,如果有心的同学就会注意到,我一直用的是“单元测试”,而不是“测试驱动”。因为测试驱动是一个更广阔的概念,是一个更崭新的天地!单元测试只是其中的一小部分,在下一篇博客,我会讲解我是如何试着将测试驱动的概念运用到项目开发管理中去的。这里,需要强调的一点:先写测试。
一上手就写开发代码,写完了才写单元测试。这是很多开发人员的习惯,我也经常犯这样的毛病,一不留神就忘了。这样做最大的问题就是,没有真正实现“测试驱动”。你实际上还是由开发在驱动,那么很自然的,测试照着开发的if...else...写一遍,有什么意义呢?这样做下去,就会不断的强化“测试无用累赘”的印象,因为测试就是简单的把开发代码重写一遍而已。我开的药方是:
- 单元测试代码和开发代码由不同的人员编写
- 如果做不到上面一点,先写单元测试
- 如果连上面一点也做不到,直到出了bug了再写单元测试
第三条可能有同学无法理解,不是说单元测试很重要么?为什么要等到出了bug才写?答案是:偷懒呗!记住,我们程序员是世界上最懒的人,没意义的事从来不做!你先写开发代码再写测试真的没意义,没意义就干脆不要做了。但你可以开启“乐观模式”(或者“Lazy模式”?),先乐观的认为,我的代码没问题,或许真的就没问题呢,是吧?如果真出了问题,做一个补救,这个时候就应该用单元测试把这个问题表现出来,因为他根据墨菲定律,它这里出了问题,以后就很有可能继续出问题。这个时候,就不要再偷懒了。
(未完待续)