SQL и Qt

Тестирование слоя данных: in-memory SQLite и mock-репозитории

Введение

Бизнес-логика, зависящая от репозиториев (статья 231), должна быть тестируема без необходимости поднимать реальную, полноценную базу данных для каждого запуска тестов. В статье разберём два взаимодополняющих подхода: тестирование самих SQL-репозиториев через in-memory SQLite (быструю, временную базу данных, существующую только в памяти процесса) и тестирование бизнес-логики, использующей репозитории, через mock-реализации интерфейсов, не обращающиеся к реальной СУБД вовсе.

Концепция

SQLite поддерживает специальный режим, при котором база данных существует целиком в памяти процесса (:memory: как имя файла базы данных) — она создаётся пустой при открытии соединения и полностью уничтожается при его закрытии, что идеально подходит для тестов: каждый тест может работать с гарантированно чистой базой данных, без необходимости очищать состояние после предыдущих тестов и без зависимости от файловой системы. Для тестирования кода более высокого уровня, который сам зависит от repository через интерфейс (а не от конкретной реализации SQL), удобнее использовать in-memory mock-реализацию (паттерн из основного цикла статей про тестирование, статья 53), вообще не выполняющую никакого SQL.

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

// test_sqluserrepository.cpp — тестирование самого SQL-репозитория через in-memory SQLite
#include <QtTest/QtTest>
#include <QSqlDatabase>
#include <QSqlQuery>
#include "sqluserrepository.h"

class TestSqlUserRepository : public QObject
{
    Q_OBJECT
private slots:
    void init() // выполняется перед КАЖДЫМ тестом — гарантирует чистую базу данных
    {
        m_db = QSqlDatabase::addDatabase("QSQLITE", "test_connection");
        m_db.setDatabaseName(":memory:");
        QVERIFY(m_db.open());

        QSqlQuery query(m_db);
        QVERIFY(query.exec(
            "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, "
            "email TEXT, name TEXT, active INTEGER)"));
    }

    void cleanup()
    {
        m_db.close();
        QSqlDatabase::removeDatabase("test_connection");
    }

    void testSaveAndFindById()
    {
        SqlUserRepository repository(m_db);

        User newUser;
        newUser.email = "test@example.com";
        newUser.name = "Тестовый пользователь";
        newUser.active = true;

        QVERIFY(repository.save(newUser));
        QVERIFY(newUser.id > 0); // id должен быть заполнен после вставки

        auto found = repository.findById(newUser.id);
        QVERIFY(found.has_value());
        QCOMPARE(found->email, QString("test@example.com"));
    }

    void testFindByIdReturnsNulloptWhenNotFound()
    {
        SqlUserRepository repository(m_db);
        auto found = repository.findById(9999);
        QVERIFY(!found.has_value());
    }

private:
    QSqlDatabase m_db;
};

QTEST_MAIN(TestSqlUserRepository)
#include "test_sqluserrepository.moc"
// test_userregistrationservice.cpp — тестирование бизнес-логики через mock-репозиторий
#include <QtTest/QtTest>
#include "iuserrepository.h"
#include "userregistrationservice.h"

class MockUserRepository : public IUserRepository
{
public:
    std::optional<User> findById(int id) override { return m_users.value(id); }
    std::optional<User> findByEmail(const QString &email) override
    {
        for (const auto &[id, user] : m_users.asKeyValueRange()) {
            if (user.email == email) return user;
        }
        return std::nullopt;
    }
    QVector<User> findAll() override { return QVector<User>(m_users.begin(), m_users.end()); }

    bool save(User &user) override
    {
        if (user.id == 0) user.id = ++m_nextId;
        m_users[user.id] = user;
        return true;
    }

    bool remove(int id) override { return m_users.remove(id) > 0; }

    QHash<int, User> m_users;
    int m_nextId = 0;
};

class TestUserRegistrationService : public QObject
{
    Q_OBJECT
private slots:
    void testRejectsDuplicateEmail()
    {
        auto mockRepo = std::make_shared<MockUserRepository>();
        User existing;
        existing.email = "alice@example.com";
        mockRepo->save(existing);

        UserRegistrationService service(mockRepo);
        const bool result = service.registerUser("alice@example.com", "Алиса Вторая");

        QVERIFY(!result); // регистрация с уже существующим email должна быть отклонена
    }
};

QTEST_MAIN(TestUserRegistrationService)
#include "test_userregistrationservice.moc"

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

В TestSqlUserRepository метод init() (вызывается перед каждым тестом, как разбиралось в основном цикле статей про тестирование) создаёт новое in-memory подключение SQLite и сразу создаёт нужную таблицу — поскольку база данных существует только в памяти и привязана к конкретному соединению, каждый тест получает полностью изолированную, гарантированно чистую базу данных, не влияя на соседние тесты. Это даёт реальную проверку того, что SQL-запросы внутри SqlUserRepository корректны и работают именно с реальной СУБД (хоть и in-memory), а не просто проверяют логику кода в отрыве от настоящего SQL.

MockUserRepository в TestUserRegistrationService — полная in-memory реализация интерфейса IUserRepository без единой строчки SQL, основанная на простом QHash — она используется для тестирования UserRegistrationService, бизнес-логики, которая зависит от IUserRepository через конструктор (паттерн dependency injection из основного цикла статей про интерфейсы) — тест проверяет конкретное бизнес-правило (отказ в регистрации при дублирующемся email), не заботясь вообще о деталях хранения данных.

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

  • in-memory SQLite не на 100% идентична по поведению «настоящей» файловой SQLite или, тем более, другим СУБД (PostgreSQL, MySQL) в продакшене. Некоторые специфичные особенности конкретной СУБД (особенности типов данных, нюансы синтаксиса, поведение при конкурентном доступе) могут не воспроизводиться в in-memory SQLite — для тестов, проверяющих именно SQL-совместимость с продакшен-СУБД, иногда нужен дополнительный слой интеграционных тестов против реальной целевой базы данных, а не только быстрых unit-тестов на SQLite.
  • Использование одного и того же имени подключения ("test_connection") в параллельно выполняющихся тестах (если тестовый фреймворк запускает тесты в нескольких потоках одновременно) может привести к конфликту, поскольку имена подключений QSqlDatabase должны быть уникальны в рамках процесса — для параллельного выполнения тестов имя подключения должно быть уникальным для каждого теста, например включающим идентификатор текущего теста или потока.
  • Mock-репозиторий, реализующий логику слишком похожую на настоящую SQL-реализацию, рискует стать «вторым SQL-репозиторием», который сам нуждается в отдельном тестировании на корректность — простой mock должен быть максимально примитивным (как MockUserRepository в примере, использующий простой QHash), а не пытаться эмулировать все тонкости поведения настоящей базы данных, что было бы избыточным усложнением теста, проверяющего бизнес-логику, а не само хранилище.
  • Забытый cleanup() для закрытия подключения и его удаления через QSqlDatabase::removeDatabase() после каждого теста — без этого шага накопление многих именованных подключений за время выполнения большого тестового набора может привести к предупреждениям Qt о повторном использовании имени подключения или к избыточному расходу ресурсов, даже если сама in-memory база данных и так уничтожается при закрытии соединения.