使用数据库测试之前先安装拓展 composer require --dev phpunit/dbunit
。
数据库测试的难点
为什么所有单元测试的范例都不包含数据库交互?这里有个很好的理由:这类测试的建立和维护都很复杂。对数据库进行测试时,需要考虑以下这些变数:
- 数据库和表
- 向表中插入测试所需要的行
- 测试运行完毕后验证数据库的状态
- 每个新测试都要清理数据库
许多数据库 API,比如 PDO、MySQLi 或者 OCI8,都十分繁琐且书写起来十分冗长,因此,手工进行这些步骤绝对是噩梦。
测试代码应当尽可能简短精确,这有若干原因:
你不希望因为生产代码的小变更而需要对测试代码进行数量可观的修改。
你希望在哪怕好几个月以后也能轻松地阅读并理解测试代码。
另外,必须认识到,对于代码而言,本质上来说数据库是全局输入变量。测试套件中的两个不同的测试可能是运行在同一个数据库上的,并且可能把数据重用好多次。一个测试中出现的失败很容易影响到后继测试的结果,从而让整个测试过程变得非常艰难。前面提到的清理步骤对于解决“数据库是全局输入“的问题是非常重要的。
数据库测试的四个阶段
建立基境(fixture)
由于总是会有某个测试运行在并不确定表中是否有数据的数据库上,PHPUnit 在所有指定表上执行 TRUNCATE 操作来把它们清空
PHPUnit 随后将迭代所有指定的基境数据行并将其插入到对应的表里
执行被测系统
在所有数据库都完成重置并加载好初始状态后,PHPUnit 才会执行实际的测试。这个部分的测试代码完全不需要数据库扩展模块的参与,可以随意测试任何想要测试的内容
验证结果
在测试中,验证的目的可以使用一个名为 assertDataSetsEqual() 的特殊断言来实现
拆除基境(fixture)
数据库测试用例的配置
如果测试代码用到了数据库扩展模块,需要扩展 TestCaseTrait 类,它要求实现两个抽象方法,getConnection()
和 getDataSet()
:
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
use PHPUnit\DbUnit\Database\Connection;
use PHPUnit\DbUnit\DataSet\ArrayDataSet;
class MyGuestbookTest extends TestCase
{
use TestCaseTrait;
/**
* @return Connection
*/
public function getConnection()
{
$pdo = new PDO(
'mysql:host=localhost:3306;dbname=phpunit;charset=utf8mb4',
'root',
'Password01'
);
return $this->createDefaultDBConnection($pdo, 'phpunit');
}
/**
* @return ArrayDataSet
*/
public function getDataSet()
{
return new ArrayDataSet(
[
'guestbook' => [
[
'id' => 1,
'content' => 'Hello buddy!',
'user' => 'joe',
'created' => '2010-04-24 17:15:23',
],
[
'id' => 2,
'content' => 'I like it!',
'user' => 'mike',
'created' => '2010-04-26 12:14:20',
],
],
]
);
}
}
实现 getConnection()
创建数据库连接,并将此连接传递给 createDefaultDBConnection()
方法。
setUp()
中会调用 getConnection()
方法来获取创建的数据库连接
PHPUnit 数据库扩展模块用 PDO 库来实现跨供应商抽象访问数据库连接。PDO连接仅仅用于清理和建立基境,并不要求应用程序本身基于PDO。
实现 getDataSet()
getDataSet()
方法定义基境数据集。
setUp()
中会调用一次 getDataSet()
方法来接收基境数据集,随后用它来:
根据此数据集所指定的所有表名,将数据库中对应表内的行全部删除。
将数据集内数据表中的所有行写入数据库。
小建议:使用你自己的抽象数据库 TestCase 类
从前面的实现范例中容易发现 getConnection()
方法是相当稳定的,可以在不同的数据库测试用例中重用。另外,为了保持测试的性能良好和数据库的开销较低,可以对代码进行一点重构,来为应用程序形成一个通用的抽象测试用例,同时依然可以为每个具体测试用例指定不同的数据基境
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
abstract class MyApp_Tests_DatabaseTestCase extends TestCase
{
use TestCaseTrait;
// only instantiate pdo once for test clean-up/fixture load
static private $pdo = null;
// only instantiate PHPUnit_Extensions_Database_DB_IDatabaseConnection once per test
private $conn = null;
final public function getConnection()
{
if ($this->conn === null) {
if (self::$pdo == null) {
self::$pdo = new PDO(
'mysql:host=localhost:3306;dbname=phpunit;charset=utf8mb4',
'root',
'Password01'
);
}
$this->conn = $this->createDefaultDBConnection(self::$pdo, 'phpunit');
}
return $this->conn;
}
}
这个例子里,数据库连接信息硬编码在 PDO 连接里了。PHPUnit 有另外一个绝妙的特性,可以让这个 TestCase 类更加通用。通过 XML 配置 可以为每个测试单独配置数据库连接信息。首先,在应用程序的 tests/ 目录下创建 “phpunit.xml“ 文件,内容大体是这样
<?xml version="1.0" encoding="UTF-8" ?>
<phpunit>
<php>
<var name="DB_DSN" value="mysql:dbname=phpunit;host=localhost" />
<var name="DB_USER" value="root" />
<var name="DB_PASSWD" value="Password01" />
<var name="DB_DBNAME" value="phpunit" />
</php>
</phpunit>
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
abstract class Generic_Tests_DatabaseTestCase extends TestCase
{
use TestCaseTrait;
// only instantiate pdo once for test clean-up/fixture load
static private $pdo = null;
// only instantiate PHPUnit_Extensions_Database_DB_IDatabaseConnection once per test
private $conn = null;
final public function getConnection()
{
if ($this->conn === null) {
if (self::$pdo == null) {
self::$pdo = new PDO( $GLOBALS['DB_DSN'], $GLOBALS['DB_USER'], $GLOBALS['DB_PASSWD'] );
}
$this->conn = $this->createDefaultDBConnection(self::$pdo, $GLOBALS['DB_DBNAME']);
}
return $this->conn;
}
}
DataSet(数据集)和 DataTable(数据表)
DataSet(数据集)和 DataTable(数据表)是围绕着数据库表、行、列的抽象层(不一定是实际数据库的表)。DataSet(数据集)可能包括多张表,DataTable(数据表)是其子集。
通过一套简单的API,底层数据库内容被隐藏在对象结构之下,同时,这个对象结构也可以用其他非数据库数据源来实现,诸如 XML、 YAML、 CSV 文件或者 PHP 数组等方式来表达。
DataSet 和 DataTable 接口以语义相似的方式模拟关系数据库存储,从而能够对这些概念上完全不同的数据源进行比较。
在测试中,数据库断言的工作流由以下三个简单的步骤组成:
用表名称来指定数据库中的一个或多个表(实际上是指定了一个数据集)
用你喜欢的格式(YAML、XML等等)来指定预期数据集
断言这两个数据集陈述是彼此相等的。
有三种不同类型的数据集:
基于文件的 DataSet 和 DataTable
基于文件的数据集一般用于初始化基境或描述数据库的预期状态。
基于查询的 DataSet 和 DataTable
对于数据库断言,不仅需要有基于文件的 DataSet,同时也需要有一种内含数据库实际内容的基于查询/SQL 的 DataSet
筛选与组合 DataSet 和 DataTable
Array DataSet (数组数据集)
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
use PHPUnit\DbUnit\DataSet\ArrayDataSet;
class MyTestCase extends TestCase
{
use TestCaseTrait;
public function getConnection()
{
$pdo = new PDO(
'mysql:host=localhost:3306;dbname=phpunit;charset=utf8mb4',
'root',
'Password01'
);
return $this->createDefaultDBConnection($pdo, 'phpunit');
}
public function getDataSet()
{
return new ArrayDataSet(
[
'guestbook' => [
[
'id' => 1,
'content' => 'Hello buddy!',
'user' => 'joe',
'created' => '2010-04-24 17:15:23',
],
[
'id' => 2,
'content' => 'I like it!',
'user' => 'mike',
'created' => '2010-04-26 12:14:20',
],
],
]
);
}
}
Flat XML DataSet (平直 XML 数据集)
最常见的一种数据集名叫 Flat XML。这是一种非常简单的 XML 格式,根节点为 <dataset>,根节点下的每个标签就代表数据库中的一行数据。标签的名称就等于表名,而每个属性代表一个列。一个简单的留言本应用程序的例子大致上可能是这样
<dataset>
<guestbook id="1" content="Hello buddy!" user="joe" created="2010-04-24 17:15:23" />
<guestbook id="2" content="I like it!" created="2010-04-26 12:14:20" />
<user />
</dataset>
guestbook
是表名,这个表内有两行记录,每行有四个列:id
、content
、user
和created
,以及各自的值通过一个没有属性值的标签,以空表的名字作为标签名,来指定一个空表
user
在数据行的表述中省略掉对应的属性来表示 NULL 值
在这个例子里第二个条目是匿名发表的。但是这为列的识别带来了一个非常严重的问题。在数据集相等断言的判定过程中,每个数据集都需要指明每个表拥有哪些列。如果有一个列在数据表的所有行里其值都是 NULL,那么数据库扩展模块又该从何得知表中包含这个列呢
总的来说,建议只在不需要 NULL 值的情况下使用 Flat XML Dataset。
可以在数据库 TestCase 中调用 createFlatXmlDataSet($filename)
方法来创建 Flat XML Dataset 实例:
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
class MyTestCase extends TestCase
{
use TestCaseTrait;
public function getConnection()
{
$pdo = new PDO(
'mysql:host=localhost:3306;dbname=phpunit;charset=utf8mb4',
'root',
'Password01'
);
return $this->createDefaultDBConnection($pdo, 'phpunit');
}
public function getDataSet()
{
return $this->createFlatXmlDataSet('myFlatXmlFixture.xml');
}
}
XML DataSet (XML 数据集)
有另外一种更加结构化的 XML DataSet,它写起来有点冗长,但是规避了 Flat XML DataSet 所存在的 NULL 问题。在根节点 <dataset>
内,可以指定 <table>
、<column>
、 <row>
、<value>
和 <null />
标签。
<?xml version="1.0" ?>
<dataset>
<table name="guestbook">
<column>id</column>
<column>content</column>
<column>user</column>
<column>created</column>
<row>
<value>1</value>
<value>Hello buddy!</value>
<value>joe</value>
<value>2010-04-24 17:15:23</value>
</row>
<row>
<value>2</value>
<value>I like it!</value>
<null />
<value>2010-04-26 12:14:20</value>
</row>
</table>
</dataset>
所定义的每个 <table>
都有一个名称,并且必须有对所有列及其名称的定义。其下可以包含零个或任意正整数个 <row>
元素。没有定义 <row>
意味着这是个空表。<value>
和 <null />
标签必须按照之前给定 <column>
元素的顺序来指定。<null />
标签显然意味着这个值为 NULL
可以在数据库 TestCase 中调用 createXmlDataSet($filename) 方法来创建 XML DataSet 实例:
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
class MyTestCase extends TestCase
{
use TestCaseTrait;
public function getConnection()
{
$pdo = new PDO(
'mysql:host=localhost:3306;dbname=phpunit;charset=utf8mb4',
'root',
'Password01'
);
return $this->createDefaultDBConnection($pdo, 'phpunit');
}
public function getDataSet()
{
return $this->createXMLDataSet('myXmlFixture.xml');
}
}
MySQL XML DataSet (MySQL XML 数据集)
这种新的 XML 格式是 MySQL 数据库服务器专用的。PHPUnit 3.5 加入了对这种格式的支持。可以用 mysqldump
工具来生成这种格式的文件。与同样为 mysqldump 所支持的 CSV 数据集不同,这种 XML 格式可以在单个文件中包含多个表的数据。
要生成这种格式的文件,可以这样调用:
$ mysqldump --xml -t -u [username] --password=[password] [database] > /path/to/file.xml
可以在数据库 TestCase 中调用 createMySQLXMLDataSet($filename)
方法来使用这个文件:
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
class MyTestCase extends TestCase
{
use TestCaseTrait;
public function getConnection()
{
$pdo = new PDO(
'mysql:host=localhost:3306;dbname=phpunit;charset=utf8mb4',
'root',
'Password01'
);
return $this->createDefaultDBConnection($pdo, 'phpunit');
}
public function getDataSet()
{
return $this->createMySQLXMLDataSet('/path/to/file.xml');
}
}
YAML DataSet (YAML 数据集)
guestbook:
-
id: 1
content: "Hello buddy!"
user: "joe"
created: 2010-04-24 17:15:23
-
id: 2
content: "I like it!"
user:
created: 2010-04-26 12:14:20
简单方便,同时还解决了和它类似的 FLat XML DataSet 所具有的 NULL 问题。在 YAML 中,只有列名而没有指定值就表示 NULL。空白字符串则这样指定:column1: “”
目前,数据库 TestCase 中没有 YAML DataSet 的工厂方法,因此需要手工进行实例化:
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
use PHPUnit\DbUnit\DataSet\YamlDataSet;
class YamlGuestbookTest extends TestCase
{
use TestCaseTrait;
public function getConnection()
{
$pdo = new PDO(
'mysql:host=localhost:3306;dbname=phpunit;charset=utf8mb4',
'root',
'Password01'
);
return $this->createDefaultDBConnection($pdo, 'phpunit');
}
protected function getDataSet()
{
return new YamlDataSet("guestbook.yml");
}
}
CSV DataSet (CSV 数据集)
数据集中的每个表用一个单独的 CSV 文件表示。对于留言本的例子,可以这样定义 guestbook-table.csv 文件
id,content,user,created
1,"Hello buddy!","joe","2010-04-24 17:15:23"
2,"I like it!","nancy","2010-04-26 12:14:20"
CSV DataSet 中无法指定 NULL 值。给出一个空白列的结果是往这个列中插入数据库的默认空值
创建 CSV DataSet:
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
use PHPUnit\DbUnit\DataSet\CsvDataSet;
class CsvGuestbookTest extends TestCase
{
use TestCaseTrait;
public function getConnection()
{
$pdo = new PDO(
'mysql:host=localhost:3306;dbname=phpunit;charset=utf8mb4',
'root',
'Password01'
);
return $this->createDefaultDBConnection($pdo, 'phpunit');
}
protected function getDataSet()
{
$dataSet = new CsvDataSet();
$dataSet->addTable('guestbook', "guestbook.csv");
return $dataSet;
}
}
Query (SQL) DataSet (查询(SQL)数据集)
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
use PHPUnit\DbUnit\DataSet\QueryDataSet;
class QueryGuestbookTest extends TestCase
{
use TestCaseTrait;
public function getConnection()
{
$pdo = new PDO(
'mysql:host=localhost:3306;dbname=phpunit;charset=utf8mb4',
'root',
'Password01'
);
return $this->createDefaultDBConnection($pdo, 'phpunit');
}
protected function getDataSet()
{
$dataSet = new QueryDataSet($this->getConnection());
$dataSet->addTable('guestbook', 'SELECT id, content FROM guestbook ORDER BY created DESC');
return $dataSet;
}
}
Database (DB) Dataset (数据库数据集)
可以像 testGuestbook() 中那样创建整个数据库所对应的 DataSet,或者像 testFilteredGuestbook() 方法中那样用一个白名单来将 DataSet 限制在若干表名的集合上
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
class MySqlGuestbookTest extends TestCase
{
use TestCaseTrait;
public function getConnection()
{
//...
}
public function testGuestbook()
{
$dataSet = $this->getConnection()->createDataSet();
// ...
}
public function testFilteredGuestbook()
{
$tableNames = ['guestbook'];
$dataSet = $this->getConnection()->createDataSet($tableNames);
// ...
}
}
Replacement DataSet (替换数据集)
Replacement DataSet 是已有数据集的修饰器(decorator),能够将数据集中任意列的值替换为其他替代值
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
use PHPUnit\DbUnit\DataSet\ReplacementDataSet;
class ReplacementTest extends TestCase
{
use TestCaseTrait;
public function getDataSet()
{
$ds = $this->createFlatXmlDataSet('myFlatXmlFixture.xml');
$rds = new ReplacementDataSet($ds);
$rds->addFullReplacement('##NULL##', null);
$rds->addSubStrReplacement('##NULL##', null);
return $rds;
}
}
DataSet Filter (数据集筛选器)
如果有一个非常大的基境文件,可以用数据集筛选器来为需要包含在子数据集中的表和列指定白/黑名单。与 DB DataSet 联用来对数据集中的列进行筛选尤其方便
注意:不能对同一个表同时应用排除与包含两种列筛选器,只能分别应用于不同的表。
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
use PHPUnit\DbUnit\DataSet\Filter;
class DataSetFilterTest extends TestCase
{
use TestCaseTrait;
public function testIncludeFilteredGuestbook()
{
$dataSet = $this->getConnection()->createDataSet();
$filterDataSet = new Filter($dataSet);
$filterDataSet->addIncludeTables(['guestbook']);//包含的表
$filterDataSet->setIncludeColumnsForTable('guestbook', ['id', 'content']);//包含的列
// ..
}
public function testExcludeFilteredGuestbook()
{
$dataSet = $this->getConnection()->createDataSet();
$filterDataSet = new Filter($dataSet);
$filterDataSet->addExcludeTables(['foo', 'bar', 'baz']); //排除的表
$filterDataSet->setExcludeColumnsForTable('guestbook', ['user', 'created']);//排除的列
// ..
}
}
组合数据集
Composite DataSet 能将多个已存在的数据集聚合成单个数据集,因此非常有用。如果多个数据集中存在同样的表,其中的数据行将按照指定的顺序进行追加
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
use PHPUnit\DbUnit\DataSet\CompositeDataSet;
class CompositeTest extends TestCase
{
use TestCaseTrait;
public function getDataSet()
{
$ds1 = $this->createFlatXmlDataSet('fixture1.xml');
$ds2 = $this->createFlatXmlDataSet('fixture2.xml');
$compositeDs = new CompositeDataSet();
$compositeDs->addDataSet($ds1);
$compositeDs->addDataSet($ds2);
return $compositeDs;
}
}
数据库连接 API
由数据库的 getConnection() 方法所返回的连接接口有三个方法:
createDataSet()
方法创建一个在数据集实现一节描述过的 Database (DB) DataSet(数据库数据集)。createQueryTable()
方法用于创建 QueryTable 的实例,需要为其指定结果名称和所使用的 SQL 查询。当涉及到结果/表的断言时,这个方法会很方便。<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
class ConnectionTest extends TestCase
{
use TestCaseTrait;
public function testCreateQueryTable()
{
$queryTable = $this->getConnection()->createQueryTable('guestbook', 'SELECT * FROM guestbook');
}
}
getRowCount()
方法提供了一种方便的方式来取得表中的行数,并且还可以选择附加一个 WHERE 子句来在计数前对数据行进行过滤。它可以和一个简单的相等断言合用:
数据库断言
对表中数据行的数量做出断言
使用断言方法 assertEquals
:
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
class GuestbookTest extends TestCase
{
use TestCaseTrait;
public function getConnection()
{
//...
}
public function getDataSet()
{
//...
}
public function testRowCount()
{
$this->assertEquals(2, $this->getConnection()->getRowCount('guestbook'), "Pre-Condition");
}
}
对查询的结果作出断言
使用断言方法 assertTablesEqual()
:
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
class ComplexQueryTest extends TestCase
{
use TestCaseTrait;
public function testComplexQuery()
{
$queryTable = $this->getConnection()->createQueryTable(
'myComplexQuery', 'SELECT complexQuery...'
);
$expectedTable = $this->createFlatXmlDataSet("complexQueryAssertion.xml")
->getTable("myComplexQuery");
$this->assertTablesEqual($expectedTable, $queryTable);
}
}
对多个表的状态作出断言
使用断言方法 assertDataSetsEqual()
:
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\DbUnit\TestCaseTrait;
class DataSetAssertionsTest extends TestCase
{
use TestCaseTrait;
public function testCreateDataSetAssertion()
{
$dataSet = $this->getConnection()->createDataSet(['guestbook']);
$expectedDataSet = $this->createFlatXmlDataSet('guestbook.xml');
$this->assertDataSetsEqual($expectedDataSet, $dataSet);
}
}