Тестирование

QTest: пишем первый юнит-тест для Qt-класса с нуля

Введение

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 выводит и фактическое, и ожидаемое значение при несовпадении — стоит привыкнуть читать этот вывод целиком, а не просто видеть «тест провален».