在计算机编程中,单元测试(英语:Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。
本文主要是根据PHPUnit(The PHP Testing Framework)文档结合实例简单介绍一下 PHP 单元测试中 mock 和数据库测试。如果你是初次接触单测的话建议先看一下 PHPUnit 文档中的入门章节。
通常来说是开发程序和单测是同步进行的,项目提测的时候核心模块都需要包括单测(报告),但这个要求在不同的公司、部门、项目组要求不一样。虽然单测会占用一定的开发时间但总的来说单测是利远大于弊,最大的好处是自己或者别人后续更新模块功能时不用担心对原有功能造成了影响而不知情。
下面是一个具体的例子,在 web 开发中这样一个场景可能很常见:PHP 提供一个帐号注册的接口供前端调用,接口先检验一下此用户名是否已经存在,不存在的话插入数据库,返回注册成功。接口代码是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | <?php require_once "lib/Join.php"; require_once "lib/Db.php"; use web\lib\Db; use web\lib\Join; $username = $_POST['username']; $password = $_POST['password']; $db = new Db('user'); $join = new Join($db->connect()); echo json_encode($join->signIn($username, $password)); |
主要调用了 Join 类的 signIn 方法。我们来看看 Join 类是啥样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | <?php namespace web\lib; require_once "Db.php"; class Join { private $db; function __construct(Db $db) { $this->db = $db; } public function signIn($userName, $password) { if ($this->db->exists('user', ['username' => $userName])) { return [ 'code' => 1, 'msg' => "user has exists", ]; } else { $this->db->insert('user', [ 'username' => $userName, 'password' => $password, ]); return [ 'code' => 0, 'msg' => "success", ]; } } } |
逻辑很简单,先调用 Db 类的 exists 方法判断用户名是否存在,不存在的话使用 insert 方法插入数据。Join 类是这次业务新加的,比较重要,需要单测来保障质量,但这里用到了个 Db 类,这个库是以前就有的(坑),可能会影响本模块单测的正确性,而且 Db 类需要连接数据库,比较麻烦,这种场景就需要 mock 了。本文说的 mock 是广义上的,包括 Stubs(桩件)和仿件对象(Mock Object)。
将对象替换为(可选地)返回配置好的返回值的测试替身的实践方法称为上桩(stubbing)。可以用桩件(stub)来“替换掉被测系统所依赖的实际组件,这样测试就有了对被测系统的间接输入的控制点。这使得测试能强制安排被测系统的执行路径,否则被测系统可能无法执行”。
将对象替换为能验证预期行为(例如断言某个方法必会被调用)的测试替身的实践方法称为模仿(mocking)。
我们这里应用的是打桩的概念。signIn 方法有两个分支:用户名存在和不存在。所以我们需要让 Db 类的 exists 方法在输入某个(些)用户名的时候返回 true。主要使用 PHPUnit_Framework_TestCase
类提供的 getMockBuilder()
方法来建立一个桩件对象:
1 2 3 | // 为Db类创建桩件 $db = $this->getMockBuilder('web\lib\Db') ->getMock(); |
代码看上去很像是实例化了一个类,其实原理也和这个差不多,PHPUnit 通过反射机制获取到类及其方法的信息,然后使用内置模板生成一个新类。我们需要 mock 掉 insert
和 exists
方法:
1 2 3 4 | $db = $this->getMockBuilder('web\lib\Db') ->disableOriginalConstructor() ->setMethods(['insert', 'exists']) ->getMock(); |
这里使用了桩件生成器的 setMethods()
方法来设置哪些方法被上桩,以下是生成器提供的方法列表:
setMethods(array $methods)
可以在仿件生成器对象上调用,来指定哪些方法将被替换为可配置的测试替身。其他方法的行为不会有所改变。如果调用setMethods(null)
,那么没有方法会被替换。setConstructorArgs(array $args)
可用于向原版类的构造函数(默认情况下不会被替换为伪实现)提供参数数组。setMockClassName($name)
可用于指定生成的测试替身类的类名。disableOriginalConstructor()
参数可用于禁用对原版类的构造方法的调用。disableOriginalClone()
可用于禁用对原版类的克隆方法的调用。disableAutoload()
可用于在测试替身类的生成期间禁用__autoload()
。
然后分别设置两个方法的参数和返回值。这里 insert
操作比较简单,可以用 willReturn($value)
返回简单值:
1 2 | $db->method('insert') ->willReturn(true); |
上面的例子中,使用了 willReturn($value)
返回简单值。这个简短的语法相当于 will($this->returnValue($value))
。而在这个长点的语法中,可以使用变量,从而实现更复杂的上桩行为。我们这里的需求是需要根据预定义的参数清单来返回不同的值,显然这是一个映射(map),PHPUnit 提供现成的 returnValueMap()
方法来做这个事情:
1 2 3 4 5 6 7 | // mock method multiple calls with different arguments $map = [ ['user', ['username' => 'yaozhen'], true], [$this->anything(), $this->anything(), false], ]; $db->method('exists') ->will($this->returnValueMap($map)); |
完整的单测代码: Continue Reading...