Доброго времени суток, друзья!
Хочу поделиться опытом по борьбе с PHPUnit/DbUnit в связке с MySQL. Далее небольшая предыстория.
Краткая предыстория
В процессе написания одного веб-приложения возникла необходимость тестировать код на PHP, интенсивно взаимодействующий с БД MySQL. В проекте в качестве фреймворка модульного тестирования использовался порт xUnit — PHPUnit. В результате было принято решение писать тесты для модулей, непосредственно взаимодействующих с базой, подцепив плагин PHPUnit/DbUnit. Дальше я расскажу о тех трудностях, которые возникли при написании тестов и о том, каким способом я их преодолел. В ответ же хотелось бы получить комментарии знающих людей относительно корректности моих решений.
Как работает DbUnit
Подпункт предназначен для тех, кто не знаком с методикой тестирования с использованием PHPUnit и/или DbUnit. Кому не интересно, смело можно переходить к следующему.
Далее по тексту:
- тестовый класс — класс, содержащий код модульных тестов, наследник любой из реализаций PHPUnit::TestCase;
- тестируемый класс — класс, который необходимо протестировать.
Так как подпункт для начинающих, то для начала будет рассмотрена процедура модульного тестирования обычных классов PHP, а потом описаны отличия при тестировании кода, взаимодействующего с БД.
Тестирование обычных классов 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 и содержащий в себе все эти методы. Остальные тестовые классы проекта, работающие с базой, наследуются от этого базового класса.
Перефразирую одного хорошего человека — дратьсятестировать я не умею, но очень люблю. Так что хотелось бы услышать комментарии по поводу представленных в статье способов.
Автор: Ostrovski