聊聊坚持单元测试编写

接下来谈谈单元测试如何坚持下来的问题

相信大家或因为社区影响、或因为上级领导的要求、抑或纯粹的想挑战自身的编码水平,也尝试写过单元测试,或许都已经上了 Jenkins、TravisCI 等集成工具。

想必最初看到单元测试一路绿灯的时候,自己的内心一定是愉悦的。不错哟今天又向更高的软件质量迈进了坚实的一步!然而随着开发的持续,单元测试逐渐变得偶尔失败,再到持续的失败,等大家闲下来想补下单元测试的时候,发现之前写的测试已经祖国江山一片红,完全不能使用了。想到又要补充大量的测试,以及去读那些之前晦涩的测试代码在再三的衡量工作量之后,觉得代价太大无法承担,于是你不得不承认,写单元测试的又一次努力最终以失败告终。

这种事情是不是很熟悉?甚至在我司也经历了数次反复,摸爬滚打之后最终才调整过来。那么这些反复的出现到底是哪里出了问题?

我总结了以下几条

  1. 做单元测试没有明确的目标感
  2. 我知道单元测试失败了,但是测试环境过了,业务能过
  3. 不知道写的测试失败了

做单元测试没有明确的目标感

如果我们做一件事情没有明确的目标感,或者对做这个事的意义只有模糊的感受,那这个事情确实会变得十分难坚持下去。

当然会有朋友指出,我写单元测试当然有目标啊,是为了 提高软件质量 啊,这么重要的目标怎么能说没有呢。 但是,这真的是个目标么? 想想上学时代的晨跑、或者在背单词的时候,不都也有 “我这是为了健康”、或者 “我这是为了提高英语”,为什么没有最后坚持下来?

因为这种目标过于远大,以至于你感觉不到它的变化。除非有一个强大的内心,否则坚持这件事情真的会变得很难。

那么,就让我们把目标变得具体起来。

以前看《把时间当做朋友》的时候,说了一个关于记单词的有趣故事

因为,一共要搞定 20,000 个单词,而因此可能获得的奖学金是每年 40,000 美元左右——并且连续四年没有失业可能(后来的事实是,他直到五年之后才获得了博士学位)。当时的美元兑换人民币的汇率差不多是 8:1,所以,大约应该相当于 320,000 元人民币。而如果一年的税后收入是 320,000 元人民币的话,那么税前就要赚取差不多 400,000 元人民币。那么,每个单词应该大约值 20 元人民币——这还只不过是这算了一年的收入而已。

所以,他终于明白背单词是非常快乐的。他每天都强迫自己背下 200 个单词。而到了晚上验收效果的时候,每在确定记住了的单词前面画上一个勾的时候,他就要想象一下刚刚数过一张 20 元人民币的钞票。每天睡觉的时候总感觉心满意足,因为今天又赚了 4000 块!

如果你是 Team leader ,那么问题就变得十分简单了:给出一条线即可:提交的代码测试覆盖率达到 70% 以上,且能跑过的代码才是好代码。最开始可以就是 models 层的单元测试。整个团队会因为霍桑效应从而做出相应的转变。制定这个的规定有一个技巧:不要人工的去进行评判,可以在 git server 的 hook 加一个脚本来进行认证。因有了人工的判断,就会有特例,一旦开了特例,马上就会有下一个特例,具体参见破窗理论。

当然做出这一点的前提有两个:首先,你自己得信单元测试,其次,你得是领导,顶得住压力

如果你不是领导,那我们拿什么来说服自己写测试呢? 当年我最初写单元测试的目的非常简单:避免我犯下的低级错误进入到代码仓库,被别人看到了我丢不起这人。因为自己用 IDE 调试的时间与单元测试的时间实际上差不多,更重要的是,我可以向同事吹嘘:我信仰自动化,我测试覆盖率能达到XX

我知道单元测试失败了,但是我测过了,业务能过

随着开发与测试的持续进行,几乎所有人都发现了一个很恼火的事情 “原来写的单元测试因为代码的变化导致运行失败了,但是我测过了,业务上没问题”

首先有一个很重要的原则:

