Pull to refresh

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

Reading time 9 min
Views 27K
Доброго времени суток, друзья!
Хочу поделиться опытом по борьбе с 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 и содержащий в себе все эти методы. Остальные тестовые классы проекта, работающие с базой, наследуются от этого базового класса.

Перефразирую одного хорошего человека — дратьсятестировать я не умею, но очень люблю. Так что хотелось бы услышать комментарии по поводу представленных в статье способов.
Tags:
Hubs:
+23
Comments 31
Comments Comments 31

Articles