人人都能看懂的全栈开发教程——单元测试

人人都能看懂的全栈开发教程——单元测试

Chris Yue No Comment
Posts

每当我们做好一个功能,我们都需要亲自运行,确认一下是否真的已经可以使用,而之前我们又提出了『优先实现需求』的开发方式。但这种方式相比以前的开发方式来说还是有一个问题:如果我们并没有完成 Infrastructure 里的代码,或者说如果没有实现我们需要的接口,我们就没有办法测试现有代码是否可用了。

大家可能都去过宜家买过灯泡。宜家会在灯泡旁边设置一个灯座,让你可以试试灯泡是不是坏的,而不需要你非要买回去给你家的灯具安上才能确认。如果你使用面向对象的思想去设计你的代码,你也可以像例子中的灯泡一样测试你的代码是不是已经好用了。

让我们再回顾一下 Domain\UserManager 类。当我们还没有一个具体实现 Domain\UserRepositoryInterfaceDomain\PasswordEncoderInterface 这两个接口的类的时候,我们无法创建一个 Domain\UserManager 的对象。但我们可以自己先写两个极其简单的类来实现我们需要的俩接口。

不过且慢,我们并不需要真的去创建两个类,实际上 PHP 已经有工具能帮我们轻松做这个事情了,这个工具就是我们上一章所安装的 PHPUnit,即 PHP 的『单元测试』工具。

为什么单元测试(Unit Test)要叫『单元』测试呢?实际上在整个开发周期中,我们都一直在做『测试』,如果测试出现问题,虽然有的时候我们可以从错误信息里看到错误原因,但有些错误,可能没有错误信息,比如我们之前说一个任务需要跟一个用户关联。所以 task 表的 user_id 字段必须有一个对应的 user 的 id。可能因为代码写得有问题,我们当前用户的 ID 获取到的值都是 0。如果我们并没有用数据库的外键约束功能,用户创建任务时,不会报错任何错误,但就是看不见自己刚创建的任务。

我们再拿灯泡来举例。假如你家某个灯具不亮了,原因可能是灯泡坏了,可能是灯具的灯座坏了,可能是插头坏了,可能就是没电了,虽然机率比较低,但不排除是电也没了灯也坏了。总之你没有办法在第一时间知道到底哪个环节出了问题。但如果你家里有一个像宜家那样的灯座,你就可以先测试是不是灯泡的问题;你也可以看看电冰箱,先确认一下是不是没电了。

单元测试实际上就是对某个『单元』进行测试,不但可以让你不需要完整的运行环境就能做测试,而且还能第一时间告诉你是哪个单元可能有问题。

我们先使用 PHPUnit 来实际写一个单元测试感受一下。让我们先创建一个 tests 目录,然后创建 tests/Domain/UserManager.php 文件,并写下以下内容。注意看代码中的注释:

回到终端,执行下面命令运行测试

这里要说明的一点是,某些 PHP 库可能会自带命令行工具,比如说 PHPUnit,这些命令行工具都将被安装到 vendor/bin/ 目录下。

正常情况下,PHPUnit 给出的运行结果为

Tests: 1, Assertions: 0, Risky: 1.

即有一个测试(Test,实际是指测试类),但没有任何检查(Assertion),正因为只有测试类但没做任何检查,所以存在一个风险(Risk)。

虽然有风险,但这已经可以检查我们的代码有没有语法错误之类的『低级错误』了。

这里我们再说说检查。什么是检查呢?在 PHPUnit 中,当我们运行一个函数时,我们对函数返回值的『期待』是否是函数实际返回的值,就是检查;或者当我们要测试的方法 A 里调用了别的方法 B,我们所『期待』 B 方法会接受到的参数是否与实际运行时 B 方法接受到的参数一致,也是检查。

