Введение
Бизнес-логика, зависящая от репозиториев (статья 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 база данных и так уничтожается при закрытии соединения.