framework.zend.com
Stable релиз 2.0 / 1.12

PHPUnit. Тестируем базу данных приложения на Zend Framework

К комментариям

В предыдущей статье я рассмотрел самый простой тест.
Сейчас я предлагаю перейти к более интересному и полезному делу - тестированию модели, работающей с таблицей базы данных приложения на Zend Framework.

Структура каталогов

Структура каталогов, описанная в первой статье, дополняется каталогом models, в котором будут располагаться файлы с тестами. В этом каталоге будет подкаталог fixtures, в нем я буду хранить данные для тестирования. Причем общие для всех тестов данные будут помещены непосредственно в этот каталог, а данные, специфичные для тестируемых классов будут располагаться в одноименных каталогах.

Cтруктура каталогов

project
|--lib
|
|--application
|--tests
|
|--application
| |--bootstrap.php
| |--ControllerTestCase.php
| |--DbTestCase.php
| |--XmlDataSet.php
| |
| |--models
| |--CategoryTest.php
| |--fixtures
| |--init.xml
| |--Model_DbTable_Category
| |--addCategory.xml
| |--delBeginCategory.xml
| |--delEndCategory.xml
| |--getCategory.xml
| |--updateBeginCategory.xml
| |--updateEndCategory.xml
|
|--phpunit.xml

В этом примере я рассмотрю тестирование модели таблицы tcategory. Напишу тесты для функций создания, изменения, удаления, получения категории.

Инфраструктурные файлы

Перейдем к рассмотрению файлов.
Файл bootstrap.php не претерпел принципиальных изменений, лишь добавлено подключение нескольких новых файлов.

Файл bootstrap.php

<?php 
error_reporting( E_ALL | E_STRICT );

date_default_timezone_set('Europe/Moscow');

define('BASE_PATH', realpath(dirname(__FILE__) . '/../../'));
define('APPLICATION_PATH', BASE_PATH . '/application');
define('CONFIG_PATH', APPLICATION_PATH . '/configs/application.ini');

set_include_path(
'.'
. PATH_SEPARATOR . BASE_PATH . '/library'
. PATH_SEPARATOR . get_include_path()
);

define('APPLICATION_ENV', 'testing');

require_once 'Zend/Application.php';
require_once 'ControllerTestCase.php';
require_once 'DbTestCase.php';
require_once 'XmlDataSet.php';

Файл phpunit.xml не изменился совсем.
По аналогии с ControllerTestCase создаю класс DbTestCase, от которого будут наследоваться все тесты.

Файл DbTestCase.php

<?php

require_once 'Zend/Test/PHPUnit/DatabaseTestCase.php';

abstract class DbTestCase extends Zend_Test_PHPUnit_DatabaseTestCase
{
protected $_db;
protected $_model;
protected $_modelClass;

protected $_fixturesDir;
protected $_filesDir;
protected $_initDataSet;

public function setUp()
{
$this->_fixturesDir = dirname(__FILE__).'/models/fixtures/';
$this->_filesDir = $this->_fixturesDir.$this->_modelClass.'/';
$this->_model = new $this->_modelClass($this->getAdapter());
parent::setUp();
}

protected function getTearDownOperation()
{
return PHPUnit_Extensions_Database_Operation_Factory::DELETE_ALL();
}

protected function getConnection()
{
if (empty($this->_db))
{
$vApplication = new Zend_Application(APPLICATION_ENV,CONFIG_PATH);
$vApplication->bootstrap();
$vOptions = $vApplication->getOptions();

$vConfig = new Zend_Config_Ini(CONFIG_PATH,'testing');
$vDbname = $vConfig->resources->db->params->dbname;

$vDb = $vApplication->getBootstrap()->getPluginResource('db')->getDbAdapter();
$this->_db = $this->createZendDbConnection($vDb, $vDbname);
}
return $this->_db;
}

protected function getDataSet($pFileName=null)
{
if ($pFileName===null)
{
$vFileName = $this->_fixturesDir.'init.xml';
}
else
{
$vFileName = $pFileName;
}
return $this->createXmlDataSet($vFileName);
}

protected function prepareInitData($pInitData)
{
$this->getDatabaseTester()->setDataSet($this->getDataSet($pInitData));
$this->getDatabaseTester()->onSetUp();
}
}