比如我们假装使用 UserManagerregister 方法添加一个登录名为 chris 密码为 123456 的用户记录(正如代码 26 行所描述的样子)。按我们的需求,我们的第一个期待为,PasswordEncoderInterfaceencode 方法会被调用且只调用一次,并且参数必须是 123456。然后,我们假设 encode 方法完成了加密工作,返回了 654321,这里强调一下,encode 返回 654321 不是『期待』,而是我们的『假设』,所以它不是检查。

接下来我们的第二个期待是 UserRepositoryInterfaceadd 方法会被执行且只执行一次,接收参数必须是 chris654321

下面我们将把我们的『检查』在代码里实现出来:

此时我们再执行 PHPUnit 命令,会得到以下结果:

OK (1 test, 2 assertions)

依然是一个测试(的类),但有了两个检查,即对两个接口的两个方法的参数和执行次数的检查,并且都通过了。

如果我们的需求有一些变化,我们也可以先改测试代码,再改业务逻辑代码。比如说我们的注册方法实际上遗漏了对重复注册的登录名的检查,当这个需求被提出来之后,我们可以先添加此需求对应的测试。

为了判断注册时的用户名是否是已经被占用,我们首先需要给我们的『用户仓库』类添加判断用户名是否存在的方法:

而我们希望当检查到有已经存在的用户时,抛出一个『用户名已存在异常』。先不说什么是『异常』,只用知道异常也是一个类,代表发生了『意外』:

我们再把对『检查重复注册的登录名』的检查写到测试代码里:

在我们还没有实现 UserManager 类的注册逻辑时,我们已经按需求把测试代码写出来了,当然这个时候运行 PHPUnit 肯定是会报错的。我们最后把注册逻辑对用户名的重复检查实现:

这个时候再执行 PHPUnit 不出意外,应该返回检查通过信息:

OK (2 tests, 5 assertions)

即使我们还没有实现 UserRepositoryhasUsernameExisted 方法,依然对 UserManagerregister 方法做了测试。

看到这里,大家事实上已经接触到另外一个概念叫做『测试驱动』,英文缩写叫 TDD (Test Driven Development)。

可能有人会觉得别扭,代码是你自己写的,测试也是你自己写的,有一种『即做球员又做裁判』的感觉。

单元测试最大的价值在于,在代码未来不断变化的过程中,保持一种『完整性』。怎么理解这句话呢?

如果没有单元测试,以我自己的经验,在需求不断变化,代码不断升级的过程当中,很难不出现 bug。也许你为了实现某个产品的需求,把某个方法的参数从两个变成了三个,或者纯粹是处于某种技术考虑,交换了一下参数的顺序。很有可能你以为把该改的都改了,也只测试了自己以为该测试的地方,但还是可能有遗漏的调用部分你没有想到;如果是很多人开发的大型项目,甚至你都不知道影响有多大,这就会导致部分功能出现 bug。而有了 PHPUnit,修改完自己的代码之后再确认一遍有没有导致别的代码出错,是一个保证『代码质量』的一个好习惯。

另外单元测试也是一种『双重』检查。如果有条件,测试代码和业务代码应该是不同的两个人来负责。如果测试的结果有问题,两个人中必有一个人对业务的理解是有问题的,不一定是写业务代码的人有问题,可能测试代码本身是错的,但双重检查肯定更可靠,两个人都错,还错到一块儿去了的概率极其之低。

最后,单元测试也提现了面向对象的思想,即如果某个类依赖另外若干类,但都还没有做出来,我们可以通过『造假』的方式,让做好的类能先创建出来,进行测试,这在之前『从上到下式』的开发方式里是完全不能想象的。我不得不说,在这十多年不断积累技术知识和经验的过程当中,我收获的不但是技术的提升,更是解决各种问题的提升。技术开发里所出现的各种解决问题的思想和方法,也是是可以在解决别的问题上借鉴的。

本章完成代码请见这里

人人都能看懂的全栈开发教程——单元测试 by Chris Yue is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

微信赞赏码

如果觉得文章还不错,就请扫码鼓励一下作者吧
天使打赏人

发表评论

57 + = 62