Все подряд О чем здесь

PHPUnit и работающие с базой данных классы

Программисты, впервые берущиеся за автоматические тестирование кода с помощью PHPUnit, сталкиваются с несколькими проблемами - такими как непродуманная архитектура проекта, больше количество зависимостей и глобальных переменных и, конечно же, сложности в тестировании работы с базой данных.

Немного о своем опыте автоматического тестирования классов, работающих с БД, я бы и хотел написать в рамках данной статьи.

phpunit

В чем сложность тестирования

Для того чтоб понять как справить с проблемами, давайте их сначала систематизируем.

Тестовые данные в базе данных

На каждый тест нам необходим набор данных. Набор данных должен быть доступен из тестируемой функции. Этот набор данных мы должны контроллировать и откатывать назад (сбрасывать) после каждого теста, в противном случае тесты не будут изолированными и результат работы предыдушего теста будет влиять на результат работы каждого следующего.

Нюансы движка базы данных

Зачастую в проектах используются нюансы той или иной базы данных, так же там же есть хранимые процедуры и периодически меняющаяся архитектура схемы базы данных. Все это ведет к тому что симулировать работу базы данных mock-объектами становится довольно сложно либо это порождает очень много дополнительного кода и необходимость дорабатывать его при смене схемы БД.

Тестирование правильности изменений данных

Проверить правильно ли тестирумый метод удалил или изменил запись из БД - довольно просто. Но многие ли проверяют что метод не удалил или не изменил лишние строки из БД? Когда метод занимается массовым изменением или удалением строк из таблицы - такие тесты начинают порождать довольно много лишних проверок.

Проблемы стандартных методов тестирования

Самым известным расширением для тестирования работы с БД является dbunit. Лично я так и не смог им воспользоваться потому что у меня волосы на лоб полезли от сложности его использования. Это и необходимость создавать несколько dataSet для проверки корректности теста и необходимость дорабатывать встроенные адаптеры в том случае если вы используете "фичи" некоторых движков. Сравнивать генерируемые запросы мне тоже не нравится, так как в процессе жизни проекта неизменно запросы могут видоизменяться (самый яркий пример - оптимизация с помощью использования временных таблиц), при этом возвращаемые значения остаются теми же.

Еще одним удачным вариантом, по-моему, является описанный на хабре транзакционный подход. Его я в итоге и решил развить до чуть более простого в использовании варианта.

Составим список по пунктам по тому что хотелось бы получить в итоге.

Что хотим получить от нашей реализации автоматического тестирования работы с БД

  1. "Нативная" поддержка изменяющейся схемы БД - особенно важно при начальном этапе развития проекта, когда схема изменяется довольно часто. Яркий пример - добавление столбца is_deleted с дефолтным нулевым значением не должно приводить к необходимости изменения тестовых данных.
  2. Простота в "загрузке" тестового дампа. Разумеется, мне бы хотелось с помощью обычного MySQL Workbench вбить в БД необходимые данные и использовать их в тестах.
  3. И уж тем более мне бы не хотелось под каждый тест составлять свой набор данных.
  4. Хотелось бы чтоб assert и "читающих из бд", и "пишущих в бд" методов был простым ровно настолько же, насколько простым может быть сравнение массива.

В общем, весь смысл сводится к тому что моя природная программерская лень кричит о том что тратить слишком много времени на тесты - вред, и это нужно максимально оптимизировать.

После вечера размышлений в моей голове родилась идея, которую позже про себя я назвал "метод двух баз".

Метод двух баз

Основная идея в том что необходимый датасет мы можем загружать перед тестами в структуру базы, которая только что "скопирована" из основной. Тестовый набор данных может браться из типичного дампа с выброшенными оттуда выражениями CREATE TABLE. Кроме того, для проверки валидности изменения и извлечения данных, мы создадим еще одну базу данных - точную копию тестируемой. Вместо того чтоб вручную генерить наборы тестовых данных - мы будем генерить их "на лету" и сравнивать полученные результаты с теми которые получаются при работе тестируемого метода. Генерация "на лету" будет осуществляться с помощью отправки "правильных" запросов в нашу вторую БД. Изолированность тестов мы сможем гарантировать с помощью использования транзакций. Итак, приступим.

phpunit.xml

