SQL и Qt

Тестирование кода, работающего с БД: тестовые БД, фикстуры, транзакционный rollback

Введение

Основной цикл статей уже разбирал автоматизированное тестирование (статья 6) — в этой статье разберём специфику тестирования кода, взаимодействующего с реальной БД: использование отдельной тестовой БД, подготовку тестовых данных (фикстур), и паттерн «транзакция плюс откат» для изоляции тестов друг от друга без необходимости полной пересоздания тестовой БД перед каждым отдельным тестом.

Концепция

Тестирование кода, работающего с БД, по умолчанию не должно затрагивать production-данные — для этого используется отдельная, изолированная тестовая БД (часто SQLite в памяти, статья 425, для скорости, либо полноценная отдельная установка той же СУБД, что используется в production, для максимальной достоверности тестов относительно реального поведения). Фикстуры — заранее подготовленный, известный набор тестовых данных, загружаемых в тестовую БД перед выполнением тестов, дающий предсказуемую, контролируемую основу для проверки логики, зависящей от конкретного состояния данных. Паттерн «начать транзакцию перед тестом, откатить после» даёт эффективную изоляцию между отдельными тестами без необходимости полностью пересоздавать и заново заполнять тестовую БД перед каждым отдельным тестовым случаем — изменения, сделанные одним тестом, автоматически отменяются откатом транзакции, не влияя на следующий тест.

Пример кода: Тестирование кода, работающего с БД

// Тестовый класс с паттерном "транзакция + откат" для изоляции тестов друг от друга
#include <QtTest>
#include <QSqlDatabase>
#include <QSqlQuery>

class OrderRepositoryTest : public QObject
{
    Q_OBJECT
private slots:
    void initTestCase() // выполняется ОДИН раз перед всеми тестами этого класса
    {
        m_db = QSqlDatabase::addDatabase("QSQLITE");
        m_db.setDatabaseName(":memory:"); // SQLite полностью в памяти — максимально быстро для тестов
        m_db.open();

        QSqlQuery query(m_db);
        query.exec("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT)");
        query.exec("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, total_amount REAL)");
    }

    void init() // выполняется перед КАЖДЫМ отдельным тестом
    {
        m_db.transaction(); // начало транзакции — все изменения этого теста будут откачены после него

        // Загрузка фикстур — известного, контролируемого набора тестовых данных
        QSqlQuery query(m_db);
        query.exec("INSERT INTO customers (id, name) VALUES (1, 'Тестовый клиент')");
    }

    void cleanup() // выполняется после КАЖДОГО отдельного теста
    {
        m_db.rollback(); // откат — данные, изменённые этим тестом, не влияют на следующий тест
    }

    void testCreateOrderForExistingCustomer()
    {
        QSqlQuery query(m_db);
        query.prepare("INSERT INTO orders (customer_id, total_amount) VALUES (?, ?)");
        query.addBindValue(1);
        query.addBindValue(500.0);
        QVERIFY(query.exec());

        query.exec("SELECT COUNT(*) FROM orders WHERE customer_id = 1");
        query.next();
        QCOMPARE(query.value(0).toInt(), 1);
    }

private:
    QSqlDatabase m_db;
};

QTEST_MAIN(OrderRepositoryTest)
#include "orderrepositorytest.moc"

Пояснения к коду

Разделение на initTestCase() (один раз, создание схемы), init()/cleanup() (перед/после каждого отдельного теста, транзакция и фикстуры) — стандартный для Qt Test (и концептуально похожий в других фреймворках тестирования) паттерн жизненного цикла тестового класса, явно разделяющий дорогие, разовые операции (создание схемы) от более частых, но обычно значительно более лёгких операций подготовки/очистки состояния для каждого отдельного теста. m_db.transaction()/m_db.rollback() в init()/cleanup() показывают конкретную реализацию паттерна «транзакция плюс откат» — каждый отдельный тест выполняется внутри собственной транзакции, начатой непосредственно перед ним и откатываемой непосредственно после, что гарантирует полную изоляцию между тестами (изменения одного теста никогда не «просачиваются» в следующий) значительно эффективнее, чем полное пересоздание и повторное заполнение всей тестовой БД перед каждым отдельным тестом.

Подводные камни

  • Тестирование непосредственно на реальной production-БД «для удобства» или из-за лени настроить отдельное тестовое окружение — это крайне рискованная практика, способная привести к реальной потере или повреждению production-данных из-за тестового кода, особенно учитывая, что тестовый код по своей природе часто проверяет именно граничные, потенциально разрушительные случаи (тестирование DELETE, ошибочных данных), и отдельная, изолированная тестовая БД (даже если это просто SQLite в памяти, как в примере) — обязательная, не опциональная мера предосторожности.
  • Использование SQLite для тестов кода, написанного и реально развёртываемого с PostgreSQL/MySQL/другой СУБД (статьи 423-426) без проверки реальной совместимости диалектов (статья 427) — тесты, успешно проходящие на SQLite, могут пропустить проблемы, специфичные именно для реальной, production СУБД (различия синтаксиса, поведения NULL, специфичных функций), и для критичных по корректности участков кода стоит явно дополнить SQLite-тесты (быстрые, удобные для частого запуска) тестами на реальной, целевой СУБД (медленнее, но точнее отражающей реальное production-поведение), хотя бы для части критичных тестовых случаев.
  • Отсутствие достаточно представительных, реалистичных фикстур, ограничивающихся минимальным, «счастливым путём» набором тестовых данных без покрытия граничных случаев (NULL значения, статья 396; очень длинные строки; специальные символы, потенциально проблемные для SQL-инъекций, статья 433, если параметризация где-то была упущена) — фикстуры должны явно включать репрезентативные граничные случаи, релевантные для конкретной тестируемой логики, а не только простейшие, «идеальные» тестовые данные.
  • Неучёт побочных эффектов триггеров (статья 410) или хранимых процедур (статья 411) при подготовке фикстур и проверке результатов тестов — если тестируемая логика неявно зависит от срабатывания триггера (как, например, денормализованный счётчик из статьи 403, обновляемый триггером), тест должен либо явно проверять и учитывать это побочное действие, либо явно создавать тестовое окружение, где такие триггеры присутствуют и корректно функционируют так же, как в production, а не предполагать, что тестовая схема БД (особенно при использовании более простой СУБД для тестов, как SQLite, не всегда полностью поддерживающей все возможности production-СУБД) идентична по поведению production-схеме без явной проверки этого соответствия.