Рассмотрим файл более внимательно.
Функция setUp() запускается перед началом каждого теста (см. PHPUnit. Часть 04 Тестовые окружения (Fixtures)).
Ее предназначение подготовить тестовое окружение к работе: проинициализировать переменные, хранящие пути к файлам, и загрузить из xml-файла тестовые данные. Функция getTearDownOperation() определяет операцию, которая будет выполняться после каждого теста (см. PHPUnit. Часть 07 Тестирование базы данных). В данном случае будет выполняться очистка таблиц.
Таким образом, с помощью функций setUp() и getTearDownOperation() для каждого теста создается специальное тестового окружение, которое по завершении работы удаляется. Получается, что тесты работают со своими персональными данными и не влияют друг на друга.

Функция getConnection() устанавливает соединение с базой данных и не требует особых пояснений. Отмечу лишь то, что для тестирования создана специальная база, по структуре полностью повторяющая боевую, имя базы зачитывается из ini-файла, это типовой конфигурационный файл Zend Framework.

Функция protected function getDataSet($pFileName=null) - это реализация обязательно метода класса Zend_Test_PHPUnit_DatabaseTestCase, назначение метода - создать тестовые данные. Для всех тестов предполагается один файл с данными init.xml, однако тест может использовать и свой специфический файл.

Класс Zend_Test_PHPUnit_DatabaseTestCase - это Zend Framework заточенный наследник PHPUnit_Extensions_Database_TestCase.

Функция protected function prepareInitData($pInitData) выполняют установку первоначальных данные, если тесту требуются специальные условия.

Файл init.xml
В этом файле хранится конфигурация среды тестирования. Если тест не использует свой конфигурационный файл, то по умолчанию берется этот.

<?xml version="1.0" encoding="UTF-8"?>

<dataset>
<table name="tcategory">

<column>Id</column>
<column>Name</column>
<column>ParentId</column>
<column>Description</column>
<column>OrderNo</column>
<column>Level</column>
<column>FlagHasChildren</column>

</table>

</dataset>

Как видите, это простой xml-файл, который описывает структуру данных и собственно сами данные, более подробно о конфигурационных файлах написано здесь.
Данные не заданы, поэтому после загрузки этого файла из таблицы tcategory будут удалены все записи.

Класс тестирования

Переходим к тестам.

Файл CategoryTest.php
В этом файле находится основной класс.

<?php
require_once APPLICATION_PATH.'/models/DbTable/Category.php';

class CategoryTest extends DbTestCase
{
protected $_TableName = 'tcategory';

public function __construct()
{
$this->_modelClass = 'Model_DbTable_Category';
}

public function testaddCategory()
{
$vDataSet = new XmlDataSet($this->_filesDir.'addCategory.xml');

$this->_model->addCategory(
$vDataSet->getValue($this->_TableName,0,"Name"),
$vDataSet->getValue($this->_TableName,0,"ParentId"),
$vDataSet->getValue($this->_TableName,0,"Description"));

$vExpected = $this->createXmlDataSet($this->_filesDir.'addCategory.xml');
$vActual = new PHPUnit_Extensions_Database_DataSet_QueryDataSet($this->getConnection());
$vActual->addTable($this->_TableName);
$this->assertDataSetsEqual($vExpected, $vActual);
}

public function testgetCategory()
{
$this->prepareInitData($this->_filesDir.'getCategory.xml');
$vExpected = new XmlDataSet($this->_filesDir.'getCategory.xml');

$vActual = $this->_model->getCategory($vExpected->getValue($this->_TableName,0,"Id"));

$this->assertEquals($vActual["Id"],
$vExpected->getValue($this->_TableName,0,"Id"));
$this->assertEquals($vActual["Name"],
$vExpected->getValue($this->_TableName,0,"Name"));
$this->assertEquals($vActual["ParentId"],
$vExpected->getValue($this->_TableName,0,"ParentId"));
$this->assertEquals($vActual["Description"],
$vExpected->getValue($this->_TableName,0,"Description"));
}

public function testupdateCategory()
{
$this->prepareInitData($this->_filesDir.'updateBeginCategory.xml');

$vExpected = new XmlDataSet($this->_filesDir.'updateEndCategory.xml');

$this->_model->updateCategory(
$vExpected->getValue($this->_TableName,0,"Id"),
$vExpected->getValue($this->_TableName,0,"Name"),
$vExpected->getValue($this->_TableName,0,"ParentId"),
$vExpected->getValue($this->_TableName,0,"Description"));

$vActual = new PHPUnit_Extensions_Database_DataSet_QueryDataSet($this->getConnection());
$vActual->addTable($this->_TableName);
$this->assertDataSetsEqual($this->createXmlDataSet($this->_filesDir.'updateEndCategory.xml'),
$vActual);
}

public function testdelCategory()
{
$this->prepareInitData($this->_filesDir.'delBeginCategory.xml');
$vExpected = new XmlDataSet($this->_filesDir.'delBeginCategory.xml');

$this->_model->delCategory($vExpected->getValue($this->_TableName,0,"Id"));

$vActual = new PHPUnit_Extensions_Database_DataSet_QueryDataSet($this->getConnection());
$vActual->addTable($this->_TableName);
$this->assertDataSetsEqual($this->createXmlDataSet($this->_filesDir.'delEndCategory.xml'),
$vActual);
}
}