Конфиг для первой БД должен подгружаться при тестах по "основному" алгоритму. Конфиг для второй БД подключим через XML-конфиг PHPUnit. Добавим к файлу phpunit.xml блок в который поместим в глобальные переменные реквизиты подключения ко второй БД, например как здесь:

Копирование структуры

Скопировать структуру БД можно несколькими способами - можно командой mysqldump | mysql, можно пробежаться по базе и выполнить CREATE TABLE db_check.t1 LIKE db_test.t1, а можно аналогично пробежаться по таблицам исходной БД, выполнить SHOW CREATE TABLE t1 и полученный результат отправить в тестовые базы данных. Первый вариант мне не нравится, так как я не люблю не кроссплатформенный exec если есть альтернативы. Второй вариант работает только в рамках одной СУБД (что не всегда подойдет на практике). Поэтому остановимся на третьем варианте и доработаем адаптер базы данных.

public function createDbLike($dbName, Db_Adapter $db) { $tables = $db->fetchCol(' SELECT TABLE_NAME FROM information_schema.tables WHERE TABLE_SCHEMA = '.$db->quote($dbName) ); $this->query('SET foreign_key_checks = 0'); foreach ($tables as $tblName) { $createRow = $db->fetchRow('SHOW CREATE TABLE `'.$dbName.'`.`'.$tblName.'`'); $createStmt = preg_replace('/AUTO_INCREMENT=[\d]+/', '', $createRow['Create Table']); $this->query('DROP TABLE IF EXISTS `'.$tblName.'`'); $this->query($createStmt); } $this->query('SET foreign_key_checks = 1'); }

AUTO_INCREMENT автоинкремент удаляем чтоб содержимое таблиц "донора структуры" не влияло на генерацию наших id в тестах.

bootstrap

Теперь доработаем код, исполняющийся перед запуском всех тестов. Добавим инициализацию баз данных из дампа.

//одна база должна инициализироваться "как обычно" $dbTest = Db_Adapter::getDefaultAdapter(); //вторую базу вручную "подключаем" $dbCheck = Db_Adapter::factory('Pdo_Mysql', array( 'host' => $GLOBALS['DBCHECK_HOST'], 'username' => $GLOBALS['DBCHECK_USERNAME'], 'password' => $GLOBALS['DBCHECK_PASSWORD'], 'dbname' => $GLOBALS['DBCHECK_DBNAME'], )); //копируем структуры из какой-то основной базы. $dbTest->createDbLike($config['db']['general_struct_dbname'], $dbTest); $dbCheck->createDbLike($config['db']['general_struct_dbname'], $dbTest); //берем дамп и загружаем его в базы $sql = file_get_contents($GLOBALS['DATABASE_DATA']); $dbTest->query($sql); $dbCheck->query($sql); $GLOBALS['DBCHECK_ADAPTER'] = $dbCheck;

В моем проекте базы лежат в одной СУБД, поэтому при копировании структур я не передавал еще один коннект, но в общем случае это может понадобиться. Если дамп очень большой и не помещается в память или больше чем max_query_size - можно побить его на несколько файлов, либо вызывать внешнюю утилиту mysql.

Доработка PHPUnit_Framework_TestCase

Добавляем begin transaction в setUp(), rollback в tearDown() и добавляем функционал сравнения таблиц.

abstract class PHPUnit_Db_Framework_TestCase extends PHPUnit_Framework_TestCase { /** * @var Zend_Db_Adapter_Pdo_Mysql_Ne */ protected $dbTest; /** * @var Zend_Db_Adapter_Pdo_Mysql_Ne */ protected $dbCheck; protected $rollBackAfterTest = true; public function __construct($name = null, array $data = [], $dataName = '') { parent::__construct($name, $data, $dataName); $this->dbTest = Zend_Db_Table_Abstract::getDefaultAdapter(); $this->dbCheck = $GLOBALS['DBCHECK_ADAPTER']; } public function setUp() { parent::setUp(); if ($this->rollBackAfterTest) { $this->dbTest->beginTransaction(); $this->dbCheck->beginTransaction(); } } public function tearDown() { parent::tearDown(); if ($this->rollBackAfterTest) { $this->dbTest->rollBack(); $this->dbCheck->rollBack(); } } public function assertEqualsDbTable($tblName) { $e = $this->dbCheck->fetchAll('SELECT * FROM `'.$tblName.'`'); $a = $this->dbTest->fetchAll('SELECT * FROM `'.$tblName.'`'); $this->assertEquals($e, $a); } }

