Интеграционные тесты с заготовленной БД

Я в последнее время всё больше люблю писать интеграционные (API) тесты — запускаю половину приложения, но не привязан к UI. Это золотая середина между очень медленными end-to-end тестами и очень быстрыми unit-тестами. Рассмотрим особый случай таких тестов, которые используют заготовленные данные под каждый тест.

Такие тесты приходится создавать, когда проект становится таких масштабов, что тесты с одной БД начинают конфликтовать между собой и становятся нестабильными. То список где-то постоянно растёт, то ID у ресурса требуется фиксированный а у нас autoincrement, то данные хочется удалить.

Это особенно ярко видно в e2e тестах, где приходится управлять всем жизненным циклом данных что-бы тесты оставались рабочими. Управление всем циклом из создания-операции-удаления, вынуждает тесты делать зависимыми друг от друга, а значит становится невозможно запустить тест сам по себе.

Пример

Вот как выглядит моё решение этой проблемы..

use kurapov\tests\database\IsolatedDataIntegrationTestBase;
class UserIsolatedDataTest extends IsolatedDataIntegrationTestBase {
    /**
     * @test
     */
    function postRemove_UserByManager() {
        //$this->db->execute(file_get_contents(dirname(realpath(__FILE__)) . '/' . __CLASS__ . '/' . __FUNCTION__ . '.sql'));
        $this->db->execute(
"INSERT INTO `user` (`id`, `email`, `password`) VALUES (1,'[email protected]','553ae7da92f5505a92bbb8c9d47be76ab9f65bc2');
INSERT INTO `user` (`id`, `email`, `password`) VALUES (2,'[email protected]','f4542db9ba30f7958ae42c113dd87ad21fb2eddb');"
        );
        $this->loginAs('[email protected]');
        $result = $this->curlPOST($this->baseURL . 'User/remove', ['id' => 2]);
        $this->assertNotContains('error', $result);
        $this->assertEquals("{'status':'ok'}", $result, $result);
    }
}

В данном случае при запуске теста, схема БД уже существует — она изолирована и чиста. Я лишь добавляю данные и делаю curl запрос. Я не проверяю итоговое состояние в БД в данном случае. Если SQL очень длинный, я могу вынести его в отдельный файл.

Поскольку этот тест одновременно занимается и сетевыми запросами (curlPOST функция) и подготовкой БД, то сам класс наследует написанный мною IntegrationTestBase и IsolatedDataIntegrationTestBaseсоответственно. Если бы я напрямую работал с функцией, без сетевых запросов, возможно я мог бы использовать DBUnit.

Пишу я в сыром SQL для mysql, абстрагированием (скажем с doctrine) и переключением на in-memory БД я не занимаюсь. Вместо этого, процесс подготовки у меня такой:

  • До запусков всех тестов прогоняются все миграции что-бы иметь up-to-date схему
  • В фазе setUp теста — удаляем тестовую БД
  • Копируем всю схему базы проекта в новую тестовую БД, без данных
  • Для конкретного теста запускаем SQL для добавления специфичных данных (Пользователи, данные, связки)
  • Запускаем сам тест, который делает сетевой запрос
  • В сетевом запросе — указываем дополнительный параметр, который в конфигурации переключает наш проект на тестовую БД (только для локального запуска)
  • Если тест падает — у нас в БД остаётся состояние тестовой БД, можно посмотреть

А так выглядит файлик подготавливающий тестовую БД..

namespace kurapov\tests\database;
use IntegrationTestBase;
use PDO;
class IsolatedDataIntegrationTestBase extends IntegrationTestBase {
    const DEV_DBNAME  = "myproject";
    const TEST_DBNAME = "myproject-test";
    public function setUp() {
        $this->db = new \kurapov\Database(new \PDO(
            'mysql:host=127.0.0.1;dbname=' . self::DEV_DBNAME . ";charset=utf8",
            'root','',
            [
                \PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
                \PDO::ATTR_PERSISTENT         => true,
                \PDO::ATTR_ERRMODE            => \PDO::ERRMODE_EXCEPTION
            ]
        ));
        $this->db->execute("DROP DATABASE IF EXISTS `" . self::TEST_DBNAME . "`;");
        $this->duplicateDB();
        $this->db = new \kurapov\Database(new \PDO(
            'mysql:host=127.0.0.1;dbname=' . self::TEST_DBNAME . ";charset=utf8",
            'root','',
            [
                \PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
                \PDO::ATTR_PERSISTENT         => true,
                \PDO::ATTR_ERRMODE            => \PDO::ERRMODE_EXCEPTION
            ]
        ));
    }
    public function tearDown() {
//        $this->db->execute("DROP DATABASE `" . self::TEST_DBNAME . "`;");
    }
    protected function curlPost($url, $data, $useCookie = true) {
        $data['isolated_db'] = self::TEST_DBNAME;
        return parent::curlPost($url, $data, $useCookie);
    }
    protected function curlGET($url, $useCookie = true) {
        return parent::curlGET($url . '&isolated_db=' . self::TEST_DBNAME, $useCookie);
    }
    private function duplicateDB() {
        $tables = $this->db->execute("SHOW TABLES;");
        $this->db->execute("CREATE DATABASE `" . self::TEST_DBNAME . "`;");
        foreach ($tables as $table) {
            $tableName = $table['Tables_in_' . self::DEV_DBNAME];
            $this->db->execute("CREATE TABLE `" . self::TEST_DBNAME . "`.`$tableName` LIKE `" . self::DEV_DBNAME . "`.`$tableName`;");
        }
    }
    public function copyTable($table) {
        $this->db->execute(
            "INSERT INTO `" . self::TEST_DBNAME . "`.$table
            SELECT * FROM `" . self::TEST_DBNAME . "`.$table"
        );
    }
}

Итого

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

Достоинства

  • Тесты становятся стабильней, т.к. меньше зависимости друг от друга и данные не затираются/не добавляются
  • Изолированные тесты быстрей выполняются, чем цепочка запросов/тестов
  • Написание SQL для тестов начинает влиять на проектирование БД
  • В случае падения теста, можно посмотреть состояние тестовой БД именно в этом контексте
  • Потенциально можно расширить применение на e2e тесты либо распараллелить запуск используя отдельную БД на каждый тест или поток

Недостатки

  • Надо подготавливать для каждого теста свои данные в SQL это неприятно
  • Надо поддерживать этот SQL если вы измените схему и она затрагивает тест