摘要:代码质量的重要性不言而喻,直接影响了项目质量和团队开发效率,对于如何提高代码的质量,除了依赖开发人员本身的技术素质外,还有一些系统化的方法可循,比如严格统一的编码规范,code review,单元测试等。本篇文章从其中比较容易实践的方法--单元测试入手,分享一些自己对单元测试的认识和实践心得。
1、单元测试的作用
我们首先来看下写单元测试带来的好处,下面这几点虽然看起来有些假大空务虚,但真正好好写单元测试的团队应该会有所共鸣:
1)让我们对自己的代码有信心
我想很多人都有过这样的经历:自己写的代码要上线的时候就开始紧张,总是有一种铤而走险的感觉,甚至有些公司专门建立了微信群以方便大家上线时祈祷,这些都是对自己写的代码没信心的表现。虽然写了单元测试并不能百分之百保证代码完全正确运行,但从个人主观的感受来说,能够明显的增加对代码的信心。空口无凭,请继续往下看。
2)为代码重构保驾护航
看到代码很差劲,想重构,但又担心重构之后出问题,怎么办呢?
如果有单元测试情况就不一样了, 重构完代码,跑一边单元测试,如果单元测试都通过,基本上可以保证我们的重构没有破坏原来代码逻辑的正确性 。不过前提是之前的写的单元测试质量很好,覆盖率很高。
当然这仅限于小范围的重构,比如重构一个类或者函数的实现,但对于大刀阔斧的重构,可能不怎么适用。
3)写单元测试的过程本身就是一次小的代码重构的过程
好的东西都是迭代改出来的,比如好的产品,好的架构,代码也不例外,写的好的代码都是经历了作者不停地 review 和修改。写单元测试的过程本身就是一个自我 code review 的过程,在这个过程中,可以发现一些设计上的问题(比如代码设计的不可测试),代码编写方面的问题(比如一些边界条件的处理不当)等,做到及时发现及时修正,不需要等到测试阶段甚至上线之后再发现再修改。
4)通过单元测试快速熟悉代码
对于那些没文档没有注释的代码,而业务或产品文档又缺失的情况,单元测试不仅起到了测试的作用,还是一种很好的“文档”,通过单元测试,我们不需要深入的阅读代码,便能知道这段代码做什么工作,有哪些特殊情况需要考虑,包含哪些业务。
2、测试内容
写单元测试本身不是一件需要高深技术才能完成的事情,更多的是考验工程师思考的缜密程度,主要工作就是要设计出覆盖各种正常和异常场景的测试用例,来保证代码在任何预期或非预期的情况下都能正确运行。
单元测试是代码层面的测试,用于测试“自己”编写的代码的逻辑的正确性。单元测试顾名思义是测试一个”单元”, 这个”单元”一般是类或方法,而不是模块或者系统 。测试过程不依赖于任何不可控的组件,如果 代码中依赖了其他这些不可控的组件,比如复杂外部系统,复杂模块或类,则需要通过 mock 的方式,将这些不可控的部分变成可控 。
写单元测试也是一件考验耐心的活,一般情况下,单元测试的代码量往往大于要测试的代码量,一般情况会是 1-2 倍的样子,很多人往往会觉得写单元测试比较繁琐而且没有太多挑战而不愿意去写。
单元测试与集成测试的区别
集成测试:是一种 end to end 的系统测试,测试相关模块集成在一起是否能够按照预期工作,一般都是 接口或者功能层面的测试 ,单元测试只能反应局部问题,而集成测试能反应全局问题。
为什么会单独强调这两者的区别呢?因为这两者是最容易混淆的。
在以往的工作中,发现很多同事写的单元测试四不像,比如直接依赖开发数据库中的数据来做测试,有点像集成测试,又不是很合格的集成测试,这种测试往往过一段时间后就不能运行了,因为太依赖开发环境,所以我们在写单元测试的时候要避免写成集成测试。
下面介绍了两者的区别,大家写单元测试的时候可以作为参考:
补充一点,集成测试可能会依赖很多系统,测试的代码逻辑一般比较复杂,运行时间会比较长,出错之后的修复成本高,所以一般来讲建议单元测试和集成测试分开组织,可以放到一个项目的不同目录下,也可以将集成测试放到独立的项目里面。这样集成测试的出错或不可运行,不会影响到单元测试的继续执行。
3、怎么写出可测试的代码
代码的可测试性是代码写的好坏的一个标准,保证代码可测试性的一个方法就是: Dependency injection(依赖注入), 概念听起来很唬人,其实没啥东西,很多框架都做了这个事情,像 Spring IOC, Guice 等,核心思想是:依赖的对象不要在实现的过程中创建,而是在外部创建,通过 构造函数或者 set 方法或者函数参数传递进来 。
对于这个概念,大家如果有不了解的,看下面一篇文章就够了:
https://en.wikipedia.org/wiki/Dependency_injection
尽管现在有很多代码测试框架(比如 powermock, easymock 等)提供了很多高级的功能,几乎没有什么奇怪代码测试不了,但如果我们设计的类或方法需要使用单元测试框架很复杂的特性才能完成单元测试,那我们还是要当心代码的设计是否合理。
4、单元测试覆盖率高就够了吗
单元测试覆盖率是比较容易量化的指标,常常作为单元测试写的好坏的评判标准,有很多现成的工具专门用来做单元测试代码覆盖率统计,比如Jacoco,Cobertura, Emma, Clover等。对于覆盖率的计算方式,有以下几种:
· 函数覆盖(Function Coverage)
· 语句覆盖(Statement Coverage)
· 决策覆盖(Decision Coverage)
· 条件覆盖(Condition Coverage)
不管覆盖率的计算方式如何高级,将覆盖率作为衡量单元测试好坏的唯一标准是不合理的,除此之外,还要看测试用例是否覆盖了所有的情况,特别是一些corner case。
我们来举个简单的小例子:
public double cal(double a, double b) {
return a/b;
}
这个例子中,我们只需要一个测试用例就可以做到覆盖率100%,比如 cal(10.0, 2.0),但并不代表测试足够全面了,我们还需要考虑除数等于0的情况下是否运行符合预期。
过度的关注单元测试的覆盖率,会导致开发人员为了提高单元测试覆盖率,写很多没有必要的测试代码(比如有些 get/set 简单方法是没有要测试的),而在提高代码质量方面收效甚微。从过往的经验上来讲,单元测试覆盖率在60%~70%即可上线,如果要求较高的项目,可以适当提高单元测试覆盖率。
5、如何选择单元测试框架
写单元测试本身不需要太复杂的技术,大部分单元测试框架都能满足,在公司内部,起码团队内部需要统一单元测试框架。如果自己写的代码用已经选定的单元测试框架无法测试,那多半是代码写的不够好,不够可测试性,这个时候是要审视重构自己的代码,而不是去找另一个提供更高级功能的单元测试框架。
具体的单元测试框架的对比,选择及其如何使用,还有单元测试的一些实践规范,我们在另一篇文章中详细介绍。
6、有了测试团队,写单元测试是不是浪费时间
中国人把很多事情都搞成劳动密集型的,码农这一行业也不例外,有很多公司,包括一些大厂,开发过程中既没有单元测试也没有 code review 等流程,或者有些有但做的也差强人意,写好代码直接提交,然后丢给测试黑盒狠命的测,测出问题反馈给开发团队再修改,测不出的问题就留在线上出了问题再 fix。这样的开发模式下,团队往往觉得单元测试没有必要,浪费时间,但是如果我们开发团队把单元测试写好做好 code review,重视起代码质量,其实是可以很大程度上减少黑盒测试的投入。
作者曾经参与过的一个完全没有测试团队参与的项目,代码的正确性完全靠开发团队来保障,线上 Bug 反倒是非常少。除此之外,单元测试可以弥补一些复杂系统无法全面测试的不足。
对于一些特别复杂的系统,再强大的测试团队也是无法模拟所有的测试场景所有的测试用例的,比如我们在月球上重建像地球一样的“气候”,我们需要对这个“系统”做测试,我们几乎无法穷举,因为这个系统太复杂,涉及的子系统/模块太多了,组合爆炸,穷举的成本太高。
既然无法从系统的整体上保证100%符合我们的预期,我们可以先保证每个子系统的设计都符合我们的预期。类比到我们的系统,如果测试团队无法做到100%全面的测试,单元测试起码能保证我们代码在细粒度上运行符合预期。
有很多人认为写单元测试这件事情在团队中不好执行,刚开始写的时候比较认真,当开发任务紧了之后,就来不及写了,然后慢慢的大家就都不写了,这种情况很常见。但哪有那么多特别紧要的任务能让我们连写单元测试的时间都没有呢?这明显是战略上不作为却靠战术上的勤奋来弥补,正常的开发应该是有条不紊,未雨绸缪,而不是突然想到要做某个业务或 feature,就急吼吼的催着去开发,然后再花更多的时间来填坑。
还有一种情况就是由于历史遗留问题,原来代码都没有写单元测试,代码已经堆砌了十几万行了,不可能再去一个一个的补单元测试。这种情况我们首先要保证新写的代码都要有单元测试,其次每次在改动到某个类时,如果没有单元测试就顺便补上,不过这要求工程师们或者 leader 有足够的 ownership,不然大家会找各种借口不去做。
总结
以上是我对单元测试浅显的认识和实践心得,本身写单元测试这件事情还是没有那么难的,而且现在互联网信息如此的公开透明,有很多规范和最佳实践可以参考,关键还是看如何因地制宜的执行,不可生搬硬套。