一旦单元测试挂了,团队应该首先解决这个问题

注意,这里是 “单元测试”,而不是"UI 级别的功能测试",后者以后有机会再聊。

首先要说,单元测试为什么会挂,我总结的原因如下:

  1. 由于语法错误导致的挂
  2. 配置原因导致的挂
  3. 因为代码结构发生改变,从而导致的之前写的单元测试失败
  4. 部分测试有一定概率的失败,即常说的测试出现了 “假摔”

语法一旦出错,立即去修,没有什么好说的,因为语法出错必然导致了部分或全部业务跑不起来。
配置原因多半是因为测试依赖的第三方组件并没有启动起来,这也是大多数人不推荐数据库的原因,我对此点的态度中立,我觉得可以依赖部分外部环境的,只要速度足够快,没什么不好的,毕竟在真正部署上线的时候,你也真是依赖它,如果在平时测试的时候就能熟练处理这些问题,到真正上线的时候也不会忙的手忙脚乱的。

因为代码结构发生改变,从而导致了测试的失败,这种情况通常有两种情况,第一种是我们喜闻乐见的:我们发现了过去的一个旧方法因为其他代码的改变而发生了改变,导致了最后的处理不符合我们原先设计的这个函数的目的。这正是将测试自动化的好处,我们没有足够的时间精力去测我们没有修改过的函数的正确性,但是机器可以。 这种情况没有什么好说的修 bug 就好了。第二种情况是:我非常确定对代码的修改会影响到其他旧有函数的功能,且这个变化是我期待的正确的业务处理变更,换句话说:“测试测的是老旧的业务方式,已经不是最新的期待的函数的功能了”。这种情况对应的就是修改单元测试本身。然而这里就有个问题了,我发现随便的修改都会导致单元测试报错,修改单元测试的成本变得非常的高,但是需求却是满足的。

这就涉及到了单元测试在编写的时候的一些问题了,这是我在写单元测试编写的时候的实践:

  1. 不要重复检测已经检测过的逻辑。 尽量避免在 A 函数的单元测试中去测试 B 函数的功能。这个含义非常重要,在初期我们写单元测试的时候通常会很随意,往往觉得这个地方有点犹豫,我就需要 assert 一下,但这么做的缺点就变成了本来 A 函数的 Test 由 A 来保证。举个例子:
it "could build adult correctly" do
   person = Person.init_adult
   assert_equal true, person.age >= 18
end
it "adult could buy beer" do 
   person = Person.init_adult
   assert_equal true, person.age >= 18 # 又一次检测了init_adult的正确与否
   assert_equal true, person.could_buy_beer
end

上面的代码当 init_adult 发生改变,age 由 18 提升到了 21 的时候,两个测试会同时挂,但是这里的 buy_beer 函数本身是没有发生任何逻辑上的错误的,假设更多的测试用例都增加了person = Person.init_adult; assert person.age > 18 的话,有可能更多的测试都会失败,而你从众多的测试失败中想找到真正的测试失败的adult_init 的难度也会变得更大。产品又催的急,很可能你就不会修复这个测试,只手动测试一下产品的逻辑,没问题,就上线了。下次你再跑测试的时候,又发现挂一片,感觉修改无力,遂放弃,轰轰烈烈的单元测试理想就此失败了。

  1. 谨慎对待私有函数的测 对私有函数的测试我保持一个谨慎的态度。其他语言测试私有方法会比较麻烦,但是这个麻烦在 ruby 这门语言上是不存在了:仅仅需要调用call 就可以轻松的使用私有方法。但是我们是否要真正的测试私有方法呢?

私有方法一定会被公开的方法调用,否则私有方法就没有存在的意义。如果私有方法出现了问题,一定会反应到最终调用的共有方法身上。那么如果我对私有方法本身做一些优化或者修改,只要不影响到公开的方法的行为,为什么要让测试失败呢?同时我也认为私有方法是不稳定的,因为没有对外公开暴露,所以这个方法随时随地都有可能被人修改,甚至因为其他的优化而被删除,这样就会导致之前对私有方法的测试莫名的挂掉。

