PHP / Много текста про практику работы с PHPUnit/DbUnit

в 20:00, , рубрики: dbunit, mysql, php, phpunit, unit test, бд, костыль, модульное тестирование, метки: , , , , , , ,

Доброго времени суток, друзья!
Хочу поделиться опытом по борьбе с 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, можно переопределить эти методы и поместить повторяющийся ранее в каждом тестовом методе код в них. В результате последовательность вызова методов при прогонке тестов будет следующая:

  1. setUp()       {/* Установили систему в нужное состояние */}
  2. testMethod1() {/* протестировали метод 1 класса */}
  3. tearDown()    {/* Очистили систему */}
  1. setUp()       {/* Установили систему в нужное состояние */}
  2. testMethod2() {/* протестировали метод 2 класса */}
  3. tearDown()    {/* Очистили систему */}

  1. setUp()       {/* Установили систему в нужное состояние */}
  2. testMethodN() {/* протестировали метод N класса */}
  3. 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() также должна предоставляться тестовым классом, т.е. нами. В результате получим похожую последовательность вызовов

  1. setUp()       {/* Установили БД в соответствии с данными, получаемыми от                   метода getDataSet() */}
  2. testMethod1() {/* протестировали метод 1 класса */}
  3. tearDown()    {/* Очистили систему */}
  1. setUp()       {/* Установили БД в соответствии с данными, получаемыми от                   метода getDataSet() */}
  2. testMethod2() {/* протестировали метод 2 класса */}
  3. tearDown()    {/* Очистили систему */}

  1. setUp()       {/* Установили БД в соответствии с данными, получаемыми от                   метода getDataSet() */}
  2. testMethodN() {/* протестировали метод N класса */}
  3. 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


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js