Доброго времени суток, друзья!
Хочу поделиться опытом по борьбе с PHPUnit/DbUnit в связке с MySQL. Далее небольшая предыстория.
В процессе написания одного веб-приложения возникла необходимость тестировать код на PHP, интенсивно взаимодействующий с БД MySQL. В проекте в качестве фреймворка модульного тестирования использовался порт xUnit — PHPUnit. В результате было принято решение писать тесты для модулей, непосредственно взаимодействующих с базой, подцепив плагин PHPUnit/DbUnit. Дальше я расскажу о тех трудностях, которые возникли при написании тестов и о том, каким способом я их преодолел. В ответ же хотелось бы получить комментарии знающих людей относительно корректности моих решений.
Подпункт предназначен для тех, кто не знаком с методикой тестирования с использованием PHPUnit и/или DbUnit. Кому не интересно, смело можно переходить к следующему.
Далее по тексту:
Чтобы протестировать класс, написанный на PHP, с использованием фреймворка PHPUnit, необходимо создать тестовый класс, расширяющий базовый класс PHPUnit_Framework_TestCase. Затем создать в этом классе публичные методы, начинающиеся со слова test (если создать метод, который будет называться по-другому, он не будет автоматически вызван при прогоне тестов), и поместить в них код, выполняющий действия с объектами тестируемого класса и проверяющий результат. На этом можно закончить и скормить полученный класс phpunit, который, в свою очередь, последовательно вызовет все тестовые методы и любезно предоставит отчет об их работе. Однако в большинстве случаев в каждом из тестовых методов будет повторяющийся код, подготавливающий систему для работы с тестируемым объектом. Для того, чтобы избежать дублирования кода, в классе PHPUnit_Framework_TestCase созданы защищенные методы setUp и tearDown, имеющие пустую реализацию. Эти методы вызываются перед и после запуска очередного тестового метода соответственно и служат для подготовки системы к выполнению тестовых действий и очистки ее после завершения каждого теста. В тестовом классе, расширяющем PHPUnit_Framework_TestCase, можно переопределить эти методы и поместить повторяющийся ранее в каждом тестовом методе код в них. В результате последовательность вызова методов при прогонке тестов будет следующая:
Процесс написания тестов для кода, взаимодействующего с БД, практически не отличается от процедуры тестирования обычных классов PHP. Сначала необходимо создать тестовый класс, наследующий PHPUnit_Extensions_Database_TestCase (класс PHPUnit_Extensions_Database_TestCase сам при этом наследует PHPUnit_Framework_TestCase), который будет содержать тесты для методов тестируемого класса. Затем создать тестовые методы, начинающиеся с префикса test, а потом скормить этот код phpunit с указанием имени тестового класса. Отличия заключаются лишь в том, что в тестовом классе обязательно необходимо реализовать два публичных метода — getConnection() и getDataSet(). Первый метод необходим для того, чтобы научить DbUnit работать с БД (придется использовать PDO), а второй для того, чтобы сообщить фреймворку, в какое состояние переводить базу данных перед выполнением очередного теста. Под DataSet в терминологии DbUnit понимается набор из одной или более таблиц.
Как говорилось выше, перед выполнением очередного теста (представленного методом в тестовом классе), PHPUnit вызывает специальный метод setUp(), чтобы сэмулировать среду выполнения для объекта тестируемого класса. В случае DbUnit реализация по умолчанию метода setUp() уже не пустая. Если говорить в общих чертах, то внутри метода setUp() будет создан некий объект databaseTester, который, используя определенный нами метод getConnection(), переведет базу в состояние, представленное набором таблиц (DataSet`ом), получаемым при вызове метода getDataSet(). Если вы были внимательны, то реализация метода getDataSet() также должна предоставляться тестовым классом, т.е. нами. В результате получим похожую последовательность вызовов
Оперативная обстановка: База данных, используемая в проекте, имеет несколько десятков таблиц, движок MySQL InnoDB. Механизм внешних ключей активно используется с целью поддержания согласованности данных на уровне самой БД.
Первая неприятность, которая начала омрачать мне процесс тестирования — инициализация базы данных созданными мной наборами таблиц.
DbUnit позволяет создавать DataSet`ы, получая данные из различных источников:
Каждый из вышеперечисленных способов создания наборов таблиц реализуется отдельным методом класса PHPUnit_Extensions_Database_TestCase.
Я избрал себе в помощники mysqldump и ринулся в атаку: сформировал нужное состояние базы, выгрузил его в xml и в реализации getDataSet() написал что-то вроде:
… и решил прогнать первый тест. Однако, тут же получил исключение, в котором недвусмысленно говорилось о том, что база данных не может быть приведена в заданное состояние из-за наличия в ней ограничений по внешним ключам.
Несколько минут копания в исходниках DbUnit показали, что в методе PHPUnit_Extensions_Database_TestCase::setUp() установка базы в состояние в соответствии с указанным мной DataSet`ом, осуществляется при помощи операции PHPUnit_Extensions_Database_Operation_Factory::CLEAN_INSERT. Операция CLEAN_INSERT в свою очередь представляет собой порождаемую фабрикой макрокоманду, включающую в себя две операции: PHPUnit_Extensions_Database_Operation_Factory::TRUNCATE и PHPUnit_Extensions_Database_Operation_Factory::INSERT. Очевидно, что тут все стало на свои места — не возможно сделать TRUNCATE для базы, у которой имеются активные ограничения по внешним ключам FOREIGN KEY.
Нужно решать. Пути два — либо временно отключить FOREIGN KEY во время тестирования (темный путь), либо использовать новую команду PHPUnit_Extensions_Database_Operation_Factory::DELETE_ALL, обнаруженную во время курения исходников DbUnit (светлый, но более длинный путь). Через минуту темная сторона во мне пересилила, и я решил пойти более простым путем — отключить ограничения целостности по внешним ключам во время создания подключения. Благо код создания все равно был написан мной в реализации метода getConnection().
Типовая реализация getConnection() выглядит примерно так:
$m_oConn — это переменная-член тестового класса, которая представляет собой некоторую обертку вокруг PDO. А если быть точным, то это экземпляр класса PHPUnit_Extensions_Database_DB_DefaultDatabaseConnection. Добавив сразу после создания объекта PDO строку $oPdo->exec('SET foreign_key_checks = 0') я на какое-то время решил проблему с инициализацией.
Собственно, как и следовало ожидать, через некоторое время я напоролся на грабли с несогласованностью данных в базе и пришлось возвращаться на светлый путь, а именно — отказаться от отключения внешних ключей и заменить TRUNCATE на DELETE_ALL.
Очередной просмотр исходников показал, что копать нужно в сторону реализации PHPUnit_Extensions_Database_TestCase::setUp(). Вот ее код:
и вот метод getSetUpOperation():
Переопределив в своем тестовом классе метод getSetUpOperation() на:
я избавился от TRUNCATE, но добавил себе необходимость реализации очистки базы данных. Так как наша база содержит несколько представлений, то бездумный вызов PHPUnit_Extensions_Database_Operation_Factory::DELETE_ALL() для DataSet`а из всех таблиц базы ни к чему хорошему не привел бы. К тому же я посчитал, что функциональность очистки базы может быть достаточно полезной не только в момент инициализации теста, поэтому решил оформить ее в виде самостоятельного метода:
В коде делается допущение, что все представления, существующие в базе начинаются с префикса view_.
Осталось только переопределить метода setUp(), чтобы он самостоятельно очищал базу перед тем, как отдавать ее на заполнение данными databaseTester`у.
Следующая проблема возникла при попытке сравнения двух DataSet`ов — одного полученного непосредственно из базы (сформированного в результате выполнения тестируемого кода), а другого — созданного заранее руками и представляющего желаемый результат.
Текущее состояние базы можно получить следующим способом:
Увидев в манах метод PHPUnit_Extensions_Database_TestCase::assertDataSetsEqual, сравнивающий два набора таблиц я очень обрадовался. Как оказалось рановато. Результаты сравнения оказались весьма неожиданными. Два идентичных на вид набора таблиц при сравнении вызывали падение assert`а.
Отладчик в свою очередь показал, что беда в DataSet`е, получаемом из базы. Видимо в целях оптимизации, при вызове $this->getConnection()->createDataSet() в тестовом классе, происходит лишь частичная загрузка набора таблиц, а если быть точным — только метаданные DataSet`а (имя базы и еще какая-то шелуха).
Исходный код PHPUnit_Extensions_Database_TestCase::assertDataSetsEqual следующий:
Если раскручивать цепочку вызовов дальше, то после нескольких делегирований непосредственно операции сравнения дело дойдет до PHPUnit_Extensions_Database_DataSet_AbstractTable::matches(PHPUnit_Extensions_Database_DataSet_ITable $other), в котором будут сравниваться две таблицы. В этом методе при сравнении таблиц данные в них будут в обязательном порядке затянуты из базы. Но это если дело дойдет до этого метода. Потому что прежде чем сравнивать таблицы двух DataSet`ов между собой, производится сравнения DataSet`ов. В итоге assert в каком-то месте не проходит. Этот баг есть в issues PHPUnit/DbUnit на github, ему уже несколько месяцев.
В ожидании исправления этой ошибки я быстренько накидал метод сравнения наборов таблиц. Не совсем в духе DbUnit, где все сделано универсальной последовательностью вызовов evaluate -> matches конкретных реализаций сравниваемых объектов, но зато рабочий:
Поведение DbUnit, описанное в статье, было получено при использовании DbUnit 1.1.2, PHPUnit 3.6.10 и MySQL 5.1. В результате добавления всех вышеописанных костылей был создан базовый класс, расширяющий PHPUnit_Extensions_Database_TestCase и содержащий в себе все эти методы. Остальные тестовые классы проекта, работающие с базой, наследуются от этого базового класса.
Перефразирую одного хорошего человека —дратьсятестировать я не умею, но очень люблю. Так что хотелось бы услышать комментарии по поводу представленных в статье способов.
Хочу поделиться опытом по борьбе с PHPUnit/DbUnit в связке с MySQL. Далее небольшая предыстория.
Краткая предыстория
В процессе написания одного веб-приложения возникла необходимость тестировать код на PHP, интенсивно взаимодействующий с БД MySQL. В проекте в качестве фреймворка модульного тестирования использовался порт xUnit — PHPUnit. В результате было принято решение писать тесты для модулей, непосредственно взаимодействующих с базой, подцепив плагин PHPUnit/DbUnit. Дальше я расскажу о тех трудностях, которые возникли при написании тестов и о том, каким способом я их преодолел. В ответ же хотелось бы получить комментарии знающих людей относительно корректности моих решений.
Как работает DbUnit
Подпункт предназначен для тех, кто не знаком с методикой тестирования с использованием PHPUnit и/или DbUnit. Кому не интересно, смело можно переходить к следующему.
Далее по тексту:
- тестовый класс — класс, содержащий код модульных тестов, наследник любой из реализаций PHPUnit::TestCase;
- тестируемый класс — класс, который необходимо протестировать.
Тестирование обычных классов PHP
Чтобы протестировать класс, написанный на PHP, с использованием фреймворка PHPUnit, необходимо создать тестовый класс, расширяющий базовый класс PHPUnit_Framework_TestCase. Затем создать в этом классе публичные методы, начинающиеся со слова test (если создать метод, который будет называться по-другому, он не будет автоматически вызван при прогоне тестов), и поместить в них код, выполняющий действия с объектами тестируемого класса и проверяющий результат. На этом можно закончить и скормить полученный класс phpunit, который, в свою очередь, последовательно вызовет все тестовые методы и любезно предоставит отчет об их работе. Однако в большинстве случаев в каждом из тестовых методов будет повторяющийся код, подготавливающий систему для работы с тестируемым объектом. Для того, чтобы избежать дублирования кода, в классе PHPUnit_Framework_TestCase созданы защищенные методы setUp и tearDown, имеющие пустую реализацию. Эти методы вызываются перед и после запуска очередного тестового метода соответственно и служат для подготовки системы к выполнению тестовых действий и очистки ее после завершения каждого теста. В тестовом классе, расширяющем PHPUnit_Framework_TestCase, можно переопределить эти методы и поместить повторяющийся ранее в каждом тестовом методе код в них. В результате последовательность вызова методов при прогонке тестов будет следующая:
setUp() {/* Установили систему в нужное состояние */}
testMethod1() {/* протестировали метод 1 класса */}
tearDown() {/* Очистили систему */}
setUp() {/* Установили систему в нужное состояние */}
testMethod2() {/* протестировали метод 2 класса */}
tearDown() {/* Очистили систему */}
setUp() {/* Установили систему в нужное состояние */}
testMethodN() {/* протестировали метод N класса */}
tearDown() {/* Очистили систему */}
Тестирование кода PHP, взаимодействующего с БД
Процесс написания тестов для кода, взаимодействующего с БД, практически не отличается от процедуры тестирования обычных классов PHP. Сначала необходимо создать тестовый класс, наследующий PHPUnit_Extensions_Database_TestCase (класс PHPUnit_Extensions_Database_TestCase сам при этом наследует PHPUnit_Framework_TestCase), который будет содержать тесты для методов тестируемого класса. Затем создать тестовые методы, начинающиеся с префикса test, а потом скормить этот код phpunit с указанием имени тестового класса. Отличия заключаются лишь в том, что в тестовом классе обязательно необходимо реализовать два публичных метода — getConnection() и getDataSet(). Первый метод необходим для того, чтобы научить DbUnit работать с БД (придется использовать PDO), а второй для того, чтобы сообщить фреймворку, в какое состояние переводить базу данных перед выполнением очередного теста. Под DataSet в терминологии DbUnit понимается набор из одной или более таблиц.
Как говорилось выше, перед выполнением очередного теста (представленного методом в тестовом классе), PHPUnit вызывает специальный метод setUp(), чтобы сэмулировать среду выполнения для объекта тестируемого класса. В случае DbUnit реализация по умолчанию метода setUp() уже не пустая. Если говорить в общих чертах, то внутри метода setUp() будет создан некий объект databaseTester, который, используя определенный нами метод getConnection(), переведет базу в состояние, представленное набором таблиц (DataSet`ом), получаемым при вызове метода getDataSet(). Если вы были внимательны, то реализация метода getDataSet() также должна предоставляться тестовым классом, т.е. нами. В результате получим похожую последовательность вызовов
setUp() {/* Установили БД в соответствии с данными, получаемыми от метода getDataSet() */}
testMethod1() {/* протестировали метод 1 класса */}
tearDown() {/* Очистили систему */}
setUp() {/* Установили БД в соответствии с данными, получаемыми от метода getDataSet() */}
testMethod2() {/* протестировали метод 2 класса */}
tearDown() {/* Очистили систему */}
setUp() {/* Установили БД в соответствии с данными, получаемыми от метода getDataSet() */}
testMethodN() {/* протестировали метод N класса */}
tearDown() {/* Очистили систему */}
Маленькие неприятности
Оперативная обстановка: База данных, используемая в проекте, имеет несколько десятков таблиц, движок MySQL InnoDB. Механизм внешних ключей активно используется с целью поддержания согласованности данных на уровне самой БД.
1. Инициализация базы
Первая неприятность, которая начала омрачать мне процесс тестирования — инициализация базы данных созданными мной наборами таблиц.
DbUnit позволяет создавать DataSet`ы, получая данные из различных источников:
- Flat Xml — такой простенький способ описание состояния БД в xml-файле, рассчитанный преимущественно на ручное формирование файла.
- Xml — полноценный формат задания состояния, намного больше букаф, но и более широкие возможности (можно задавать null-значения, более точно описывать структуру БД и пр.).
- MySQL Xml — разновидность предыдущего формата, любезно предоставленная разработчиками DbUnit, позволяющая создавать объект DataSet на основании экспорта данных БД утилитой mysqldump.
- Создание объекта DataSet по текущему состоянию БД.
Каждый из вышеперечисленных способов создания наборов таблиц реализуется отдельным методом класса PHPUnit_Extensions_Database_TestCase.
Я избрал себе в помощники mysqldump и ринулся в атаку: сформировал нужное состояние базы, выгрузил его в xml и в реализации getDataSet() написал что-то вроде:
public function getDataSet() {
return $this->createMySQLXMLDataSet('db_init.xml'); //имя файла, полученного mysqldump.
}
… и решил прогнать первый тест. Однако, тут же получил исключение, в котором недвусмысленно говорилось о том, что база данных не может быть приведена в заданное состояние из-за наличия в ней ограничений по внешним ключам.
Несколько минут копания в исходниках DbUnit показали, что в методе PHPUnit_Extensions_Database_TestCase::setUp() установка базы в состояние в соответствии с указанным мной DataSet`ом, осуществляется при помощи операции PHPUnit_Extensions_Database_Operation_Factory::CLEAN_INSERT. Операция CLEAN_INSERT в свою очередь представляет собой порождаемую фабрикой макрокоманду, включающую в себя две операции: PHPUnit_Extensions_Database_Operation_Factory::TRUNCATE и PHPUnit_Extensions_Database_Operation_Factory::INSERT. Очевидно, что тут все стало на свои места — не возможно сделать TRUNCATE для базы, у которой имеются активные ограничения по внешним ключам FOREIGN KEY.
Нужно решать. Пути два — либо временно отключить FOREIGN KEY во время тестирования (темный путь), либо использовать новую команду PHPUnit_Extensions_Database_Operation_Factory::DELETE_ALL, обнаруженную во время курения исходников DbUnit (светлый, но более длинный путь). Через минуту темная сторона во мне пересилила, и я решил пойти более простым путем — отключить ограничения целостности по внешним ключам во время создания подключения. Благо код создания все равно был написан мной в реализации метода getConnection().
Типовая реализация getConnection() выглядит примерно так:
public function getConnection() {
if (is_null($this->m_oConn)) {
$oPdo = new PDO('mysql:dbname=db1;host=localhost', 'root', 'qwerty');
$this->m_oConn = $this->createDefaultDBConnection($oPdo, 'db1');
}
return $this->m_oConn;
}
$m_oConn — это переменная-член тестового класса, которая представляет собой некоторую обертку вокруг PDO. А если быть точным, то это экземпляр класса PHPUnit_Extensions_Database_DB_DefaultDatabaseConnection. Добавив сразу после создания объекта PDO строку $oPdo->exec('SET foreign_key_checks = 0') я на какое-то время решил проблему с инициализацией.
Собственно, как и следовало ожидать, через некоторое время я напоролся на грабли с несогласованностью данных в базе и пришлось возвращаться на светлый путь, а именно — отказаться от отключения внешних ключей и заменить TRUNCATE на DELETE_ALL.
Очередной просмотр исходников показал, что копать нужно в сторону реализации PHPUnit_Extensions_Database_TestCase::setUp(). Вот ее код:
protected function setUp() {
parent::setUp(); //вызов PHPUnit_Framework_TestCase::setUp() - пустая реализация
$this->databaseTester = NULL;
$this->getDatabaseTester()->setSetUpOperation($this->getSetUpOperation());
$this->getDatabaseTester()->setDataSet($this->getDataSet());
$this->getDatabaseTester()->onSetUp();
}
и вот метод getSetUpOperation():
protected function getSetUpOperation() {
return PHPUnit_Extensions_Database_Operation_Factory::CLEAN_INSERT();
}
Переопределив в своем тестовом классе метод getSetUpOperation() на:
protected function getSetUpOperation() {
return PHPUnit_Extensions_Database_Operation_Factory::INSERT();
}
я избавился от TRUNCATE, но добавил себе необходимость реализации очистки базы данных. Так как наша база содержит несколько представлений, то бездумный вызов PHPUnit_Extensions_Database_Operation_Factory::DELETE_ALL() для DataSet`а из всех таблиц базы ни к чему хорошему не привел бы. К тому же я посчитал, что функциональность очистки базы может быть достаточно полезной не только в момент инициализации теста, поэтому решил оформить ее в виде самостоятельного метода:
protected function clearDb() {
$aTableNames = $this->getConnection()->createDataSet()->getTableNames();
foreach ($aTableNames as $i => $sTableName) {
if (false === strpos($sTableName, 'view_'))
continue;
unset($aTableNames[$i]);
}
$aTableNames = array_values($aTableNames);
$op = \PHPUnit_Extensions_Database_Operation_Factory::DELETE_ALL();
$op->execute($this->getConnection(), $this->getConnection()->createDataSet($aTableNames));
}
В коде делается допущение, что все представления, существующие в базе начинаются с префикса view_.
Осталось только переопределить метода setUp(), чтобы он самостоятельно очищал базу перед тем, как отдавать ее на заполнение данными databaseTester`у.
protected function setUp() {
$this->clearDb();
parent::setUp();
}
2. Сравнение наборов таблиц
Следующая проблема возникла при попытке сравнения двух DataSet`ов — одного полученного непосредственно из базы (сформированного в результате выполнения тестируемого кода), а другого — созданного заранее руками и представляющего желаемый результат.
Текущее состояние базы можно получить следующим способом:
$oActualDataSet = $this->getConnection()->createDataSet();
Увидев в манах метод PHPUnit_Extensions_Database_TestCase::assertDataSetsEqual, сравнивающий два набора таблиц я очень обрадовался. Как оказалось рановато. Результаты сравнения оказались весьма неожиданными. Два идентичных на вид набора таблиц при сравнении вызывали падение assert`а.
Отладчик в свою очередь показал, что беда в DataSet`е, получаемом из базы. Видимо в целях оптимизации, при вызове $this->getConnection()->createDataSet() в тестовом классе, происходит лишь частичная загрузка набора таблиц, а если быть точным — только метаданные DataSet`а (имя базы и еще какая-то шелуха).
Исходный код PHPUnit_Extensions_Database_TestCase::assertDataSetsEqual следующий:
public static function assertDataSetsEqual(PHPUnit_Extensions_Database_DataSet_IDataSet $expected, PHPUnit_Extensions_Database_DataSet_IDataSet $actual, $message = '')
{
$constraint = new PHPUnit_Extensions_Database_Constraint_DataSetIsEqual($expected);
self::assertThat($actual, $constraint, $message);
}
Если раскручивать цепочку вызовов дальше, то после нескольких делегирований непосредственно операции сравнения дело дойдет до PHPUnit_Extensions_Database_DataSet_AbstractTable::matches(PHPUnit_Extensions_Database_DataSet_ITable $other), в котором будут сравниваться две таблицы. В этом методе при сравнении таблиц данные в них будут в обязательном порядке затянуты из базы. Но это если дело дойдет до этого метода. Потому что прежде чем сравнивать таблицы двух DataSet`ов между собой, производится сравнения DataSet`ов. В итоге assert в каком-то месте не проходит. Этот баг есть в issues PHPUnit/DbUnit на github, ему уже несколько месяцев.
В ожидании исправления этой ошибки я быстренько накидал метод сравнения наборов таблиц. Не совсем в духе DbUnit, где все сделано универсальной последовательностью вызовов evaluate -> matches конкретных реализаций сравниваемых объектов, но зато рабочий:
public function compareDataSets(PHPUnit_Extensions_Database_DataSet_IDataSet $expected,
PHPUnit_Extensions_Database_DataSet_IDataSet $actual,
$message = '') {
$aExpectedNames = $expected->getTableNames();
$aActualNames = $actual->getTableNames();
sort($aActualNames);
sort($aExpectedNames);
$this->assertEquals($aExpectedNames, $aActualNames, $message);
foreach ($aActualNames as $sTableName) {
$atable = $actual->getTable($sTableName);
$etable = $expected->getTable($sTableName);
if (0 == $atable->getRowCount()) {
$this->assertEquals(0, $etable->getRowCount(), $message);
} else {
$this->assertTablesEqual($etable, $atable, $message);
}
}
}
Заключение
Поведение DbUnit, описанное в статье, было получено при использовании DbUnit 1.1.2, PHPUnit 3.6.10 и MySQL 5.1. В результате добавления всех вышеописанных костылей был создан базовый класс, расширяющий PHPUnit_Extensions_Database_TestCase и содержащий в себе все эти методы. Остальные тестовые классы проекта, работающие с базой, наследуются от этого базового класса.
Перефразирую одного хорошего человека —