Обратите внимание на наименования тестовых методов. Наименование образуется так: test+тестируемый метод (см. PHPUnit. Часть 03 Написание тестов для PHPUnit).
Тестируем класс Model_DbTable_Category, поэтому разумно все конфигурационные файлы поместить в каталог fixtures/Model_DbTable_Category.
Для функций создания и получения категории используется типовой набор первоначальных данных init.xml. А вот для функций удаления и изменения яребуются специальные стартовые условия. 

Функция testaddCategory()

Это тест функции создания категории. Смысл функции заключается в создании категории с заранее заданными параметрами, после чего состояние базы, т.е. тестируемой таблицы сравнивается с заранее известным. Если состояния совпали, значит функция отработала как надо.
Для обращения к параметрам XML-файла я использую свой класс XmlDataSet, вот его код.

Файл XmlDataSet.php

<?php

require_once 'PHPUnit/Extensions/Database/DataSet/XmlDataSet.php';

class XmlDataSet extends PHPUnit_Extensions_Database_DataSet_XmlDataSet
{
private $_DataSet;

public function __construct($pXmlFile)
{
$this->_DataSet = new PHPUnit_Extensions_Database_DataSet_XmlDataSet($pXmlFile);
}

public function getValue($pTableName, $pRowIndex, $pColumnName)
{
$vTableColumns = array();
$vTableValues = array();
$this->_DataSet->getTableInfo($vTableColumns, $vTableValues);

return $vTableValues[$pTableName][$pRowIndex][$pColumnName];
}
}

Подозреваю, что есть и более правильный способ, если знаете - подскажите.

Класс XmlDataSet унаследован от PHPUnit_Extensions_Database_DataSet_XmlDataSet и нужен чтобы исправить непонятную особенность - недоступность метода getTableInfo. Функция getValue нужна для упрощения извлечения данных из xml-файлов.

    А теперь как работает тест:
  • PHPUnit запускает функцию setUp(), переопределенную в DbTestCase, эта функция из файла init.xml зачитывает инициализационные данные для таблицы, т.е. по сути очищает эту таблицу.
  • Запускается сам тест testaddCategory, тест создает категорию и проверяет, что получилось в результате.
  • После завершения теста, независимо от результата выполняется функция tearDown(), в DbTestCase я не стал ее переопределять как setUp(),т.к. в этом нет смысла. tearDown() вызывает функцию getTearDownOperation(), которая переопределена в DbTestCase, эта функция очищает все результаты работы теста.

Таким образом, после выполнения testaddCategory все готово для выполнения других тестов, т.к. база данных находится в первоначальном состоянии.
Изолированность тестов имеет множество плюсов: результат не зависит от последовательности выполнения, можно смело менять тестовые данные любого теста и не бояться повлиять на выполнение других.

Принцип работы других тестов аналогичен тесту создания категории: создается среда тестирования, выполняется функция тестируемой модели, полученный результат сравнивается с эталоном, тестовая среда возвращается к первоначальному состоянию.

Конфигурационные файлы

Применение конфигурационных файлов позволяет вносить изменения в тесты, не исправляя исходные тексты этих тестов. Это делает возможным разделение работы между программистом - автором тестов и тестировщиком. Тестировщику, чтобы изменить данные, не надо лазить по исходным текстам.

XML файлы выбраны для хранения тестовых данных не случайно (о других возможностях см. PHPUnit. Часть 07 Тестирование базы данных). XML позволяет в очень удобном виде описывать структуры данных и сами данные.

Файл addCategory.xml
Эталонные данные для тестирования создания категории.

<?xml version="1.0" encoding="UTF-8"?>

<dataset>
<table name="tcategory">

<column>Id</column>
<column>Name</column>
<column>ParentId</column>
<column>Description</column>
<column>OrderNo</column>
<column>Level</column>
<column>FlagHasChildren</column>

<row>
<value>1</value>
<value>addCategory</value>
<null/>
<value>add Category</value>
<null/>
<null/>
<null/>
</row>