当然有些人觉得只要一个方法稍微复杂一点,就有必要对其进行测试,这个概念我也比较赞同,所以我中庸的赞同这样一个方式处理

当我不确定我新编写的私有方法的正确性的时候,我对其写上单元测试。同时我也会在测试旁边备注到:如果将来你跑这个单元测试挂了的时候,请删除掉这个测试。这样就避免了别人在测试的时候看到这个莫名的私有方法测试失败后又不知道如何处理的尴尬。

关于假摔,我总结了如下的可能

我的想法是,在单元测试的时候,就不要有 “依赖网络” 这个情况。如果真的有依赖网络,请打 stub,或者请从代码编写的时候遵循依赖注入 的原则。

异步也是单元测试里的忌讳,我在上篇文章中已经介绍过了如何避免,这里就不再复述了。

关于单元测试加载顺序导致的假摔,一般存在于解释型语言里,比如 ruby。这主要是因为语言本身的性质决定的: ruby 可以在任意时刻改变 class,但是运行单元测试的时候是在运行前加载所有的 class,每个 case 跑完的时候并不做重新加载。如果你在某个 case 中修改了某个 class 的方法,其他的测试也会被影响。当你运气好的时候,这个 case 在其他受影响的 case 跑完后运行,测试 ok,但是运气不好的时候,后面的测试对于你来讲,就变成了 “莫名其妙的挂掉”。解决他的方式很简单粗暴,改了之后记得改回来就好了。合理的使用alias_method方法会很好的解决这个问题。解决这类问题的核心的原则就是让单元测试不依赖对 case 执行的顺序。

总而言之,不要让测试挂了后大家仍然无动于衷我们人对于少数能够快速处理的问题的时候,往往是愿意解决的,但是当问题变得巨大无比的时候,行动就变得没那么容易了。正所谓防微杜渐就是这个道理。

最后说一个情况,简单却又重要:“不知道写的测试失败了”

不知道写的测试失败了

为什么写的单元测试失败了我们不知道?因为我们没有运行这些单元测试。别笑,这就是事实。

跑测试,只需要简单的一个命令行就行了,但是我们真的随时随地都运行了这条命令么?并没有。原因主要是:

单元测试应该合理的控制时间,比较多的推荐是 5mins, 最长不超过 10 mins。我仍然对此时间表示巨大的怀疑,每次运行单元测试,盯着屏幕上的绿点,都有一种经历了一次奇点爆炸到宇宙湮灭的感觉。我能忍受的速度最多是 10s,于是只运行正在编写的方法的单元测试就变成了合理的策略。但是这样的话,其他的测试似乎又没有运行到?答案很简单,交给持续集成工具(CI)去做就好了。

市面上有大把的持续集成工具可以选择,比如著名的开源软件 jenkins,你只需要将其运行在你自己的服务器上,做出适当的配置即可;或者你觉得麻烦,甚至可以用基于 SaaS 的 CI 工具,比如 travis.CI 以及 flow.ci,简单的几分钟就能搭建一套持续集成系统,连设置与机器都省了。

我们将持续集成工具设置为当新的代码 push 进代码仓库的时候,就运行所有的单元测试,并最终将运行的结果邮件通知给我们。这样,我们就可以做到既能够足够快速的运行我们自己想运行的单元测试结果,又能够从稍后的邮件中得知整个单元测试的结果。

同时,当你上了持续集成工具后,就算你自己不运行测试,工具也会在你每次 push 的时候忠实的运行你之前写的单元测试,这样也解决了忘记跑单元测试的问题。

当持续集成工具告诉你测试失败的时候,这又回到了 “当测试出错的时候,我们应该怎么办” 的问题上了。再次啰嗦下:

一旦单元测试挂了,团队应该首先解决这个问题

好了,如何坚持单元测试的编写就说到了这里,下次有机会说说持续集成工具的一些实践。有兴趣的朋友也可以在微信中搜索公众号 “持续集成慢慢来” 进行关注或留言,我会很高兴与你交流。最后,感谢你的阅读 : D


↙↙↙阅读原文可查看相关链接,并与作者交流