PHPUnit и его Database Extension. Беглый взгляд

    Пространное и многословное вступление


    Уже чуть более года в проекте где я работаю ходят разговоры о модульном тестировании. Помимо разговоров неоднократно делались попытки превратить эти разговоры в жизнь. Все попытки на данный момент закончились тем, что ни один ранее написанный модульный тест не прогоняется в процессе разработки. Все они лежат мертвым кодом в недрах нашей системы. Представили такой себе постсоветский индастриэл? Торчащие колонны из земли, ржавая арматура на фоне хмурого неба:)

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



    Плавный переход к практической стороне вопроса


    Это все лирика. Теперь пойдет больше по делу. Как можно понять проект написан на php, не маленький и живет уже по меньшей мере больше года(а в действительности 4 года).

    Настали времена когда даже самые упрямые и «технически грамотные» заказчики стали понимать необходимость в качестве кода на котором так настаивают сочувствующие PM-ы. Сверху пришла «указивка» заняться модульным тестированием по настоящему. Даже задачи в жиру положили соответствующие.

    Итак основываясь на нашем опыте прежних неудач мы выделили ряд проблем:
    1. Проблема фикстур. Не все что берется из базы можно заменить
      моками. Связанно это с некоторой безсистемностью работы с базой. У нас нет ORM и все функции последнего между делом выполняет модель.
    2. Скорость прогона тестов. Последняя попытка внедрения захлебнулась
      отчасти из-за этого, а отчасти и …
    3. Поддерживаемость. … из-за неподдерживаемости причиной которой
      стала проблема из п.1 решая которую мы оснастили наши тесты
      многострочными инсертами и апдейтами.

    Мы обратились к компетентному в этом вопросе коллеге — Java-разработчику. Он то нам и поведал о таких инструментах как JUnit и DbUnit. Он же нам и рассказал о процессе который они используют с участием этого инструментария: во время разработки они прогоняют тесты на in memory базах данных, решая таким образом задачу скорости работы тестов увеличивая вероятность их более частого запуска, перед коммитами они запускают те же тесты на реальной базе и потом еще добивают баги continious integration системой.

    xUnit фреймворк(SimpleTest) мы использовали и ранее, а вот о DbUnit мы слышали впервые. Собственно о нем и пойдет речь далее. Для php есть реализация DbUnit в виде расширения к PHPUnit. Смысл его использования в том что все фикстуры готовятся в виде xml-файлов и заливаются в тестовую базу перед запуском каждого тестового метода. Это по моему мнению решает задачу поддерживаемости фикстур, потому как все же xml более читаемый чем sql-дампы.

    Практический пример


    Предположим есть у нас таблица person
    1. CREATE TABLE person (
    2.      id INTEGER PRIMARY KEY AUTOINCREMENT,
    3.      name VARCHAR(255),
    4.      surname VARCHAR(255)
    5. );


    Для того что бы что-то в ней тестировать нужно что бы она была наполнена какими-то данными. В Database Extension PHPUnit наполнение описывается в xml формате, по одному элементу на каждую строку в таблице. Имя элемента должно соответствовать имени таблицы, а имена атрибутов — именам полей таблицы.

    Создадим такой себе persons.xml с фикстурой для этой таблицы:
    1. <?xml version="1.0" encoding="UTF-8" ?>
    2. <dataset>
    3.     <person
    4.         id="1"
    5.        name="Nalabal"
    6.        surname="Nadjava"/>
    7. </dataset>



    Теперь внимание тест кейс:
    1. require_once 'PHPUnit/Extensions/Database/TestCase.php';
    2. require_once 'PHPUnit/Framework.php';
    3. class PersonTest extends PHPUnit_Extensions_Database_TestCase
    4. {
    5.     public function __construct()
    6.     {
    7.         $this->connection = new PDO('sqlite::memory:');
    8.         $this->connection->query("
    9.            CREATE TABLE person (
    10.                id INTEGER PRIMARY KEY AUTOINCREMENT,
    11.                name VARCHAR(255),
    12.        surname VARCHAR(255)
    13.    );
    14.        ");
    15.  
    16.     }
    17.     protected function getConnection()
    18.     {
    19.         return $this->createDefaultDBConnection($this->connection, 'sqlite');
    20.     }
    21.  
    22.     protected function getDataSet()
    23.     {
    24.         return $this->createFlatXMLDataSet(dirname(__FILE__).'/persons.xml');
    25.     }
    26. }


    Очевидно — тест ничего не тестирует, но что то там мы уже понакодили:
    Он унаследован от PHPUnit_Extensions_Database_TestCase в котором уже реализованы некоторые методы, в том числе setUp (очищает базу и заполняет ее вновь) и tearDown — который делает тоже самое. В доке по PHPUnit более детально расписано, то как эти методы взаимодействуют с тестовыми методами. В конструкторе для простоты я создаю вышеуказанную табличку и в inmemory базе данных. После я переопределяю два абстрактных метода Database_TestCase. Первый возвращает объект соединения с базой, а второй объект созданный из нашей xml-фикстуры. Эти методы используются в определенных в Database_TestCase setUp и tearDown методах.

    Теперь проверим, добавив новый метод
    1. public function testPerson ()
    2. {
    3.     $sql = "SELECT * FROM person";
    4.     $statement =
    5.         $this->getConnection()->getConnection()->query($sql);
    6.     $result = $statement->fetchAll();
    7.     $this->assertEquals(1, sizeof($result));
    8.     $this->assertEquals('Nalabal', $result[00]['name']);
    9. }


    Результат: OK (1 test, 2 assertions)
    В методе выбираем все записи из таблицы persons и проверяем выборку на соответствие нашей фикстуре

    Мне в конкретном случае понадобилось добавлять некоторые данные во время выполнения конкретного тестового метода, не найдя ничего в доке я поковырялся слегонь в коде и пришел к следующему решению:

    Добавляем новую фикстуру additionalPersons.xml
    1. <?xml version="1.0" encoding="UTF-8" ?>
    2. <dataset>
    3.     <person
    4.        id="2"
    5.        name="Nageneril"
    6.        surname="Mudrapragram"/>
    7.     <person
    8.        id="3"
    9.        name="Strudomprassal"
    10.        surname="Vashapragram"/>
    11. </dataset>


    И пишем такой тестовый метод:
    1. public function testAdditionalPerson ()
    2. {
    3.     $insertOperation = PHPUnit_Extensions_Database_Operation_Factory::INSERT();
    4.     $insertOperation->execute($this->getConnection(), $this->createFlatXMLDataSet(dirname(__FILE__).'/additionalPersons.xml'));
    5.     $sql = "SELECT * FROM person";
    6.     $statement = $this->getConnection()->getConnection()->query($sql);
    7.     $result = $statement->fetchAll();
    8.     $this->assertEquals(3, sizeof($result));
    9.     $this->assertEquals('Nalabal', $result[00]['name']);
    10.     $this->assertEquals('Nageneril', $result[1]['name']);
    11.     $this->assertEquals('Strudomprassal', $result[2]['name']);
    12. }


    В 3й строке создаем с помощью Operation_Factory (вопрос с Операциями в официальной доке пока еще не описан) объект вставки который реализовывает интерфейс PHPUnit_Extensions_Database_Operation_IDatabaseOperation у которого есть только один метод имеющий следующую сигнатуру:

    1. public function execute(PHPUnit_Extensions_Database_DB_IDatabaseConnection $connection, PHPUnit_Extensions_Database_DataSet_IDataSet $dataSet);


    Его то мы и вызываем в 4й строке предыдущего листинга, а он в свою очередь дополняет нашу первоначальную фикстуру из persons.xml данными из additionalPersons.xml

    Результатом запуска будет: OK (2 tests, 6 assertions)

    Заключение


    Вероятно что в жизни все не так сложно как в этом примере. Потому прошу не поддавать искусственность описываемого критике.
    По мере развития ситуации мне хотелось бы посвящать Вас во все допустимые детали как возможность запомнить все раскопанное и неописанное в документации.
    Конечно же, только при условии, что это кому-нибудь нужно(это я в скором времени пойму).
    Share post

    Comments 29

      0
      а мы делаем немного по-другому

      создан класс который умеет создавать базу данных, наполнять ее и удалять базу данных. создание идет в setUp, каждый тест может наполнить ее теми данными которые ему нужны, протестировать работу, удаление в tearDown.

      итого каждый тест абсолютно независим от других
        0
        Ну мы в целом к тому варианту который вы описали тоже пришли, но потом посоветовались с вышеназванным java-developer и он нам подсказал :)
          0
          а что ж у Вас там за запросы которые требуют inmemory базу? night-тестирование сделайте, все спят — оно себе тестирует…

            0
            Да не — in memory исключительно для того что бы как можно быстрее пробегали тесты. Подправил код — запустил — если что не так опять подправил — опять запустил и так до тех пор пока не попадешь в зеленую полосу.
            А ночью тесты на hudson бегут :), но этого мало
              +2
              у меня тест с использованием базы выполняется менее секунды. если перевести на инмемори — опять же будет менее секунды. если учесть сколько времени тратится на все остальное, то использование инмемори базы не считаю актуальным лично для себя. и не понимаю зачем оно вам.

              хотя статью считаю полезное ибо она показывает альтернативу.
                0
                Ну как бы да… in memory не ключевая фишка этого решения :) нам тоже пока не принципиально, но в предшествующих попытках время ожидания было неприемлемым, ну и тесты собственно были реализованы соответственно :)
                  0
                  Не всем же так везёт…

                  У нас тесты на одной сырой системе проходят за три минуты с гаком.

                  БД в памяти очень бы помогла. Но пока переводить тесты на такие моки не будем.
            0
            В нашем проекте каждый тест выполняется в отдельной транзакции, и программисты избавлены от необходимости следить за созданными объектами и их удалением.
              0
              Идея на 5 с плюсом.
            0
            Сейчас как раз планирую заняться изучением и применением BDD, TDD в ruby, но и в php было бы хорошо подобную практику перенести. Буду следить за вашими статьями :)
              0
              А для reading так разве можно делать:
              $result[]['name']
              ?
              PHP говорит FatalError
                0
                Опечатка, момент исправлю, спасибо
                0
                А почему вы коннекшин создаете в конструкторе, а не в методе getConnection(), как описано в документации. Если думаете, что сэкономите, исплняя конструктор только один раз для всего тест кейса, то вы ошибаетесь — каждый тест исполняеться на новом экземпляре.
                  0
                  Нет я не думал сэкономить. Он создается там с целью что бы можно было создать таблицу persons в базе.
                  Собственно в таком случае создается ли соединение в конструкторе или в методе — вопрос не принципиальный. А в документации тоже иногда примеры не из жизни бывают :) как собственно пример рассмотренный в статье :)
                  +1
                  Внесу свою лепту.

                    0
                    Внесу свою лепту.

                    «Проблема фикстур. Не все что берется из базы можно заменить моками.»
                    Почему это? Что такого есть в базе, чего нельзя описать в модели.
                    Мы для этих целей используем DDD и принципы построения domain-модели на основе репозиториев и НЕ тестируем поведение слоя, который обращается к базе (ORM).

                    «Связанно это с некоторой безсистемностью работы с базой.»
                    Что за бессистемность?

                    «У нас нет ORM и все функции последнего между делом выполняет модель.»
                    Модель — это концептуальный блок. Причем тут функции ORM? Очевидно, у вас нет «чистой модели домена» (POCO), модель «знает» о способах хранения ее самой в базе. Но ORM — это всего лишь механизм доступа к базе и превращения реляционных данных в объекты-сущности. Если ваша модель знает об этом, значит в ней так или иначе в каком-либо виде должены быт реализован паттерн Unit Of Work (но зачем, ведь ORM делают это за нас, и позволяют править запросы для оптимизации).

                    «Скорость прогона тестов. Последняя попытка внедрения захлебнулась
                    отчасти из-за этого, а отчасти и …
                    Поддерживаемость. … из-за неподдерживаемости причиной которой
                    стала проблема из п.1 решая которую мы оснастили наши тесты
                    многострочными инсертами и апдейтами.»

                    Уточните немного эти пункты?
                      0
                      Система долгое время (около 3 лет) разрабатывалась абы как. И это тот случай когда Фаулер писал, что лучше переписать заново, но… система в продакшне и функционала тьма и не мы решаем когда переписывать, а когда делать что-то другое.

                      «Что за бессистемность?»
                      У нас нет ORM и все функции последнего МЕЖДУ ДЕЛОМ выполняет модель. Между делом — это значит как получилось, без всякого осознанного применения шаблонов.

                      «Причем тут функции ORM?» «НЕ тестируем поведение слоя, который обращается к базе (ORM).»
                      Вот при том :) Если бы он у нас был — мы бы могли его замокать — это значительно проще сделать чем замокать абстракцию БД вроде PDO(хотя и последний тоже реально, но неоправданно дорого). А слой который у нас обращается к базе и есть тот слой который просто необходимо тестировать, по причине того что он содержит бизнес логику.

                      по последнему цитированию:
                      Написали тесты в которых непосредственно готовили базу к асертам :) потом запутались какие данные какой тест за собой не убрал и забросили это дело.

                      Вообще все что Вы написали — это все правильно и так и должно быть в ИДЕАЛЕ. И мы рады бы последовать совету Мартина и переписать все, но реальный мир полон заказчиков и клиентов (пользователей сервисов заказчика) и как мы знаем из-за этого далек от идеала :)
                        0
                        Ну как сказать, все делается. Перевод legacy-системы «в TDD» вполне возможен, но тут должен решить тот, кто принимает решения по архитектуре и способам организации проекта.

                        По поводу бизнес-логики и модели — это уровень BLL или DL (Domain Logic) — он тестируется обязательно.
                        Остальное — базу и ORM — тестировать не нужно. Мокать их не нужно, а нужно тестировать состояние (а не поведение). Это значительно проще.

                        Вам реально нужно рассмотреть, как отделить уровень BLL от DAL (Data Access Level). Очень советую потихоньку внедрять DDD в процесс — сильно поможет разделить различные уровни модели. Сама бизнес-логика должна понятия не иметь о том, как она хранится — этим занимается отдельный уровень модели домена — репозитории. В итоге, можно протестировать ВСЮ логику с Fake-репозиториями, которые находятся в памяти. При этом используются тесты состояния — чтобы не «влазить» в то, как репозитории получают данные. А затем, когда все работает, пишете НЕ-Fake репозитории с тем же интерфейсом и используете готовый набор тестов как интеграционные тесты (т.е. они реально будут обращаться к базе в этот момент — но это нам и нужно, мы ведь тестируем интеграцию, тут мокать ничего не надо, а бизнес-логика уже протестирована, преследует все принципы PI (Persistence Ignorance).

                        Я еще об этом напишу тут на Хабре, когда сделаю статью про то, как мы создавали наш магазин, где использовали все эти методы.
                          0
                          Сегодня 2010-ый, обещание не выполнено :)
                            +1
                            Сегодня 2012-ый, обещание не выполнено :)
                              0
                              Хаха, подловили) Ну на самом деле просто руки не дошли еще тогда в 2009м. Интересно, а почему пропустили коммент в 11-м году? :)
                              +1
                              Сегодня 2013-ый, обещание не выполнено :)
                                +1
                                Извините, традиция
                                  0
                                  Хаха, круто) Ну, на самом деле намерение было тогда реально, а сейчас уже столько воды утекло — поменялось и видение, и взгляд на это дело, и подходы. И основной tech-stack.

                                  Так что для меня это такое невыполнимое обещание. Но можете напоминать раз в год :)
                                  0
                                  Сегодня 2014…
                                    0
                                    2015 год. Господа, не забываем обновлять напоминалки в календаре.
                                      0
                                      Уже 2016, я внимательно слежу за темой
                                    0
                                    интересно, как можно быть увереным в работоспособности ORM, не выполняя тестов? (например в результате изменения структуры базы)

                                    ИМХО не тестировать ORM нельзя.
                                      0
                                      1. ORM протестирована тем, кто ее разрабатывает и поддерживает. Вы принимаете ее, когда уверены в ее работоспособности.

                                      2. У вас тестируется бизнес-логика, которая должна правильно работать и пока тесты срабатывают — все будет ок. Перестать срабатывать они могут в том числе, когда что-то нарушено на уровне DAL.

                                      3. DAL тестируется (!!!) и это важно — вы используете тесты состояния, которые тестируют логику работы репозиториев и заодно являются интеграционными тестами. То есть, пока вы получаете нужные данные, исключения и ответы от DAL уровня — вы уверены, что все работает правильно. Если вы меняете таблицу — и ничего не ломается — все ок. Но если хотите быть уверенными — пишите интеграционные тесты, которые проверяют данные и структуру на целостность (но на практике это зачастую лишнее — вы по сути тестируете не логику, не код, а структуру — но ведь вы таковой ее и создали и если это не ломает логику — все будет ок. Ломается логика — станут красными несколько тестов, ее проверябщих). Так что никаких противоречий я тут не вижу.

                                  Only users with full accounts can post comments. Log in, please.