目录
前言
什么是单元测试
单元测试的价值
单元测试的特征
单元测试测什么
如何写单元测试
使用 Mock
有很多的 mock ?
本文旨在给大家一点关于如何写单元测试的指南,如果能帮助到大家的话那最好不过了。
写代码也有几年了,可能很多人都只是知道有单元测试这个东西,但是自己从来没有写过单元测试。 单元测试好像从来都只是一个可选项,而不是必选项,因为就算没有单元测试,每个公司起码也还有专门的测试人员, 我们写好代码,然后放到测试环境交给测试人员去验证即可。这样看来好像没有单元测试也可以。
但是在走过不少弯路之后发现,即使我们没有办法做到 100% 的单元测试覆盖率,仅仅对一些复杂的功能写上单元测试,也还是可以节省我们大量的时间。 主要原因是如下:
可能也有一部分人会写测试,但是可能写得并不好,比如测试的粒度太大,比如直接针对 http 接口写测试, 但是我们也知道,一个接口背后包含的逻辑可能非常多,这样一来,我们写的测试包含的不确定性也会非常大, 因为这个大接口背后任何一个逻辑的修改都有可能导致我们的测试不通过,这样的 “单元测试” 无疑是非常脆弱的。 如下图这样,RPC 服务端、数据库服务器、文件系统、HTTP 服务端的异常都会导致我们的这种测试失败。 如果我们现在也在写这样的测试,那我们就得好好看看接下来的内容了。
本质上来说,这种测试不是单元测试,而是一种集成测试,单元测试是不包含跟其他组件的交互的,而我们的 http 接口可能会调用数据库,对数据库有强依赖。 这种依赖性也是测试脆弱性的一个来源,在良好的单元测试中,所有依赖都是被 mock(模拟) 掉的,也就是说, 我们的代码中,依然会有数据库访问的代码,但是在运行测试的时候,并不会产生实际的数据库访问操作。 在一个复杂的系统中,可能还会包含 rpc 调用、http 调用等,而这些强依赖性的东西我们都是需要在单元测试中 mock 掉的。
要认识单元测试,首先要明白什么是 “单元”。所谓 “单元” 指的是代码调用的最小单位,实际上指的就是一个功能块(Function)或者方法(Method)。 **单元测试指的就是对这些代码调用单元的测试。**单元测试是一种白盒测试,就是必须要对单元的代码细节很清楚才能做的测试。 所以说,如果我们代码没写好,测试也没法写,写单元测试可以驱使我们写出更好的代码。
单元测试的编写和执行都是由软件工程师来做的。相对于单元测试,还有集成测试。 集成测试基本都是都是黑盒测试,主要由测试人员根据软件的功能手册来测试,需要有专门的测试环境配合。
单从测试的角度来说,单元测试的成本是最低的,速度最快的。 因为单元测试没有任何对外部的依赖,写完直接就可以执行,我们不需要为单元测试准备一整套环境,比如先把服务器各种组件安装好,运行起来,然后把应用启动。
在我们写完代码的时候,点一下运行测试,马上就可以知道我们的代码是否有问题。 因为单元测试不需要依赖这些外部的东西。所以就算我们连服务器都还没准备好,我们也可以对我们的代码进行单元测试来验证代码的正确性。
除此以外,对于软件工程师来说,如果写代码时对自己的代码没有办法快速的验证,也就没有一个反馈,往往会有一种强烈的不安全感。 写的代码越多,不安全感累积的会越多,最后会发觉自己对自己所写的代码完全没有把握。 即使是快速的迭代方式,最少也要一周才能得到测试的反馈。并且很有可能测试的反馈结果会导致自己一周的代码都白写了,全部要推翻重来。 所以测试人员在测试的时候,软件工程师非常焦虑。如果迭代时间更长的话,造成的心理压力会更大。 测试在进行的时候,软件工程师往往会疲于奔命地去修复问题,也容易和测试团队发生冲突,从而产生沟通问题。
当然,这个问题会在持续一段时间后会好转,因为 bug 总会随着时间的推移被一个个修复。 然后我们就可以在一个更加稳定的系统上进行一些新功能的开发。 但是依然无法避免,新开发的功能可能会在某些非常隐秘的地方破坏了旧的逻辑,然后在一段时间后才能发现。 这可能不是我们想看到的结果。
另外,单元测试一旦写好可以长期使用,特别是在回归的时候,可以帮助节省大量的测试时间, 我们可以很容易知道,新的功能、或者对旧代码修改有没有对原有功能有破坏,单元测试可以帮忙发现很多隐藏的问题。
总的来说,单元测试可以给我们带来如下价值:
如果我们有 pull 过一些优秀的开源项目,我们可以运行一下里面的单元测试,我们可能会发现在几秒内就完成了全部代码的单元测试。
独立的一个好处就是可以单独验证某一个逻辑是否正确,如果需要依赖其他测试的话,说明我们的代码还是存在一些设计上的缺陷。 因为这在某种程度上表面,我们的不同逻辑之间存在着强依赖。
如果我们每次运行的结果都不一样,那我们也无法对程序运行结果进行断言,我们就无法判断运行的结果是否正确。
比如我们不能说跑一下测试,然后去看看数据库有没有写入成功、文件有没有写入成功。 因为这种东西没什么好测的,数据库只要能正确运行那肯定是可以写入的,如果某些异常情况下数据库写入不了,那也不是我们的代码的 bug, 因此我们会 mock 掉数据库访问。而文件读写、RPC 调用之类也是同样的道理。
我们上面说了,单元测试是对一个功能块(Function)或者方法(Method)的测试。但不是所有的 “单元” 都需要单元测试的。 既然要做单元测试,我们就要知道要测什么内容。比如下面的代码,需要测试吗?
public static Response get(String url) throws IOException {
okhttp3.Request request = new okhttp3.Request.Builder()
.url(url)
.build();
return client.newCall(request).execute();
}
这是一段很常见的 http 调用代码,里面只是根据传递的 url 调用 okhttp 库发起了一个 GET 请求。 假如要单元测试的话,究竟是测试什么?测试那个 url 背后的服务器是否正常运行?测试我本地的网络是否正常?
实际上,这类对外部系统的依赖是不需要测试的,只要能够编译通过,操作系统会保证它的正常执行。 如果它不能正常执行,那也不是我们代码的问题,可能是 url 所在服务器宕机了,或者本地的网络异常了。 但是这跟我们的代码能否正确处理逻辑一点关系都没有。
我们写的业务逻辑不可能说在外部服务器宕机的时候就处理错误了,比如我们的代码里面计算了 1+1,我们断言它等于 2, 然后我们发起了一个 HTTP 请求,但是这个 HTTP 请求异常了,这个时候我也不能说 1+1 不等于 2。 因为我们的这个 1+1=2 的逻辑跟外部的系统没关系。
**单元测试测的是我们写的业务逻辑代码。**所有跟外部系统的交互都是不需要进行测试的。
明确了我们要测试的内容,接下来就得学习一下如何写单元测试了:通过提供预期的输入和预期的结果,与单元的实际运行结果进行比对, 就可以知道单元的工作是否和预期的一致。
所以,写单元测试有三个步骤:
对同一个目标方法,通过构建各种不同的输入,重复上述步骤,检测各种正常与边界状况和预期是否相符,确保把目标方法的各种可能性都覆盖。
下面是一个简单的例子(PHP):
// 单元测试的目标方法
function add(int $a, int $b): int
{
return $a + $b;
}
// 单元测试
// 测试 add 方法
public function testAdd()
{
// 构建输入
$a = 1;
$b = 1;
// 调用目标方法
$sum = $this->add($a, $b);
// 比对输出与期望的值是否一致。
// 如果不一致的话,单元测试不通过,说明我们的目标方法有错误或者我们的期望值有错误。
$this->assertEquals(2, $sum);
}
我们发现,单元测试写起来好像也没那么难是吧。 当然,在实际工作中的需求大多比这个复杂多了,但是单元测试的步骤其实就上面提到的三个:构建输入、调用被测方法、验证输出。
单元测试其实并不复杂,复杂的其实是我们的代码。 如果想更好地写好单元测试,我们还必须得了解一下单元测试中的 mock。
mock 是单元测试中帮助我们模拟类方法的一种技术。 我们知道了,单元测试不应该对数据库这些外部组件有依赖,那我们该如何实现才能让单元测试没有外部依赖呢? 答案就是 mock,当我们的代码需要依赖某一个类的时候,我们可以使用 mock 库来生成一个模拟的对象, 在我们的代码的代码需要调用这个对象的某些方法的时候,实际上并不会产生实际的调用。 这么说有点抽象,下面是一个非常典型的例子:
class Adder
{
public function add($a, $b)
{
return $a + $b;
}
}
class Calculator
{
private $adder;
/**
* @param Adder $adder 代表一个对外部的依赖
*/
public function __construct(Adder $adder)
{
$this->adder = $adder;
}
public function add($a, $b)
{
// 这里只使用了外部依赖,实际中可能包含非常多的逻辑
return $this->adder->add($a, $b);
}
}
// 单元测试
public function testCalculator()
{
// 创建一个模拟的 Adder 对象
$adder = Mockery::mock(Adder::class)->makePartial();
// shouldReceive 表明这个 mock 对象的 add 方法会被调用
// once 表明这个方法只会被调用一次(没有 once 调用表示可以被调用任意次数)
// with 如果调用 mock 对象的时候传递了 1 和 2 两个参数,就会返回 andReturn 中的参数
$adder->shouldReceive('add')->once()->with(1, 2)->andReturn(3);
$c = new Calculator($adder);
$this->assertEquals(3, $c->add(1, 2));
$adder = Mockery::mock(Adder::class)->makePartial();
// 没有指定 with,传递任意参数都会返回 3
$adder->shouldReceive('add')->andReturn(3);
$c = new Calculator($adder);
$this->assertEquals(3, $c->add(2, 3));
}
在所有常见的编程语言中,都会有一个比较成熟的 mock 库,比如:
有了 Mock,我们就可以实现隔离掉外部依赖的这一目标。 不管是 RPC、数据库还是读写文件等操作,我们都可以使用一个模拟的对象来模拟实际的操作。 这意味着,不管外部系统怎么变化,我们的单元测试如果运行通过了,说明我们写的代码逻辑上是没有问题的。这样我们的单元测试才更加健壮。
在单元测试的时候,我们通常会将外部依赖以 mock 的形式注入到我们的代码中。 这一点各种语言实现上会有比较大的差异,有时候还跟使用的框架相关:
看完上面的讲述,我们可能会兴致勃勃地想去写单元测试。 在我们开始写单元测试之后,可能会感到非常沮丧,一杯茶一根烟一个测试写一天,我们会发现怎么要 mock 这么多东西。 这个时候,我们可能会开始思考,这种 mock 的做法到底对不对,为什么写起来这么费劲呢?
出现这种情况,往往反映的是我们代码背后设计上存在的问题,如果一个类需要依赖很多其他东西,说明这个类本身太复杂了。 这个时候怎么办?那当然是能跑就行!代码跟人有一个能跑就行。
对于遗留系统的代码我们可能无能为力,但是对于我们新增的代码,我们依然有机会去改进, 在一边写新代码,一边写单元测试的过程中,我们可以去思考怎样写出来的代码是可以写单元测试的。 我们可以去看看关于软件设计方面的一些东西,比如郑晔的《软件设计之美》,个人感觉是比较接地气的。 持续地去编写单元测试可以促使我们写出可重用、可推广的代码,以及改进我们的软件设计。