原创 | 使用JUnit、AssertJ和Mockito编写单元测试和实践TDD (四)关于单元测试的常见错误观念和做法

22 小时前 阅读数: 8
原创 | 使用JUnit、AssertJ和Mockito编写单元测试和实践TDD (四)关于单元测试的常见错误观念和做法

上一章讲到“单元测试在整个测试体系中的位置”,这一章我们讲讲“关于单元测试的常见错误观念和做法”。

很多人对单元测试存在错误的观念和错误的做法。典型的错误观念和做法如下:

1. 错误观念

  • 测试是测试人员的工作。程序员只应该写产品代码

测试人员只在乎整个系统在功能和外部质量方面是否满足客户和用户的需求,他们既不了解、也不在乎你写的代码和你的程序结构,因此他们只能编写黑盒测试而无法编写白盒测试。写代码和定义内部结构是程序员的工作,通过单元测试证明你的代码和结构的正确性和可靠性同样是程序员的工作。

  • 编写单元测试会加重负担,拖慢进度

这是对单元测试最大的误解!跟我们的直觉恰恰相反,在软件的整个生命周期范围里面,编写单元测试会大大减轻负担,并且会让我们保持平稳的速度前进。没有单元测试,我们也许开始时速度会很快,但后面随着bug的不断爆发,很大一部分的时间会用于寻找、调试和修复bug,用于编写生产代码的时间越来越少,项目进展也会越来越慢。单元测试从一开始就大大减少了bug存在的可能性,出了bug也能够及时发现,准确定位,以最小的代价在最短的时间内修复bug。

首先,写单元测试所需要花的时间和精力微不足道。平均而言,写一个测试需要1分钟左右的时间。写10000个测试也只需要10000分钟,也就是7天时间,如果按8小时工作制计算,就是21个工作日。以10000个单元测试产生的收益相比,21个工作日真的不算什么。没有单元测试,在产品上线后出现bug之后通过调试等方法定位和修正bug所花的时间和成本要大的多。

其次,单元测试,特别是采用了TDD之后,会导致你的代码质量越来越好。可测试性迫使你编写简单的代码,从而使得代码更加容易理解、维护和修改。这也会提高产品质量和加快交付速度。

  • 代码太简单,不值得编写单元测试

首先,无论多么简单的代码都可能出错。例如在除法运算中被除数为零,在存取款时金额为负数,在一个集合中选取一个最优项目时集合为空……等等考虑不周的情况,都可能使你的代码潜藏bug,而在未来某天爆发出来。

其次,代码不是一次性写就的,在软件的整个生命周期中可能会经过次修改而不再简单,未来进行修改的人甚至可能不是你自己。没有单元测试保护,未来进行修改就可能引入新的bug而不被发觉。

最后,既然代码简单,写个测试就更简单了,成本可以忽略不计。如果这样,为什么还不写测试呢?

  • 代码太复杂,很难编写单元测试测试

这个误解是导致很多程序员不敢尝试写单元测试的主要原因。不采用测试驱动开发的人,会先写产品代码,再写单元测试。他就有可能写出一个成百上千行代码的方法,方法里面包含大量的分支和循环,导致很难写测试。解决这个问题的方法就是采用TDD,测试先行!就是先写好单元测试,再编写产品代码来通过测试。采用测试先行的方式,就不会有“测试很难编写”的问题。因为你只是先写个测试表达你的需求,然后写最简单的代码去通过这个测试。测试通过了,就是需求实现了。

  • 可以用集成测试和功能测试取代单元测试

很多人说,我们不需要单元测试,通过集成测试和功能测试就可以发现代码中的bug。这种看法是错误的。

首先,集成测试和功能测试不可能覆盖所有的情况。被测方法的执行结果是受对象内部状态、对象依赖项的状态、环境状态、方法参数影响的。我们需要编写多个测试方法来分别测试每种情况。如果实现某个业务功能需要m个类的协作,而每个类的被调用方法有n种可能情况,如果我们要编写功能测试来覆盖所有可能状态,就需要编写m×n个测试方法。而如果写单元测试,就只需要写m+n个测试方法,再加上一两个功能测试,覆盖主要执行路径,证明多个对象之间的协作正常即可。

其次,集成测试和功能测试执行很慢。由于往往需要访问数据库、文件系统,或通过网络调用第三方服务等等,集成测试和功能测试要花费的时间往往很长。这样就不能太频繁执行测试,从而失去了快速反馈的作用。

