Введение
QTest — встроенный в Qt фреймворк для юнит-тестирования, интегрированный с системой сборки через qmake/CMake и системой сигналов/слотов. В отличие от подключения сторонних фреймворков, QTest не требует дополнительных зависимостей и сразу умеет работать с Qt-специфичными вещами — сравнением QString, тестированием сигналов через QSignalSpy, параметризованными тестами через data-driven подход. В статье напишем первый тест с нуля для простого Qt-класса.
Концепция
Тестовый класс наследует QObject и содержит набор private slots — каждый из них становится отдельным тестовым случаем, автоматически обнаруживаемым и запускаемым тестовым раннером QTest через макрос QTEST_MAIN или QTEST_APPLESS_MAIN (для тестов, не требующих QApplication). Внутри тестов используются макросы проверки: QCOMPARE(actual, expected) для сравнения значений с подробным выводом при несовпадении, и QVERIFY(condition) для проверки булевых условий.
Специальные слоты initTestCase()/cleanupTestCase() выполняются один раз перед/после всех тестов класса, а init()/cleanup() — перед/после каждого отдельного теста, что удобно для подготовки и очистки общего состояния.
Пример кода
// calculator.h — класс, который тестируем
#pragma once
#include <QObject>
class Calculator : public QObject
{
Q_OBJECT
public:
explicit Calculator(QObject *parent = nullptr) : QObject(parent) {}
int add(int a, int b) const { return a + b; }
int divide(int a, int b) const
{
if (b == 0) {
throw std::invalid_argument("Деление на ноль");
}
return a / b;
}
};
// test_calculator.cpp — тестовый класс на основе QTest
#include <QtTest/QtTest>
#include "calculator.h"
class TestCalculator : public QObject
{
Q_OBJECT
private slots:
void initTestCase()
{
qDebug() << "Запуск набора тестов для Calculator";
}
void init()
{
// выполняется перед каждым тестом — создаём свежий экземпляр
m_calculator = new Calculator;
}
void cleanup()
{
delete m_calculator;
}
void testAddition()
{
QCOMPARE(m_calculator->add(2, 3), 5);
QCOMPARE(m_calculator->add(-1, 1), 0);
}
void testDivisionByZeroThrows()
{
QVERIFY_EXCEPTION_THROWN(m_calculator->divide(10, 0), std::invalid_argument);
}
void testDivisionNormal()
{
QCOMPARE(m_calculator->divide(10, 2), 5);
}
private:
Calculator *m_calculator = nullptr;
};
QTEST_APPLESS_MAIN(TestCalculator)
# CMakeLists.txt — подключение теста к сборке
find_package(Qt6 REQUIRED COMPONENTS Test)
add_executable(TestCalculator test_calculator.cpp)
target_link_libraries(TestCalculator PRIVATE Qt6::Test)
add_test(NAME TestCalculator COMMAND TestCalculator)
Пояснения к коду
Каждый private slot класса TestCalculator — отдельный тестовый случай: testAddition, testDivisionByZeroThrows, testDivisionNormal. Методы init()/cleanup() гарантируют, что каждый тест получает свежий экземпляр Calculator, не зависящий от состояния, оставленного предыдущим тестом — это важное правило изоляции тестов друг от друга.
QTEST_APPLESS_MAIN генерирует функцию main(), которая запускает все тестовые слоты класса — используется именно эта, «безприложенческая» версия (а не QTEST_MAIN), поскольку Calculator не требует событийного цикла QApplication. QVERIFY_EXCEPTION_THROWN проверяет, что вызов выбрасывает исключение ожидаемого типа — удобная альтернатива ручному try/catch с флагом.
Подводные камни
- Отсутствие изоляции между тестами через общее мутируемое состояние (например, статическая переменная или общий объект, созданный в
initTestCase()вместоinit()) — приводит к тестам, результат которых зависит от порядка выполнения, что трудно диагностировать при случайном падении одного теста после изменения другого. - Использование
QTEST_MAIN(требуетQApplication) для тестов, не нуждающихся в GUI-инфраструктуре, не является ошибкой, но добавляет ненужные накладные расходы инициализации и иногда требует наличия дисплея/виртуального X-сервера для запуска тестов в headless CI-окружении —QTEST_APPLESS_MAINсвободен от этого ограничения для тестов чистой логики. - Сравнение чисел с плавающей точкой через
QCOMPAREнапрямую может давать ложные сбои из-за погрешностей округления — для float/double предусмотренqFuzzyCompareили специализированные перегрузкиQCOMPAREдля сравнения с допуском. - Игнорирование вывода
QCOMPAREпри отладке падения теста. В отличие от простогоassert,QCOMPAREвыводит и фактическое, и ожидаемое значение при несовпадении — стоит привыкнуть читать этот вывод целиком, а не просто видеть «тест провален».