Не нужно забывать что при сравнии таблиц все данные будут выгружаться в память при работе тестов, поэтому, разумеется, не стоит делать слишком большие дампы для тестов. Как вариант - доработать функцию сравнения, но тогда можно получить уже долгое выполнение тестов.

Пишем тесты

Теперь внешний вид тестов будет напоминать нам типичные функциональные тесты. Например, в одном моем проекте используется "мягкий" подход к входящим параметрам и ненужные просто не используются без вызова Exception. Для проверки правильности в адаптер БД будем посылать правильный запрос и сравнивать результирующие наборы.

public function testAdd() { $k = new Keyword(); //typical usage $id = $k->add(array('word' => 'foo')); $this->dbCheck->insert('keyword', array('word' => 'foo')); $this->assertEqualsDbTable('keyword'); $this->assertEquals(12, $id); //usage with unneeded fields $id = $k->add(array('word' => 'bar', 'baz' => 'foo')); $this->dbCheck->insert('keyword', array('word' => 'bar')); $this->assertEqualsDbTable('keyword'); $this->assertEquals(13, $id); }

Проверка SELECT-запросов может выглядеть так:

/** * @param array $params * @param string $sql * @dataProvider providerGetKeywordList */ public function testGetKeywordList(array $params, $sql) { $k = new Keyword(); $data = $k->getKeywordList($params); $expected = $this->dbCheck->fetchAll($sql); $this->assertEquals($expected, $data['data']); $this->assertInternalType('array', $data); $this->assertInternalType('array', $data['data']); } public function providerGetKeywordList() { //typical usage $data1 = array( array('fields' => array('id', 'word')), 'SELECT id, word FROM keyword' ); //empty input $data2 = array( array(), 'SELECT id FROM keyword' ); //usage with limit $data3 = array( array('fields' => array('word'), 'limit' => 3), 'SELECT word FROM keyword LIMIT 3' ); return array($data1, $data2, $data3); }

Таким же образом можно тестировать методы, динамические генерирующие сложные SELECT-запросы с множеством JOIN-ов.

Как тестировать методы использующие транзакции

Например, я реализовал так:

class ClassWithTransactionsTest extends PHPUnit_Db_Framework_TestCase { /** * DISCLAMER: Be careful because in this test auto rollback turned off */ protected $rollBackAfterTest = false; /** * @param array $data * @param array $sql * @dataProvider providerSetSomething */ public function testSetSomething(array $data, array $sql) { $s = new ClassWithTransactions(); $s->setSomething($data); $this->dbCheck->beginTransaction(); foreach ($sql as $v) $this->dbCheck->query($v); $this->dbCheck->commit(); $this->assertEqualsDbTable('sometable'); } public function providerSetSomething() { //... //typical $data2 = array( array('user_id' => 1, 'project_ids' => array(1,4,5)), array( 'DELETE FROM sometable WHERE user_id = 1', 'INSERT INTO sometable (user_id, project_id) VALUES (1,1),(1,4),(1,5)' ) ); //... return array( //$data1, $data2, //$data3, $data4 ); }

Разумеется стоит учитывать что в этом наборе тестов каждый предыдущий тест влияет на следуюший. Можно, например, вручную "сбрасывать" данные перед тестами, которые этого потребуют. Либо просто создать отдельный тест который уже не требует отмены транзакций.

Если БД не поддерживает транзакции

Первое что приходит на ум - разбить дамп по csv-таблицам и в setUp() и tearDown() использовать комбинацию TRUNCATE и загрузки дампа.

Минусы метода "двух баз"

Минусы вполне очевидные - скорость работы. Если сильно увлекаться размерами данных или количеством тестов - вполне можно упереться в адекватную скорость отработки тестов. Тем не менее для себя я оставил этот вариант как основной, т.к. современные СУБД на небольших объемах данных отрабатывают весьма быстро, более того такие объемы легко кешируются операционной системой что еще ускоряет каждого следующего теста на "прогретых" данных. Ну и не стоит забывать про возможность тестирования отдельных классов. А полное комплексное тестирование оставить на запуск раз в две недели перед деплоем :)