在计算机编程中,单元测试(英语: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)); |
完整的单测代码:
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 | namespace web\test; require_once "../lib/Join.php"; use web\lib\Join; use PHPUnit_Framework_TestCase; class JoinTest extends PHPUnit_Framework_TestCase { public function testSignIn() { // class mock $db = $this->getMockBuilder('web\lib\Db') ->disableOriginalConstructor() ->setMethods(['insert', 'exists']) ->getMock(); // function mock $db->method('insert') ->willReturn(true); // mock method multiple calls with different arguments $map = [ ['user', ['username' => 'yaozhen'], true], [$this->anything(), $this->anything(), false], ]; $db->method('exists') ->will($this->returnValueMap($map)); $join = new Join($db); $this->assertEquals(['code' => 1, 'msg' => 'user has exists'], $join->signIn('yaozhen', 'pwd')); $this->assertEquals(['code' => 0, 'msg' => 'success'], $join->signIn('zhangsan', 'pwd')); } } |
这样就可以很好的测试 signIn
接口而不用担心被依赖的第三方接口/类库影响。当然这只是个很小的例子,实际操作比这个复杂,但基本原理类似,结合实际、对照文档一般的问题就能解决了,若不能简单的解决则说明代码不够优雅,可测性低,上面的例子也可以看出 db 实例是通过依赖注入的方式当做参数传入到 Join
类中,这样即降低了代码间的耦合也提高了可测性。
不过需要注意的是 final
、private
和 static
方法无法(其实可以,但 PHPUnit 不支持)对其进行上桩(stub)或模仿(mock),关于这一点是个经常被问到的问题。不支持 private 方法是因为正常情况下你不需要这样做,因为你都无法调用私有方法,参考:https://stackoverflow.com/questions/5937845/mock-private-method-with-phpunit。不支持 static 方法是因为静态方法死于可测性:静态方法调用很灵活,如果一个静态调用另一个静态方法就没办法控制被调方法的依赖。其实在早期版本(<3.9)中是支持静态方法的 mock 的,但后来作者经过考虑移除了这一功能(staticExpects 方法),也不提供其它方法支持这一功能:
当然这些限制都是可以突破的,但不建议这样做,还是好好改改你的代码吧,提高可测性,这也是单测带来的收益之一,让你发现代码中不足之处。
上面我们通过单测保障了 Join 类的代码质量,但 Db 类老是有 bug,而且每次改动还担心影响之前的功能。现在考虑把 Db 类也加上单测,但数据库测试如何做呢?其实就和平常的测试一样,连上数据库,然后运行一下代码,最后查看库里的数据是否符合预期。PHPUnit 将这个过程抽象了出来,提供了相应的方法,下面是具体例子:
一般使用 PHPUnit 时都是继承 PHPUnit_Framework_TestCase
方法,但这里是继承 PHPUnit_Extensions_Database_TestCase
并还需实现两个抽象方法,getConnection()
和 getDataSet()。
实现 getConnection():为了让清理与载入基境的功能正常运作,PHPUnit 数据库扩展模块需要用 PDO 库来实现跨供应商抽象访问数据库连接。重要的是要注意到,使用 PHPUnit 的数据库扩展模块并不要求应用程序本身基于 PDO,PDO 连接仅仅用于清理和建立基境。简单来说就是连接上测试的数据库:
1 2 3 4 5 6 7 8 9 10 11 | /** * connect Database * * @return \PHPUnit_Extensions_Database_DB_DefaultDatabaseConnection */ public function getConnection() { $db = new PDO("mysql:host=127.0.0.1;dbname=user", "root", ""); return $this->createDefaultDBConnection($db, "user"); } |
实现 getDataSet():getDataSet()
方法定义了在每个测试执行之前的数据库初始状态应该是什么样。简单来说就是每个单测 case 运行之前数据库的基境。
什么是基境(fixture)?
基境(fixture)是对开始执行某个测试时应用程序和数据库所处初始状态的描述。
那么如何创建一个数据集(DataSet)呢,主要有 3 中方法:
- 基于文件的 DataSet 和 DataTable
- 基于查询的 DataSet 和 DataTable
- 筛选与组合 DataSet 和 DataTable
这里说一下最常用的基于文件的数据集,其它几种方法的使用请查看文档。
XML DataSet (XML 数据集):在根节点 <dataset>
内,可以指定 <table>
、<column>
、<row>
、<value>
和 <null />
标签。例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <dataset> <table name="user"> <column>id</column> <column>username</column> <column>password</column> <row> <value>1</value> <value>yaozhen</value> <value>yaozhenpwd</value> </row> <row> <value>2</value> <value>yaozhen2</value> <value>yaozhenpwd2</value> </row> </table> </dataset> |
1 2 3 4 5 6 7 8 9 | /** * Set up fixture * * @return \PHPUnit_Extensions_Database_DataSet_XmlDataSet */ public function getDataSet() { return $this->createXMLDataSet("user.xml"); } |
注:PHPUnit 假设在测试运行之前数据库以及其中的所有表(table)、触发器(trigger)、序列(Sequence)和视图(view)都已经创建好。这意味着开发者必须在运行测试套件之前确保数据库已经正确建立。每个测试方法运行之前都会调用此方法清空数据库中的数据,然后导入设置好的数据集。
基础数据设置好后就可以进行实际的操作了,正常实例化 Db 类然后调用 insert 方法插入数据:
1 2 3 4 5 6 7 8 9 10 11 12 | /** * test insert one row */ public function testOneInsert() { $dbObj = new Db('user'); $dbObj->connect(); $dbObj->insert('user', [ 'username' => 'yaozhen_new_insert', 'password' => 'yaozhenpwd', ]); } |
安装单测的基本套路,现在就需要对结果进行断言了,PHPUnit 提供了 3 类数据断言 API:
- 对表中数据行的数量作出断言
- 对表的状态作出断言
- 对查询的结果作出断言
这里使用行数断言和结果断言:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | /** * test insert one row */ public function testOneInsert() { $dbObj = new Db('user'); $dbObj->connect(); $dbObj->insert('user', [ 'username' => 'yaozhen_new_insert', 'password' => 'yaozhenpwd', ]); // table rows num Assertions $this->assertEquals(3, $this->getConnection()->getRowCount('user'), "Inserting failed"); // Asserting the State of Multiple Tables $queryTable = $this->getConnection()->createQueryTable('user', "SELECT * FROM user"); $arrayDateSet = new My_DbUnit_ArrayDataSet(['user' => [ ['id' => 1, 'username' => 'yaozhen', 'password' => 'yaozhenpwd'], ['id' => 2, 'username' => 'yaozhen2', 'password' => 'yaozhenpwd2'], ['id' => 3, 'username' => 'yaozhen_new_insert', 'password' => 'yaozhenpwd'], ], ]); $expectedTable = $arrayDateSet->getTable("user"); $this->assertTablesEqual($expectedTable, $queryTable); } |
查看源码很容易发现 getRowCount()
方法在底层是通过执行的 SELECT COUNT(*)
来计算行数,createQueryTable()
方法底层也是执行传入的 SQL,可以看出这和我们平常手动自测是一样。细心的读者可能会发现数据结果断言的时候使用了一个 My_DbUnit_ArrayDataSet
类,这是一个自定义的数组 DataSet 类,方便产出自己期望的数据集,实现也非常简单:
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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | <?php namespace web\test; use PHPUnit_Extensions_Database_DataSet_AbstractDataSet; use PHPUnit_Extensions_Database_DataSet_DefaultTableMetaData; use PHPUnit_Extensions_Database_DataSet_DefaultTable; use PHPUnit_Extensions_Database_DataSet_DefaultTableIterator; use InvalidArgumentException; class My_DbUnit_ArrayDataSet extends PHPUnit_Extensions_Database_DataSet_AbstractDataSet { /** * @var array */ protected $tables = array(); /** * @param array $data */ public function __construct(array $data) { foreach ($data AS $tableName => $rows) { $columns = array(); if (isset($rows[0])) { $columns = array_keys($rows[0]); } $metaData = new PHPUnit_Extensions_Database_DataSet_DefaultTableMetaData($tableName, $columns); $table = new PHPUnit_Extensions_Database_DataSet_DefaultTable($metaData); foreach ($rows AS $row) { $table->addRow($row); } $this->tables[$tableName] = $table; } } protected function createIterator($reverse = FALSE) { return new PHPUnit_Extensions_Database_DataSet_DefaultTableIterator($this->tables, $reverse); } public function getTable($tableName) { if (!isset($this->tables[$tableName])) { throw new InvalidArgumentException("$tableName is not a table in the current database."); } return $this->tables[$tableName]; } } |
数据库测试基本的步骤也就是这些,其它几个方法的单测很容易举一反三:
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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 | <?php namespace web\test; require_once "../lib/Db.php"; require_once "My_DbUnit_ArrayDataSet.php"; use web\lib\Db; use \PDO; use PHPUnit_Extensions_Database_TestCase; class DbTest extends PHPUnit_Extensions_Database_TestCase { /** * connect Database * * @return \PHPUnit_Extensions_Database_DB_DefaultDatabaseConnection */ public function getConnection() { $db = new PDO("mysql:host=127.0.0.1;dbname=user", "root", ""); return $this->createDefaultDBConnection($db, "user"); } /** * Set up fixture * * @return \PHPUnit_Extensions_Database_DataSet_XmlDataSet */ public function getDataSet() { return $this->createXMLDataSet("user.xml"); } public function testConnect() { $dbObj = new Db('user'); $this->assertNotFalse($dbObj->connect()); $dbObj = new Db('user', '127.0.0.1', 'root', '123456'); $dbObj->connect(); $this->assertFalse($dbObj->connect()); $this->assertEquals("Access denied for user 'root'@'localhost' (using password: YES)", $dbObj->getLastError()); } /** * test insert one row */ public function testOneInsert() { $dbObj = new Db('user'); $dbObj->connect(); $dbObj->insert('user', [ 'username' => 'yaozhen_new_insert', 'password' => 'yaozhenpwd', ]); // table rows num Assertions $this->assertEquals(3, $this->getConnection()->getRowCount('user'), "Inserting failed"); // Asserting the State of Multiple Tables $queryTable = $this->getConnection()->createQueryTable('user', "SELECT * FROM user"); $arrayDateSet = new My_DbUnit_ArrayDataSet(['user' => [ ['id' => 1, 'username' => 'yaozhen', 'password' => 'yaozhenpwd'], ['id' => 2, 'username' => 'yaozhen2', 'password' => 'yaozhenpwd2'], ['id' => 3, 'username' => 'yaozhen_new_insert', 'password' => 'yaozhenpwd'], ], ]); $expectedTable = $arrayDateSet->getTable("user"); $this->assertTablesEqual($expectedTable, $queryTable); } /** * test isnert mutlti row * * @depends testOneInsert */ public function testMultiInsert() { $dbObj = new Db('user'); $dbObj->connect(); $dbObj->insert('user', [ [ 'username' => true, 'password' => 'yaozhenpwd', ], [ 'username' => 'yaozhen_new_insert2', 'password' => null, ], [ 'username' => 'yaozhen_new_insert3', 'password' => 123, ], ]); // table rows num Assertions $this->assertEquals(5, $this->getConnection()->getRowCount('user'), "Inserting failed"); } /** * test exists func */ public function testExists() { $dbObj = new Db('user'); $dbObj->connect(); $result = $dbObj->exists('user', [ 'username' => 'yaozhen', 'password' => 'yaozhenpwd', ]); $this->assertTrue($result); $result = $dbObj->exists('user', [ 'username' => true, 'password' => null, ]); $this->assertFalse($result); } } |
至此,你已经学会了基本的 Mock 和数据库测试。当然,实际中比这个更复杂,但万变不离其宗,掌握了基本套路,其它的看看文档也很快就能搞定了。
最后,本文中的代码因为实际生产环境 PHP 版本(=5.4)原因,使用的是 PHPunit 4.8 版本,如果你使用的是 5.X 版本那可能有点不一样,还需你对照手册来参考。
参考文档:
https://sebastian-bergmann.de/archives/883-Stubbing-and-Mocking-Static-Methods.html
https://zh.wikipedia.org/wiki/单元测试
https://github.com/sebastianbergmann/phpunit-documentation/issues/77
https://www.sitepoint.com/bulletproofing-database-interactions/