</table>

</dataset>

Файл delBeginCategory.xml
Первоначальные данные для тестирования удаления категории.

<?xml version="1.0" encoding="UTF-8"?>

<dataset>
<table name="tcategory">

<column>Id</column>
<column>Name</column>
<column>ParentId</column>
<column>Description</column>
<column>OrderNo</column>
<column>Level</column>
<column>FlagHasChildren</column>

<row>
<value>1</value>
<value>delCategory</value>
<null/>
<value>del Category</value>
<null/>
<null/>
<null/>
</row>

<row>
<value>2</value>
<value>CategoryAfterDel</value>
<null/>
<value>Category After Del</value>
<null/>
<null/>
<null/>
</row>

</table>

</dataset>

Файл delEndCategory.xml
Эталонные данные для тестирования удаления категории. Как видите, категория с ID=1 должна быть удалена.

<?xml version="1.0" encoding="UTF-8"?>

<dataset>
<table name="tcategory">

<column>Id</column>
<column>Name</column>
<column>ParentId</column>
<column>Description</column>
<column>OrderNo</column>
<column>Level</column>
<column>FlagHasChildren</column>

<row>
<value>2</value>
<value>CategoryAfterDel</value>
<null/>
<value>Category After Del</value>
<null/>
<null/>
<null/>
</row>

</table>

</dataset>

Файл getCategory.xml
Эталонные данные для тестирования функции получения категории.

<?xml version="1.0" encoding="UTF-8"?>

<dataset>
<table name="tcategory">

<column>Id</column>
<column>Name</column>
<column>ParentId</column>
<column>Description</column>
<column>OrderNo</column>
<column>Level</column>
<column>FlagHasChildren</column>

<row>
<value>1</value>
<value>getCategory</value>
<null/>
<value>get Category</value>
<null/>
<null/>
<null/>
</row>

</table>

</dataset>

Файл updateBeginCategory.xml
Первоначальные данные для тестирования изменения категории.

<?xml version="1.0" encoding="UTF-8"?>

<dataset>
<table name="tcategory">

<column>Id</column>
<column>Name</column>
<column>ParentId</column>
<column>Description</column>
<column>OrderNo</column>
<column>Level</column>
<column>FlagHasChildren</column>

<row>
<value>1</value>
<value>updateCategory</value>
<null/>
<value>update Category</value>
<null/>
<null/>
<null/>
</row>

</table>

</dataset>

Файл updateEndCategory.xml
Эталонные данные для тестирования изменения категории.

<?xml version="1.0" encoding="UTF-8"?>

<dataset>
<table name="tcategory">

<column>Id</column>
<column>Name</column>
<column>ParentId</column>
<column>Description</column>
<column>OrderNo</column>
<column>Level</column>
<column>FlagHasChildren</column>

<row>
<value>1</value>
<value>updated</value>
<null/>
<value>updated successfully</value>
<null/>
<null/>
<null/>
</row>

</table>

</dataset>

Заключение

В этой статье я постарался как можно более подробно осветить основы написания тестов для тестирования моделей базы данных приложений на Zend Framework. Если что-то осталось непонятным, пишите - постараюсь дополнительно пояснить.

метки: PHPUnit, Zend_Test
Лучший способ следить за обновлениями сайта это подписаться на RSS
Если информация была полезной для вас, вы можете поддержать сайт.
Комментарии:
yriel 01.05.2010 20:49 #
Какраз искал недавно, очень грамотная статья.
Ответить
Maxim 20.09.2010 23:16 #
Жаль, что нет общей теории относительно тестирования моделей. Как это часто бывает в статьях про тестирование, примеры высосаны из пальца. Расскажите о пользе тестирования (именно моделей), о недостатках. Я понимаю, что статья была о другом. Напишите еще одну!
Ответить
Уважаемые пользователи. Комментарии не для того чтобы:
  1. Спрашивать почему у вас не работает код, для этого есть тема форума закрепленная за статьей.
  2. Спрашивать как реализовать ту или иную функциональность, для этого необходимо создать свою тему на форуме.

Комментарии для того чтобы: высказать свое аргументированное мнение о статье, указать какие участки вызывают непонимание, что нужно исправить/улучшить, просто сказать спасибо.

Комментарии имеют древовидную структуру.
Если вы хотите ответить на определенный комментарий - нажмите на ссылку "Ответить" возле этого комментария.

Комментарии не соответствующие этим правилам могут быть удалены. Спасибо за понимание.
Комментарии временно отключены, вы можете воспользоваться форумом.