PHP Unit. Опыт тестирования классов, работающих с Базой Данных

Тестировать или нет классы, взаимодействующие с Базой данных — вопрос куда более холиварный, чем спор «покрывать код тестами или нет». Просмотрев свой проект, нам стало очевидно, что львиная доля классов основана на взаимодействии с базой данных. Поэтому было однозначно решено: «тестированию быть».
Далее я хочу поделится опытом написания модульных тестов для кода, работающего с базой данных.

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 уровня логики в одном классе. Получившиеся иерархия классов представлена на диаграмме:
image

Проблему «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'ов стоит предпочесть вариант с клонированием структуры базы данных.

Хотелось бы услышать с какими проблемами сталкивались Вы при использовании DbUnit и как их решали.

При подготовке материала был использован источник: www.smartyit.ru/php
Поделиться публикацией
Комментарии 28
    +2
    Вариант с копированием структуры базы напротив, требует много времени на свое выполнение, но зато и выполняется один раз. Т.е. Время его работы не зависит от количества запускаемых тестов.


    А у вас не получится тогда ситуация, что тесты периодически что-то меняют в базе данных, и следующий тест может видеть результат предыдущего? Т.е. по-хорошему, тесты должны идти в полной изоляции и не знать о существовании друг друга. И база данных тоже должна очищаться после каждого теста. На этом нельзя халтурить )

    Конечно самый удобный по скорости способ это транзакции, но в MySQL нет вложенных транзакций, а значит ваш метод тестирования будет бесполезен, если в самом приложении будет использована транзакция.

    Хотелось бы услышать с какими проблемами сталкивались Вы при использовании DbUnit и как их решали.


    Со всеми вышеописанными. Решал так:
    codeception.com/docs/08-Data

      0
      Т.е. решали поднимая каждый дамп для каждого юнит теста? А как тогда решали скорость выполнения тестов? При таком подходе у меня тесты выполнялись около 2-х минут, пришлось отказаться в пользу обычного TRUNCATE и скорость стала приемлемой: несколько десятков секунд.
        0
        Ну если вам нужна пустая база, возможно truncate помогает. Но лично я решал проблему очистки сразу с наполнением тестовыми данными, и потому что влив дампа, что truncate + заполнение данными занимали примерно одно и то же время.

        А решения такие:
        — эмулировать вложенные транзакции в приложении. Не так сложно, если не использовать чистый PDO, а какую-то обертку над ним.
        — использовать SQLite.
        — не использовать транзакции в тестируемом коде.

        Но по скорости альтернативы транзакциям нет.
        0
        как это нет вложенных транзакций? mysql 5.0 + innoDb вполне себе позволяют = dev.mysql.com/doc/refman/5.0/en/savepoint.html
          0
          А вы в приложении всегда сейвпоинтами пользуетесь? Я всегда только commit/rollback.
            0
            почему всегда? коміті и роллбеки использується только на внешней транзакции, внутри транзакция стартует и откатівается уже сейвпойнтами
              +1
              Да. Но это значит, что всегда в програмном коде нужно использовать сейвпоинты.
              Можно конечно, но лучше исходить из универсального предположения, что мы должны тестировать любой код.
            +1
            Это не вложенные (nested transaction) транзакции по определению.
              –1
              Что тогда мешает им быть вложенными транзакциями?
                +1
                Как минимум синтаксис.
          0
          >> транзакция прерывается при выполнении операции Truncate
          Я вам больше скажу больше — dev.mysql.com/doc/refman/5.0/en/implicit-commit.html
          Поэтому сомнительный это способ
            0
            Да, Вы правы. Транзакционный способ подходит к сожалению не для всех тестов. Добавлю эти ограничения в статью.
            Я даже боюсь при слишком большом количестве операций с базой данных в одном тесте можно наткнуться на баг «Lock wait timeout exceeded».
            Но не одним мускулем живы…
            +1
            Не модульные это тесты, а функциональные.

            Для себя проблему решил так:
            — В модульных тестах, которые запускаются каждые «5 минут», проверяю работу классов с БД стабированием/моканием объектов PDO, mysqli, ORM или своей обёртки для mysql_*. Многословно получается (при использовании PHPUnit), но не проверяю ничего лишнего, в частности работу расширений PHP и СУБД. Грубо говоря, проверяю что вызван метод $db_con->query('SELECT * FROM users') и подставляю в возврат ожидаемый массив. То есть проверяю работу PHP кода и ничего больше.
            — Проверку синтаксиса (прежде всего, судя по количеству фейлов) и логики отдельных запросов проверяю в интеграционных, которые запускаются гораздо реже.
            — «Глобальную» работу с БД (несколько запросов, до десятков на страницу) с переходами между страницами проверяю в функциональных/приемочных тестах, которые запускаются крайне редко.

            При таком подходе мудрить с оптимизацией времени выполнения тестов с СУБД не приходится.
              0
              Забыли сказать, что для не транзакционного движка вся эта песня с откатами работать не будет.
              Идея плавает на поверхности: завести для тестов еще одну базу данных. Но в этом случае придется поддерживать в актуальном состоянии обе базы данных.

              И что же, вы запускаете тесты на продакшн базе?
                0
                По топику видно, что речь о test и dev базах.
                0
                Используя расширение DBUnit следует переопределить метод getConnetction, который, как следует из ...

                Опечаточка)
                  –1
                  Не понимаю людей, которые не путешествуют по России при первой же возможности пытаются тестировать классы, работающие с базой данных. Ну зачем?
                  PHPUnit создан для тестирования интерфейсов! Я вообще не считаю, что он должен поддерживать тестирование БД.
                  Ну предположим, будет написан класс, задача которого отправлять запросы к БД используя переданное ему соединение с БД. Ну напишу я запрос, который передам методу этого класса. Протестирую, что с той пачки данных, которая имеется в тестовом окружении, мне вернулись правильные данные. А дальше то что? На живом сайте будет куча данных, других данных и этот тест толком не гарантирует того, что код или запрос рабочий. Индекса не будет, запрос будет тормозить, PHPUnit никогда это не протестирует. Нет, можно, наверное, навставлять костылей, которые и время выполнения запроса потестируют и explain запроса посмотрят, но это уже что-то сложное и не вообразимое в итоге получится.
                    0
                    Есть интерфейс для получения 5 последних вопрос из базы данных. И как тестировать без базы данных? Или не тестировать вообще? Последнее это уже признак плохого тестирования.
                      0
                      Не тестировать вообще не нужно. Покрывать тестами 100% кода тоже не нужно.

                      Если я правильно понял суть вопроса, то откройте для себя моки: www.phpunit.de/manual/3.0/en/mock-objects.html

                      Мок будет подменять экземпляр класса, который общается с БД. А тестировать нужно класс, который возвращает 5 последних вопросов в зависимости от того, что вернула БД, которая в тесте на самом деле мок.
                        0
                        Я знаю, что такое моки. Покрывать тестами 100% кода не нужно, но к этому надо стремиться. Вопрос остается в силе. «экземпляр класса, который общается с БД. » и который будет подменяться моком, вы предлагаете не тестировать?
                          0
                          Модульными тестами не тестировать, функциональными (фактически их и описывают в топике) много не натестируешь. Как например протестировать, что на сервер mysql упало бетонное перекрытие?

                          Класс, который возвращает 5 последних вопросов и класс, который общается с БД — в моём понимании это два разных класса. Это я про то, что в предыдущем комментарии было: «Есть интерфейс для получения 5 последних вопрос из базы данных.»
                            0
                            Один или два не так важно. Все же один класс останется без тестов?
                              0
                              Да, тот класс, который общается с БД, не будет протестирован.
                              Два класса важно, потому что один не будет иметь внешних зависимостей, а только взаимодействовать с интерфейсом другого класса, а значит может быть полностью протестирован при условии написания тестируемого кода естественно.
                                0
                                Я уж лучше протестирую оба класса. Потери в скорости минимальны кстати.
                                  0
                                  В общем-то без конкретных примеров кода обсуждать бесполезно. Если очень хочется — то можно всё, но сам я не буду :)
                          0
                          Порой проще использовать тестовые данные и БД чем создавать моки для классов баз данных.
                          Ибо там такие моки получатся, которые будут включать в себя парсер SQL. Зачем оно надо?
                          KISS
                            0
                            У меня не получалось моков, которым нужно парсить SQL… Что я делаю не так? :)
                            Тестирование БД недопустимо именно модульными тестами, потому что они должны выполняться максимально быстро. По крайней мере в TDD и ему подобных.
                            Ещё вот про dataproviders: www.phpunit.de/manual/3.6/en/writing-tests-for-phpunit.html#writing-tests-for-phpunit.data-providers. Это если сложности с возвратом кучи разных данных, которые могла бы вернуть БД.

                            Ну и, если всё таки очень хочется потестировать БД, то стоит почитать эту часть мануала: www.phpunit.de/manual/3.6/en/database.html. Там про датасеты и дататэйблы очень интересно написано.
                              0
                              Естественно, всё зависит от архитектуры. У вас не получилсоь, у кого-то получилось )

                              А тесты в транзакции выполняются максимально быстро. Проверьте.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое