近期对单元测试的一些实践

我在平时的工作中主要会用到Golang和Python这两门语言。在过去的一个月中,我对Golang和Python的单元测试进行了一些实践,在这篇文章中记录一下心得。一篇流水账。

1.

什么是单元测试呢?单元测试,简单来说,就是对软件基本单元的测试。

单元测试能做什么呢?下面是我的看法。

首先,单元测试能确保代码是一行行被验证过的。但是,验证的全面程度以及验证的有效性,就依赖测试用例的完整性和有效性了。通俗地说,单元测试是用来测试代码的表现”是否和你预期的一致”。

同时,单元测试的信念是这样的:既然组成软件的每个基本单元的质量都是没有问题的,那整个软件的质量也应当是没有问题的。虽然我们知道,系统的整体复杂程度是要大于组件的简单相加的,但是我们也能承认,软件BUG的很大一个组成成分是基本的代码逻辑问题,而不是组件之间的交互问题。

再有,单元测试的存在对代码的重构很有帮助。如果一段代码有单元测试的”保护”,那不论我们怎样对这段代码进行重构,都可以以单元测试的通过与否来判断重构的正确性。当然,这也依赖单元测试本身是否全面和正确。

并且,单元测试是软件开发自动化的关键一环。对于敏捷开发中的”持续集成”,只有当单元测试能快速地、自动地在代码提交时被执行验证,才能确保代码和系统的正确性总是保持在一个合理的程度上。比如说,有单元测试存在的情况下(同时单元测试也是有效的),就能很大程度上避免”把代码改坏了”的代码合并到主分支。虽然code review能一定程度上避免这种情况,但code review的局限性也是很显然的,人眼阅读代码并不能发现很多问题。

以上几点,就是我对单元测试的基本认知,基本都是和我工作中的实践相结合的。我从《代码整洁之道》、《代码整洁之道:程序员的职业素养》还有极客时间上的软件测试课程,学到了单元测试的基本概念和实践方式。

ps. 单元测试的具体表现形式,是很多个单独的测试用例。

2.

我最早接触单元测试,好像是大三的《软件质量测试》课程。就记得前几节课讲了白盒测试,黑盒测试,然后中间有几节课讲打桩啥的,然后剩下的时间主要就在划水了。很惭愧。我相信当时肯定讲单元测试了,不过我确实没啥印象。

其实单元测试这东西,根据我的观察,确实不是软件开发过程的”必需品”,因为我身边的同行,很多人都没写过单元测试,或者不认为单元测试有价值。但是,在我学习的过程中,看书也好,看专栏也好,有些人说单元测试非常重要,100%的代码都应当被单元测试覆盖(比如《代码整洁之道》的Bob大叔),也有些人说单元测试在核心的代码上有必要测试,总之,不管具体怎么说,所有前辈的共识都是单元测试是很有价值,对软件开发很有帮助的。所以我也就相信这点。实际上,通过在工作中实践单元测试,我的确感受到了单元测试的价值。

当然,我也并没有追求100%代码覆盖的单元测试,我过去的这一个月写的单元测试,大多也比较简陋,但即便如此,也颇有收获。我觉得我在技术上还是一个比较务实的人,如果客户的需求用一个Python脚本就能解决,我不会去考虑架构设计之类的事情。对于单元测试,我的看法是,如果我对单元测试投入了n时间,单元测试能为我节省>n的时间,这件事就是值得的。

显然,眼下这些简陋的单元测试,为我节省了>n的时间。

3.

软件系统里的函数分为两类:

  1. 纯函数:传入参数,获得返回值,函数的意义在于”获取返回值”。
  2. 副作用函数:可能没有传入参数,也可能没有返回值,函数的意义在于某种”副作用”,比如修改了数据库里的一个值。

对于纯函数来说,单元测试是非常好写的,单元测试的效果也是很好的。比如我有这么一个函数

1
2
// 移除ip数组中重复的项
func removeRepeatedIp(ip []string) []string {...}

我就可以构造几个测试用例,精心构造输入,并写好我预期的输出。比如,输入是

1
["192.168.1.1", "8.8.8.8", "8.8.8.8"]

期望的输出就是

1
["192.168.1.1", "8.8.8.8"]

在运行单元测试的时候,我会观察函数的表现是否和我预期的一致。如果在运行一个测试用例的时候,程序的真实输出和我预期程序将要输出的值一致,那就认为这个测试用例测试通过了。至于单元测试应当如何构造,请看下一节。

当然,我也可以主动构造”异常”的输入,比如

1
["192.168.1.1", 123]

这时我预期程序的输出就可能是

1
[]

如果程序返回了异常,那我可能会期望返回的异常的值是”非法输入!”。

那么副作用函数应该怎么设计测试用例呢?因为副作用函数经常涉及到和其他函数模块的交互,例如调用了一个别的函数、发起了一个HTTP请求、从数据库里读了一个值之类的,那么这时候,一个合理的处理方式是,对这些”外部资源”进行模拟,也就是打桩(或者叫mock,打桩和mock是有区别的,但区别不大)。比如,我在编写测试用例的时候,就会指定这个数据库读取数据的函数的返回值永远为某个值。

为什么要这么做呢?因为单元测试的原则是只测试当前函数的代码逻辑是否正确,如果对当前函数进行测试的时候,依赖了外部的资源,那当前函数的运行状况,就可能会有外部资源的干扰,所以单元测试应当屏蔽这些干扰,这就是所谓的打桩。