第三,在单元测试中,可以使用模拟对象(后面将要讲述的Mockito就是用来创建模拟对象的)模拟被测类的依赖项,因此可以在依赖项开发出来之前就开始测试。而集成和功能测试则必须等待所有依赖项都开发出来之后才能执行测试。模拟类还可以模拟再真实环境下不会经常出现的极端情况,预先针对这些情况进行测试,而集成和功能测试只能消极等待这些情况的出现才能开始测试。

  • 单元测试的受益者是客户和公司,我不“为他人做嫁衣裳”

单元测试可让多方受益。客户通过得到更好的产品受益,公司通过交付更好的产品受益。但最大的受益者是程序员自己。通过单元测试,我们可以自信地编码,稳定地前进。当未来由于修改、重构和扩展导致bug时,我们能够及时发现,准确定位,低成本修正bug,修正后通过回归测试还可以确认这次修正有没有不小心引入了新的bug。如果没有单元测试的保护,对于我们交付的代码,我们不知道有没有bug,bug的位置在哪里,修正bug之后会不会引入了新的bug。有单元测试的时候,我们的工作状态是自信、轻松、愉悦的,没有单元测试的时候,我们是忐忑、疲倦、怀疑自我的。这根本是两个世界。

2. 错误做法

  • 将单元测试写成了集成测试

这是很多初学者常见的错误做法。在测试中采用被测类的真实依赖项而不是测试替身,或者连接到数据库、本地文件系统、访问第三方服务等等就会将单元测试写成了集成测试。它不是测试单个类的单个方法,而是测试了相互协作的多个类,以及外部环境。

  • 单元测试只覆盖“快乐路径”

  • 被测方法的响应结果是下列因素的函数:

  1. 被测类的内部状态。例如被测的账户类有个内部状态:当前余额。它会影响取款方法调用的结果:当取款金额不大于当前余额时,取款成功,当前余额也会相应减少。当取款金额大于当前余额时,取款失败,并抛出BalanceInsufficientException异常。账户类的另一个内部状态:账户状态也影响取款方法的执行结果。当账户状态的值是Locked时,取款失败,并抛出AccountLockedException。

  2. 被测类的依赖项的状态。例如被测的订单服务类依赖定价服务类提供当前商品价格。当客户下订单时,订单服务类的被测placeOrder()方法调用定价服务类的getCurrentPrice()方法。查询购物车中每一个货品的当前单价。这个定价服务是个远程服务。当定价服务可用且存在对货品的定价时,订单创建成功;当定价服务由于网络原因不可用时,应当抛出PriceServiceUnavailableException;当定价服务不包含购物车中某些商品时,应当抛出PriceNotFoundException。

  3. 环境状态。例如我们的购物网站只在每日8:00到22:00才会接受订单。那么在7:35下订单时,被测方法应当抛出OrderServiceUnavailableException异常。

  4. 方法参数。例如取款方法的“取款金额”参数。当取款金额小于账户当前余额时,被测方法会正常执行;大于当前余额时,会抛出BalanceInsufficientException异常。

在编写单元测试时,要针对上述因素的各种可能性编写测试。不能只测试一切正常状况下的测试(被称为“快乐路径”,例如取款方法的例子,只测试账户状态正常,余额足够,取款金额是个正数的情况),而对各种异常情况视而不见。近代历史上许多严重事故(温州动车事故,美国航天飞机事故),都是由于对设备或控制软件异常状况测试不足导致的,导致了惨重后果。

后文会详细介绍我们需要对哪些方面进行测试。

  • 先写产品代码,再写单元测试

传统的做法是先写好产品代码,然后写单元测试去测试产品代码是否正确。但极限编程(XP)的发起人Kent Beck却主张测试先行,就是先写好单元测试,再编写代码去通过这个测试。在编写单元测试的时候,被测类和被测方法可能还不存在!这种颠覆性的做法能带来惊人的好处,通过单元测试表达需求,设置验收标准,在此过程中还能够驱动更好的设计!测试后行,单元测试只是个验证工具;测试先行,单元测试变成了设计工具

本教程第二部分会详细介绍测试先行和测试驱动开发(简称TDD)。

本章内容就讲到这里,下一章将通过实例讲讲“第一个单元测试”!

用户头像

编程道与术

关注

领域驱动设计(DDD)的早期践行者和布道者 2020.04.28 加入

杨宇Yangyu于2020年创立编程道与术,致力于研究领域分析与建模、测试驱动开发、架构设计、自动化构建和持续集成、敏捷开发方法论、微服务、云计算等顶尖技术领域。 了解更多公众号:编程道与术

评论

发布
暂无评论
原创 | 使用JUnit、AssertJ和Mockito编写单元测试和实践TDD (四)关于单元测试的常见错误观念和做法-InfoQ写作平台