# 理解 DataSet(数据集)和 DataTable(数据表)
PHPUnit 的数据库扩展模块的核心概念是 DataSet(数据集)和 DataTable(数据表)。为了掌握如何使用 PHPUnit 进行测试,需要试着去了解这些简单的概念。DataSet(数据集)和 DataTable(数据表)是围绕着数据库表、行、列的抽象层。通过一套简单的API,底层数据库内容被隐藏在对象结构之下,同时,这个对象结构也可以用其他非数据库数据源来实现。
为了能比较实际内容和预期内容,这种抽象是必须的。预期内容可以用诸如 XML、 YAML、 CSV 文件或者 PHP 数组等方式来表达。DataSet 和 DataTable 接口以语义相似的方式模拟关系数据库存储,从而能够对这些概念上完全不同的数据源进行比较。
在测试中,数据库断言的工作流由以下三个简单的步骤组成:
-
用表名称来指定数据库中的一个或多个表(实际上是指定了一个数据集)
-
用你喜欢的格式(YAML、XML等等)来指定预期数据集
-
断言这两个数据集陈述是彼此相等的。
在 PHPUnit 的数据库扩展中,断言并非唯一使用 DataSet 和 DataTable 的情形。就像上一节中所展示的那样,它们也用于描述数据库的初始内容。数据库 TestCase 类强制要求定义一个基境数据集,随后用它来:
-
根据此数据集所指定的所有表名,将数据库中对应表内的行全部删除。
-
将数据集内数据表中的所有行写入数据库。
### 可用的各种实现
有三种不同类型的 DataSet/DataTable:
-
基于文件的 DataSet 和 DataTable
-
基于查询的 DataSet 和 DataTable
-
筛选与组合 DataSet 和 DataTable
基于文件的数据集和表一般用于初始化基境或描述数据库的预期状态。
### Flat XML DataSet (平直 XML 数据集)
最常见的一种数据集名叫 Flat XML。这是一种非常简单的 XML 格式,根节点为 `<dataset>`,根节点下的每个标签就代表数据库中的一行数据。标签的名称就等于表名,而每个属性代表一个列。一个简单的留言本应用程序的例子大致上可能是这样:
~~~
<?xml version="1.0" ?>
<dataset>
<guestbook id="1" content="Hello buddy!" user="joe" created="2010-04-24 17:15:23" />
<guestbook id="2" content="I like it!" user="nancy" created="2010-04-26 12:14:20" />
</dataset>
~~~
显然,这非常易于编写。在这里,`<guestbook>` 是表名,这个表内有两行记录,每行有四个列:“id”、“content”、“user” 和 “created”,以及各自的值。
不过,这种简单性是有代价的。
从上面这个例子里不太容易看出该如何指定一个空表。其实可以插入一个没有属性值的标签,以空表的名字作为标签名。空的 guestbook 表所对应的 Flat XML 文件大致上可能是这样:
~~~
<?xml version="1.0" ?>
<dataset>
<guestbook />
</dataset>
~~~
在 Flat XML DataSet 中,要处理 NULL 值会非常烦。在几乎所有数据库中(Oracle 是个例外),NULL 值和空字符串值是有区别的,这一点在 Flat XML 格式中很难表述。可以在数据行的表述中省略掉对应的属性来表示NULL值。假定上面这个留言本通过在 user 列使用 NULL 值的方式来允许匿名留言,那么 guestbook 表的内容可能是这样:
~~~
<?xml version="1.0" ?>
<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" />
</dataset>
~~~
在这个例子里第二个条目是匿名发表的。但是这为列的识别带来了一个非常严重的问题。在数据集相等断言的判定过程中,每个数据集都需要指明每个表拥有哪些列。如果有一个列在数据表的所有行里其值都是 NULL,那么数据库扩展模块又该从何得知表中包含这个列呢?
在这里,Flat XML DataSet 做了一个关键假设:一个表的列信息由此表第一行的属性定义决定。在上面这个例子里,这意味着 guestbook 有 “id”、“content”、“user” 和 “created” 这几个列。第二行中 “user” 列没有定义,因此将向数据库中插入 NULL 值。
如果从数据集中删掉第一行,因为没有指定 “user”,guestbook 表拥有的列就只剩下 “id”、“content” 和 “created”。
要在有 NULL 值的情况下有效地使用 Flat XML Dataset,就必须保证每个表的第一行不包含 NULL 值,只有后继的那些行才能省略属性。这就有点棘手,因为数据行的排列顺序也是数据断言的一个相关因素。
反过来,如果在 Flat XML Dataset 中只指明了实际表中所有列的某个子集,那么所有省略掉的列都会设为它们的的默认值。如果某个省略掉的列的定义是 “NOT NULL DEFAULT NULL”,就会出现错误。
总的来说,建议只在不需要 NULL 值的情况下使用 Flat XML Dataset。
可以在数据库 TestCase 中调用 `createFlatXmlDataSet($filename)` 方法来创建 Flat XML Dataset 实例:
~~~
<?php
class MyTestCase extends PHPUnit_Extensions_Database_TestCase
{
public function getDataSet()
{
return $this->createFlatXmlDataSet('myFlatXmlFixture.xml');
}
}
?>
~~~
### XML DataSet (XML 数据集)
有另外一种更加结构化的 XML DataSet,它写起来有点冗长,但是规避了 Flat XML DataSet 所存在的 NULL 问题。在根节点 `<dataset>` 内,可以指定 `<table>`、`<column>`、 `<row>`、`<value>` 和 `<null />` 标签。和上面用 Flat XML 所定义的留言本数据集等价的 XML DataSet 如下:
~~~
<?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
class MyTestCase extends PHPUnit_Extensions_Database_TestCase
{
public function getDataSet()
{
return $this->createXMLDataSet('myXmlFixture.xml');
}
}
?>
~~~
### MySQL XML DataSet (MySQL XML 数据集)
这种新的 XML 格式是 [MySQL 数据库服务器](http://www.mysql.com)专用的。PHPUnit 3.5 加入了对这种格式的支持。可以用 [`mysqldump`](http://dev.mysql.com/doc/refman/5.0/en/mysqldump.html) 工具来生成这种格式的文件。与同样为 `mysqldump` 所支持的 CSV 数据集不同,这种 XML 格式可以在单个文件中包含多个表的数据。要生成这种格式的文件,可以这样调用 `mysqldump`:
~~~
mysqldump --xml -t -u [username] --password=[password] [database] > /path/to/file.xml
~~~
可以在数据库 TestCase 中调用 `createMySQLXMLDataSet($filename)` 方法来使用这个文件:
~~~
<?php
class MyTestCase extends PHPUnit_Extensions_Database_TestCase
{
public function getDataSet()
{
return $this->createMySQLXMLDataSet('/path/to/file.xml');
}
}
?>
~~~
### YAML DataSet (YAML 数据集)
也可以用 YAML DataSet 来写这个留言本的例子:
~~~
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
class YamlGuestbookTest extends PHPUnit_Extensions_Database_TestCase
{
protected function getDataSet()
{
return new PHPUnit_Extensions_Database_DataSet_YamlDataSet(
dirname(__FILE__)."/_files/guestbook.yml"
);
}
}
?>
~~~
### CSV DataSet (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"
~~~
用 Excel 或者 OpenOffice 来对这种格式进行编辑是非常方便的,但是在 CSV DataSet 中无法指定 NULL 值。给出一个空白列的结果是往这个列中插入数据库的默认空值。
可以这样创建 CSV DataSet:
~~~
<?php
class CsvGuestbookTest extends PHPUnit_Extensions_Database_TestCase
{
protected function getDataSet()
{
$dataSet = new PHPUnit_Extensions_Database_DataSet_CsvDataSet();
$dataSet->addTable('guestbook', dirname(__FILE__)."/_files/guestbook.csv");
return $dataSet;
}
}
?>
~~~
### Array DataSe (数组数据集)
在 PHPUnit 的数据库扩展中,(尚)没有基于数组的 DataSet,不过很容易自行实现之。留言本的例子大致是这样:
~~~
<?php
class ArrayGuestbookTest extends PHPUnit_Extensions_Database_TestCase
{
protected function getDataSet()
{
return new MyApp_DbUnit_ArrayDataSet(array(
'guestbook' => array(
array('id' => 1, 'content' => 'Hello buddy!', 'user' => 'joe', 'created' => '2010-04-24 17:15:23'),
array('id' => 2, 'content' => 'I like it!', 'user' => null, 'created' => '2010-04-26 12:14:20'),
),
));
}
}
?>
~~~
PHP 版本的 DataSet 相比于所有其他基于文件的 DataSet 相比有很明显的优点:
-
PHP 数组显然可以处理 `NULL` 值。
-
不需要为断言提供任何额外文件,可以直接在 TestCase 中指定。
对于这种 DataSet 而言,和平直 XML、CSV、YAML DataSet 一样,表的列名信息由第一个指定的行的键名定义。在上面这个例子里,就是 “id”、“content”、“user” 和 “created”。
这个数组 DataSet 类的实现是非常简单直接的:
~~~
<?php
class MyApp_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];
}
}
?>
~~~
### Query (SQL) DataSet (查询(SQL)数据集)
对于数据库断言,不仅需要有基于文件的 DataSet,同时也需要有一种内含数据库实际内容的基于查询/SQL 的 DataSet。Query DataSet 在此闪亮登场:
~~~
<?php
$ds = new PHPUnit_Extensions_Database_DataSet_QueryDataSet($this->getConnection());
$ds->addTable('guestbook');
?>
~~~
单纯以名称来添加表是一种隐式地用以下查询来定义 DataTable 的方法:
~~~
<?php
$ds = new PHPUnit_Extensions_Database_DataSet_QueryDataSet($this->getConnection());
$ds->addTable('guestbook', 'SELECT * FROM guestbook');
?>
~~~
可以在这种用法中为你的表任意指定查询,例如限定行、列,或者加上 `ORDER BY` 子句:
~~~
<?php
$ds = new PHPUnit_Extensions_Database_DataSet_QueryDataSet($this->getConnection());
$ds->addTable('guestbook', 'SELECT id, content FROM guestbook ORDER BY created DESC');
?>
~~~
在关于数据库断言的那一节中有更多关于如何使用 Query DataSet 的细节。
### Database (DB) Dataset (数据库数据集)
通过访问测试所使用的数据库连接,可以自动创建包含数据库所有表以及其内容的 DataSet。所使用的数据库由数据库连接工厂方法的第二个参数指定。
可以像 `testGuestbook()` 中那样创建整个数据库所对应的 DataSet,或者像 `testFilteredGuestbook()` 方法中那样用一个白名单来将 DataSet 限制在若干表名的集合上。
~~~
<?php
class MySqlGuestbookTest extends PHPUnit_Extensions_Database_TestCase
{
/**
* @return PHPUnit_Extensions_Database_DB_IDatabaseConnection
*/
public function getConnection()
{
$database = 'my_database';
$user = 'my_user';
$password = 'my_password';
$pdo = new PDO('mysql:...', $user, $password);
return $this->createDefaultDBConnection($pdo, $database);
}
public function testGuestbook()
{
$dataSet = $this->getConnection()->createDataSet();
// ...
}
public function testFilteredGuestbook()
{
$tableNames = array('guestbook');
$dataSet = $this->getConnection()->createDataSet($tableNames);
// ...
}
}
?>
~~~
### Replacement DataSet (替换数据集)
前面谈到了 Flat XML 和 CSV DataSet 所存在的 NULL 问题,不过有一种稍微有点复杂的解决方法可以让这两种数据集都能正常处理 NULL。
Replacement DataSet 是已有数据集的修饰器(decorator),能够将数据集中任意列的值替换为其他替代值。为了让留言本的例子能够处理 NULL 值,首先指定类似这样的文件:
~~~
<?xml version="1.0" ?>
<dataset>
<guestbook id="1" content="Hello buddy!" user="joe" created="2010-04-24 17:15:23" />
<guestbook id="2" content="I like it!" user="##NULL##" created="2010-04-26 12:14:20" />
</dataset>
~~~
然后将 Flat XML DataSet 包装在 Replacement DataSet 中:
~~~
<?php
class ReplacementTest extends PHPUnit_Extensions_Database_TestCase
{
public function getDataSet()
{
$ds = $this->createFlatXmlDataSet('myFlatXmlFixture.xml');
$rds = new PHPUnit_Extensions_Database_DataSet_ReplacementDataSet($ds);
$rds->addFullReplacement('##NULL##', null);
return $rds;
}
}
?>
~~~
### DataSet Filter (数据集筛选器)
如果有一个非常大的基境文件,可以用数据集筛选器来为需要包含在子数据集中的表和列指定白/黑名单。与 DB DataSet 联用来对数据集中的列进行筛选尤其方便。
~~~
<?php
class DataSetFilterTest extends PHPUnit_Extensions_Database_TestCase
{
public function testIncludeFilteredGuestbook()
{
$tableNames = array('guestbook');
$dataSet = $this->getConnection()->createDataSet();
$filterDataSet = new PHPUnit_Extensions_Database_DataSet_DataSetFilter($dataSet);
$filterDataSet->addIncludeTables(array('guestbook'));
$filterDataSet->setIncludeColumnsForTable('guestbook', array('id', 'content'));
// ..
}
public function testExcludeFilteredGuestbook()
{
$tableNames = array('guestbook');
$dataSet = $this->getConnection()->createDataSet();
$filterDataSet = new PHPUnit_Extensions_Database_DataSet_DataSetFilter($dataSet);
$filterDataSet->addExcludeTables(array('foo', 'bar', 'baz')); // only keep the guestbook table!
$filterDataSet->setExcludeColumnsForTable('guestbook', array('user', 'created'));
// ..
}
}
?>
~~~
> **注意:**不能对同一个表同时应用排除与包含两种列筛选器,只能分别应用于不同的表。另外,表的白名单和黑名单也只能选择其一,不能二者同时使用。
### Composite DataSet (组合数据集)
Composite DataSet 能将多个已存在的数据集聚合成单个数据集,因此非常有用。如果多个数据集中存在同样的表,其中的数据行将按照指定的顺序进行追加。例如,假设有两个数据集, *fixture1.xml*:
~~~
<?xml version="1.0" ?>
<dataset>
<guestbook id="1" content="Hello buddy!" user="joe" created="2010-04-24 17:15:23" />
</dataset>
~~~
和 *fixture2.xml*:
~~~
<?xml version="1.0" ?>
<dataset>
<guestbook id="2" content="I like it!" user="##NULL##" created="2010-04-26 12:14:20" />
</dataset>
~~~
通过 Composite DataSet 可以把这两个基境文件聚合在一起:
~~~
<?php
class CompositeTest extends PHPUnit_Extensions_Database_TestCase
{
public function getDataSet()
{
$ds1 = $this->createFlatXmlDataSet('fixture1.xml');
$ds2 = $this->createFlatXmlDataSet('fixture2.xml');
$compositeDs = new PHPUnit_Extensions_Database_DataSet_CompositeDataSet();
$compositeDs->addDataSet($ds1);
$compositeDs->addDataSet($ds2);
return $compositeDs;
}
}
?>
~~~
### 当心外键
在建立基境的过程中, PHPUnit 的数据库扩展模块按照基境中所指定的顺序将数据行插入到数据库内。假如数据库中使用了外键,这就意味着必须指定好表的顺序,以避免外键约束失败。
### 实现自有的 DataSet/DataTable
为了理解 DataSet 和 DataTable 的内部实现,让我们来看看 DataSet 的接口。如果没打算自行实现 DataSet 或者 DataTable,可以直接跳过这一部分。
~~~
<?php
interface PHPUnit_Extensions_Database_DataSet_IDataSet extends IteratorAggregate
{
public function getTableNames();
public function getTableMetaData($tableName);
public function getTable($tableName);
public function assertEquals(PHPUnit_Extensions_Database_DataSet_IDataSet $other);
public function getReverseIterator();
}
?>
~~~
这些 public 接口在数据库 TestCase 中 `assertDataSetsEqual()` 断言内使用,用以检测数据集是否相等。IDataSet 中继承自 `IteratorAggregate` 接口的 `getIterator()` 方法用于对数据集中的所有表进行迭代。逆序迭代器让 PHPUnit 能够按照与创建时相反的顺序对所有表执行 TRUNCATE 操作,以此来保证满足外键约束。
根据具体实现的不同,要采取不同的方法来将表实例添加到数据集中。例如,在所有基于文件的数据集中,表都是在构造过程中直接从源文件生成并加入数据集中,比如 `YamlDataSet`、`XmlDataSet` 和 `FlatXmlDataSet`均是如此。
数据表则由以下接口表示:
~~~
<?php
interface PHPUnit_Extensions_Database_DataSet_ITable
{
public function getTableMetaData();
public function getRowCount();
public function getValue($row, $column);
public function getRow($row);
public function assertEquals(PHPUnit_Extensions_Database_DataSet_ITable $other);
}
?>
~~~
除了 `getTableMetaData()` 方法之外,这个接口是一目了然的。数据库扩展模块中的各种断言(将于下一章中介绍)用到了所有这些方法,因此它们全部都是必需的。`getTableMetaData()` 方法需要返回一个实现了 `PHPUnit_Extensions_Database_DataSet_ITableMetaData` 接口的描述表结构的对象。这个对象包含如下信息:
-
表的名称
-
表的列名数组,按照列在结果集中出现的顺序排列。
-
构成主键的列的数组。
这个接口还包含有检验两个表的元数据实例是否彼此相等的断言,供数据集相等断言使用。
- PHPUnit 手册
- 1. 安装 PHPUnit
- 需求
- PHP 档案包 (PHAR)
- Composer
- 可选的组件包
- 2. 编写 PHPUnit 测试
- 测试的依赖关系
- 数据供给器
- 对异常进行测试
- 对 PHP 错误进行测试
- 对输出进行测试
- 错误相关信息的输出
- 3. 命令行测试执行器
- 命令行选项
- 4. 基境(fixture)
- setUp() 多 tearDown() 少
- 变体
- 基境共享
- 全局状态
- 5. 组织测试
- 用文件系统来编排测试套件
- 用 XML 配置来编排测试套件
- 6. 有风险的测试
- 无用测试
- 意外的代码覆盖
- 测试执行期间产生的输出
- 测试执行时长的超时限制
- 全局状态篡改
- 7. 未完成的测试与跳过的测试
- 未完成的测试
- 跳过测试
- 用 @requires 来跳过测试
- 8. 数据库测试
- 数据库测试所支持的供应商
- 数据库测试的难点
- 数据库测试的四个阶段
- PHPUnit 数据库测试用例的配置
- 理解 DataSet(数据集)和 DataTable(数据表)
- 数据库连接 API
- 数据库断言 API
- 常见问题(FAQ)
- 9. 测试替身
- Stubs (桩件)
- 仿件对象(Mock Object)
- Prophecy
- 对特质(Trait)与抽象类进行模仿
- 对 Web 服务(Web Services)进行上桩或模仿
- 对文件系统进行模仿
- 10. 测试实践
- 在开发过程中
- 在调试过程中
- 11. 代码覆盖率分析
- 用于代码覆盖率的软件衡量标准
- 包含与排除文件
- 略过代码块
- 指明要覆盖的方法
- 边缘情况
- 12. 测试的其他用途
- 敏捷文档
- 跨团队测试
- 13. Logging (日志记录)
- 测试结果 (XML)
- 测试结果 (TAP)
- 测试结果 (JSON)
- 代码覆盖率 (XML)
- 代码覆盖率 (TEXT)
- 14. 扩展 PHPUnit
- 从 PHPUnit_Framework_TestCase 派生子类
- 编写自定义断言
- 实现 PHPUnit_Framework_TestListener
- 从 PHPUnit_Extensions_TestDecorator 派生子类
- 实现 PHPUnit_Framework_Test
- A. 断言
- assertArrayHasKey()
- assertClassHasAttribute()
- assertArraySubset()
- assertClassHasStaticAttribute()
- assertContains()
- assertContainsOnly()
- assertContainsOnlyInstancesOf()
- assertCount()
- assertEmpty()
- assertEqualXMLStructure()
- assertEquals()
- assertFalse()
- assertFileEquals()
- assertFileExists()
- assertGreaterThan()
- assertGreaterThanOrEqual()
- assertInfinite()
- assertInstanceOf()
- assertInternalType()
- assertJsonFileEqualsJsonFile()
- assertJsonStringEqualsJsonFile()
- assertJsonStringEqualsJsonString()
- assertLessThan()
- assertLessThanOrEqual()
- assertNan()
- assertNull()
- assertObjectHasAttribute()
- assertRegExp()
- assertStringMatchesFormat()
- assertStringMatchesFormatFile()
- assertSame()
- assertStringEndsWith()
- assertStringEqualsFile()
- assertStringStartsWith()
- assertThat()
- assertTrue()
- assertXmlFileEqualsXmlFile()
- assertXmlStringEqualsXmlFile()
- assertXmlStringEqualsXmlString()
- B. 标注
- @author
- @after
- @afterClass
- @backupGlobals
- @backupStaticAttributes
- @before
- @beforeClass
- @codeCoverageIgnore*
- @covers
- @coversDefaultClass
- @coversNothing
- @dataProvider
- @depends
- @expectedException
- @expectedExceptionCode
- @expectedExceptionMessage
- @expectedExceptionMessageRegExp
- @group
- @large
- @medium
- @preserveGlobalState
- @requires
- @runTestsInSeparateProcesses
- @runInSeparateProcess
- @small
- @test
- @testdox
- @ticket
- @uses
- C. XML 配置文件
- PHPUnit
- 测试套件
- 分组
- 为代码覆盖率包含或排除文件
- Logging (日志记录)
- 测试监听器
- 设定 PHP INI 设置、常量、全局变量
- 为 Selenium RC 配置浏览器
- D. 升级
- E. 索引
- F. 参考书目
- G. 版权