Тестировать или нет классы, взаимодействующие с Базой данных - вопрос куда более холиварный, чем спор "покрывать код тестами или нет". Просмотрев свой проект, нам стало очевидно, что львиная доля классов основана на взаимодействии с базой данных. Поэтому было однозначно решено: "тестированию быть".
Далее я хочу поделится опытом написания модульных тестов для кода, работающего с базой данных.
PHPUnit содержит расширение для тестирования базы данных. Расширение выполняет следующие функции:
- перевод базы данных в заранее известное состояние,
- выполнение необходимых модификаций данных,
- проверка, что в базе данных созданы ожидаемые записи.
К сожалению в этом списке отсутствует одна очень нужная нам фича - восстановление данных в состояние, в котором они находились до тестов. Я хочу предложить 2 варианта решения этой проблемы. Так же попутно остановлюсь на проблеме внешних ключей, которая часто возникает при накатывании не полных тестовых данных на "рабочую" базу.
Итак, я предлагаю 2 варианта решения проблемы: как после проведения юнит теста вернуть базу данных в исходное состояние:
Первый путь - "Транзакционный". Суть которого сводится к выполнению всего теста в рамках одной транзакции, и последующему rollback'у транзакции.
Второй путь - Перед выполнением теста скопировать структуру "рабочей" базы и проводить тестирование на ней.
Реализация "транзакционного" пути
Фактически, нам необходимо заключить в транзакцию все операции с базой данных, которые производит Unit тест, и в последствии ее откатить.
Задача сводится к использованию всеми тестами единого подключения к БД и заключению всех операций с базой данных в единую транзакцию. Инкапсулируем подключение к базе данных в отдельном классе, от которого будут наследоваться все Unit тесты. Сам же класс будет являться потомком PHPUnit_Extensions_Database_TestCase.
Используя расширение DBUnit следует переопределить метод getConnection, который, как следует из названия, отвечает за получение ссылки на соединение с базой данных. Обращаю внимание, что данный метод не должен каждый раз создавать новое подключение к базе данных, а должен только возвращать ссылку на созданное подключение.
Следующий код не допустим, т. к. транзакции "живут" только в рамках одного подключения.
public function getConnection()
{
$pdo = new PDO("mysql:host=localhost;dbname=dbname", 'root', 'password');
return $this->createDefaultDBConnection($pdo, 'php');
}
В этом случае при каждом обращение к методу будет пересоздаваться подключения к базе. Вынесем создание объекта подключения в конструктор, а метод getConnection будет возвращать ссылку на объект подключения:
/**
* Создает PDO connection к базе данных
* Создает DefaultDBConnection для DBUnit с использованием этого PDO
*
*/
public function __construct($name = null, array $data = array(), $dataName = '')
{
parent::__construct($name, $data, $dataName);
$this->pdo = new PDO("mysql:host=localhost;dbname=dbname", 'root', 'password');
$this->pdo->exec('SET foreign_key_checks = 0'); //отключим проверку внешних ключей
$this->con = $this->createDefaultDBConnection($this->pdo, 'php');
$this->pdo->beginTransaction();
}
/**
* Получить PDO
*
* @return PHPUnit_Extensions_Database_DB_DefaultDatabaseConnection
*/
public function getConnection()
{
return $this->con;
}
Останавлюсь на на моменте отключения внешних ключей: перед выполнением тестов DBUnit очищает базу данных, отправляя truncate каждой таблице. Часто встречается ситуация, кода очищается таблица, на которую ссылаются данные в еще не очищенной таблице, тем самым блокируя очистку данных чтобы этого избежать отключаем проверку внешних ключей на время выполнения теста.
SET foreign_key_checks = 0
Так же мы дали возможность тестам выполнять запросы к БД через PDO ($this->pdo->query())
Теперь дело за малым: откатить транзакцию после выполнения теста в рамках одного тест кейса:
/**
* Деструктор
*
* Откатывает транзакцию, чтобы изменения не отражались на боевой базе
*/
function __destruct()
{
$this->pdo->rollBack();
}
Код выглядит вполне рабочим, но осталось 2 подводных камня:
1) транзакция прерывается при выполнении операции Truncate, которая выполняется перед каждой заливкой тестовых данных расширением dbUnit.
2) Если ваша СУБД - MySQL, то длительное время выполнения одной транзакции пораждает ошибку: "Lock wait timeout exceeded; try restarting transaction." Баг описан в багтрекере MySQL.
Откажемся от операции truncate следующим образом:
Покопавшись во внутренностях DBUnit находим в классе PHPUnit_Extensions_Database_Operation_Factory метод CLEAN_INSERT:
/**
* Returns a clean insert database operation. It will remove all contents
* from the table prior to re-inserting rows.
*
* @param bool $cascadeTruncates Set to true to force truncates to cascade on databases that support this.
* @return PHPUnit_Extensions_Database_Operation_IDatabaseOperation
*/
public static function CLEAN_INSERT($cascadeTruncates = FALSE)
{
return new PHPUnit_Extensions_Database_Operation_Composite(array(
self::TRUNCATE($cascadeTruncates),
self::INSERT()
));
}
котрый вызывается из PHPUnit_Framework_TestCase для очистки базы
protected function getSetUpOperation()
{
return PHPUnit_Extensions_Database_Operation_Factory::CLEAN_INSERT();
}
Все что необходимо - заменить функцию TRUNCATE на DELETE_ALL. В недрах PHPUnit делать такое моветон. Благо переопределить это поведение можно в унаследованном классе:
abstract class TrunsactionFiendlyDatabaseTestCase extends PHPUnit_Extensions_Database_TestCase
{
/**
* Returns the database operation executed in test setup.
* Return DeleteAll and Insert After this.
*
* @return PHPUnit_Extensions_Database_Operation_DatabaseOperation
*/
protected function getSetUpOperation()
{
return new PHPUnit_Extensions_Database_Operation_Composite(
array
(
PHPUnit_Extensions_Database_Operation_Factory::DELETE_ALL(),
PHPUnit_Extensions_Database_Operation_Factory::INSERT()
)
);
}
}
Все догадались что "родительский для тестов" класс следует унаследовать от TrunsactionFiendlyDatabaseTestCase()? Либо же объединить 2 этих класса, тут дело вкуса и моральных устоев. Но я предпочел не смешивать 2 уровня логики в одном классе. Получившиеся иерархия классов представлена на диаграмме:
Проблему "Lock wait timeout exceeded" удалось решить откатывая после каждого теста транзакцию и начиная новую.
/**
* Вызывается после выполнения каждого теста, рестартуя транзакцию
*/
public function tearDown()
{
$this->pdo->rollBack();
$this->pdo->beginTransaction();
}
В итоге: все операции с данными в рамках одного теста исполняются в транзакции, которая откатывается после завершения теста.
Реализация копирования структуры БД
Идея плавает на поверхности: завести для тестов еще одну базу данных. Но в этом случае придется поддерживать в актуальном состоянии обе базы данных. И легко может случиться ситуация когда тесты проходятся с успехом, но на "боевой" базе система не работает.
Попытаемся автоматизировать процесс копирования структуры базы данных перед запуском тестов. Со всем остальным прекрасно справится DbUnit.
Очевидно что базу необходимо скопировать до выполнения первого тест кейса.
PHPUnit позволяет выполнять "файл начальной загрузки", он же bootstrap, перед выполнением тестов. Задается он в файле настроек phpunit.xml таким образом:
<phpunit
bootstrap="./application/bootstrap.php"
>
Он может выполнять много полезных функций: подключение файлов, инициализация переменных окружения. Я предлагаю в нем же инициировать процесс клонирования структуры БД.
Разберем по шагам процесс клонирования структуры базы данных.
Для начала удалим тестовую базу, если вдруг она уже создана
DROP DATABASE IF EXISTS `testDB`
и создадим ее заново
CREATE DATABASE `testDB`
Конечно, можно и не удалять базу, если она уже имеется, а просто очистить ее, но в таком случае может возникнуть рассогласование структуры тестовой базы и рабочей, попросту говоря, тестовая база может устареть.
Далее получаем список таблиц рабочей базы
SHOW TABLES FROM `developDB`
и создадим по их образу и подобию таблицы в тестовой базе:
CREATE TABLE `$table` LIKE `developDB`.`$table`"
Отбросив лишнее, получаем примерно следующий код:
$res = $this->pdo->exec("DROP DATABASE IF EXISTS `testDB`");
$res = $this->pdo->exec("CREATE DATABASE `testDB`");
$tables = $this->pdo->query("SHOW TABLES FROM `developDB`");
$tables = $tables->fetchAll(PDO::FETCH_COLUMN, 0);
foreach ($tables as $table) {
$res = $this->pdo->exec("CREATE TABLE `$table ` LIKE `developDB`.`$table`");
}
На этом этапе мы имеем структуру тестовой базы данных, "идентичной натуральной"
Остается не забыть натравить DbUnit на тестовую базу. Никакое стандартное поведение DbUnit переопределять не требуется.
Попытаюсь рассмотреть плюсы и минусы обоих подходов
Транзакционный на удивление работает довольно быстро, даже не смотря на то, что транзакцию приходится откатывать после выполнения каждого теста, а не всего test case'а.
Но на сколько он окажется быстродейственным при запуске многих test case'ов для меня остается загадкой. Т.к. Общее время выполнения этой вспомогательной операции растет прямо пропорционально количеству тестов. Да и с работой зависимостей тестов, они же depends, окажутся проблемы.
Сложность алгоритма можно представить как O(n), где n - количество тестов.
UPD. Спасибо пользователю zim32
Так же этот способ накладывает ограничения на запросы к базе данных, генерируемых тестом. В частности, транзакцию необходимо завершить до использования выражений: ALTER TABLE, CREATE INDEX, DROP INDEX, DROP TABLE, RENAME TABLE.
Вариант с копированием структуры базы напротив, требует много времени на свое выполнение, но зато и выполняется один раз. Т.е. Время его работы не зависит от количества запускаемых тестов.
Сложность этого алгоритма = const. На сколько эта константа велика зависит от количества таблиц и их структуры. Для примера могу сказать, что на 40 таблицах клонирование структуры у меня занимает около 8 секунд.
Вывод: При запуске одиночных test case'ов стоит воспользоваться "транзакционным" подходом. При выполнении большого количества test case'ов стоит предпочесть вариант с клонированием структуры базы данных.
Ссылки по теме