如果需要对一个模块进行真实环境下的测试,当然也是可以的,但那就可能是比单元测试更高一级的存在了,比如集成测试。

书里有一句话我觉得说的很好,如果你觉得有一段代码很那进行单元测试,那也许这段代码在设计时就没考虑测试的视角,那么,这样的代码就必然是难以维护、难以重构的。

4.

单元测试输入的构造,原理有很多,从实践上来看,有如下几个简单的原则:

  1. 等价类划分
  2. 边界值
  3. 错误推测

等价类划分,说白了就是把程序的输入”归类”,每一类制作一个用例。

边界值的话,人们通过经验发现,程序的错误经常出现在输入的边界值附近,比如成绩系统的60分、0分、100分之类的。那么对于边界值,也应当设置一些用例。

错误推测,就是开发人员(或者测试人员)根据对具体业务的了解,根据经验推测错误可能发生的情况,将可能触发错误的条件主动写到测试用例里。

不过说实话,在我的实践中,为了节省时间,我的很多单元测试都只测试了”代码的正确运行逻辑”,而很少有错误的输入。我感觉,如果单元测试即便只测试”代码的正确运行逻辑”,也很有用了。

5.

单元测试可能还有两个意义。

第一个就是,避免代码的很多低级逻辑错误,或者是找出那些代码中很少被执行的部分中所暗藏的错误。软件系统的BUG,经常是这种情况:是某一段平时很少很少被运行到的代码,在某种特定的条件下,被运行了,其中的BUG就被触发了。如果没有单元测试,直接把代码放到实际环境中运行的话,就可能很难触发到这段代码。所以这就是为什么单元测试的覆盖率之所以重要。

第二是,单元测试是一种很好的,对代码工作的”交付”进行验收的工具。比如说程序员小王,现在接到了一个开发工作,开发了几天后,leader问你开发完了吗?小王说搞完了,可以部署了。于是大家部署了小王的代码到测试环境里,发现系统产生了BUG,经过一番调试后,小王发现自己的代码里有BUG,需要修改。

如果有单元测试的话,在提交代码前先自测好,小王就可以尽可能地保证自己的这部分代码是正确无误的,如果部署到测试环境后真的出现了BUG,要么是产生了单元测试没能覆盖的BUG(但是在有基本的单元测试的情况下,这类BUG也应当能减少80%),要么是实际系统中,各个模块之间的交互或者整体运行出现了问题,不管是那种情况,都应当能减少很多团队其他成员的时间成本。我想,单元测试通过,是对代码工作验收的一个很好的标准。

6.

为什么单元测试看起来这么厉害,但是并不是所有的人都在用,或者所有的人都认可其价值呢?

人们会认为那些能治重病的医生,是最厉害的医生,殊不知最厉害的医生,在病症还未发作时就将其消除了。人们会认为改BUG是个正常的事情,但是也许写单元测试的时间还不如改BUG的时间。

我认为这就是最根本的原因。

我坚信,单元测试在绝大多数情况下都是一个投入远远大于回报的事情,而且这种”回报”更像是一种对损失的避免,有点类似健康投资。你对健康进行了投资,得到的回报是避免了花那些治病的钱。

同时我也相信,我现在对单元测试认识是非常入门级别的,对于单元测试对软件开发的益处,我可能也抱有过高的期望。不过,这就是需要时间来见证的事情了。欢迎大家和我交流!

7.

推荐一下Golang和Python写单元测试的框架。

对于Golang来说,有两个库很好用,分别是Convey和gomonkey,前者是一个单元测试框架,能让单元测试的书写更方便,后者是一个打桩用的库,非常好用。另外就是,涉及对http接口的测试时,需要试用Golang自带的httptest,这个库的用法不太直观,不过熟悉后也挺好用的。

Python的话,我还没研究过打桩,对于普通的单元测试来说,自带的unittest库就挺好用的。

8.

还想再补充一点,就是有人说单元测试是最好的文档,从工作中的实践来看,我非常认同这一点。也就是说,单元测试不仅对软件质量本身是个保障,对整个软件开发的过程来说都很有帮助。

举例之一:

我同事写了一个模块,我的任务是把这个模块集成到系统中。虽然也是有文档的,但是由于开发节奏很快,接口文档很可能滞后于代码的版本,那么这时候我想要摸清这个程序的行为,就必然要构造很多输入,然后观察程序的输出,这样我才能进一步地把这个模块集成到系统里。设想,如果这时候有较为完善的单元测试,我只需要运行一下单元测试,就能知道这个程序的运行是否符合设计者的预期(因为单元测试的编写就是设计者预期的体现)。即便缺少文档,我也能从测试用例中看懂这个模块的预期行为。

举例之二:

前两天维护了一个4年前的老代码,这段老代码有一个不太方便测试的地方,就是它的输入依赖于另外一个模块的输出,而另外这个模块(我们公司自制的爬虫),又不太好部署,只有一些文档描述了爬虫的输出格式。那么这时候,我给老代码制作了几个测试用例,其中,用例的输出就是爬虫文档中所描述的输出,这样一来,我不用部署上游的程序,也能独立地测试我想要测试的这部分老代码。而且一旦在测试的过程中发现问题,我也能很好地判断错误发生的范围(既然上游的数据是模拟出来的,那就不会是上游程序的代码问题)。