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

Тестирование сигналов и слотов с помощью QSignalSpy: продвинутые сценарии

Введение

QSignalSpy — специализированный инструмент QTest для проверки факта и параметров испускания сигналов без необходимости вручную подключать слот-наблюдатель и хранить состояние. В предыдущей статье он упоминался для базовой проверки взаимодействий; здесь разберём более продвинутые сценарии — проверку нескольких сигналов, асинхронное ожидание сигналов с таймаутом и работу с сигналами, имеющими сложные типы аргументов.

Концепция

QSignalSpy подключается к конкретному сигналу конкретного объекта и накапливает список всех его испусканий с момента создания спая — каждое испускание сохраняется как QList<QVariant> с аргументами сигнала. Методы count() (или size()) показывают число испусканий, оператор [] даёт доступ к аргументам конкретного вызова. Для тестирования асинхронного кода (например, сигнала, который придёт через некоторое время после сетевого запроса) есть метод wait(timeout), который запускает локальный event loop и ждёт следующего испускания сигнала или истечения таймаута — это позволяет тестировать асинхронный код без искусственных QThread::sleep().

Пример кода

#include <QtTest/QtTest>
#include <QTimer>
#include <QSignalSpy>

class AsyncCounter : public QObject
{
    Q_OBJECT
public:
    void startCountingTo(int max)
    {
        m_current = 0;
        m_max = max;
        m_timer = new QTimer(this);
        connect(m_timer, &QTimer::timeout, this, &AsyncCounter::tick);
        m_timer->start(50);
    }

signals:
    void valueChanged(int value);
    void finished();

private slots:
    void tick()
    {
        m_current++;
        emit valueChanged(m_current);
        if (m_current >= m_max) {
            m_timer->stop();
            emit finished();
        }
    }

private:
    QTimer *m_timer = nullptr;
    int m_current = 0;
    int m_max = 0;
};

class TestAsyncCounter : public QObject
{
    Q_OBJECT
private slots:
    void testValueChangedEmittedCorrectNumberOfTimes()
    {
        AsyncCounter counter;
        QSignalSpy spy(&counter, &AsyncCounter::valueChanged);

        counter.startCountingTo(5);

        // Ждём сигнал finished с таймаутом — без блокирующего sleep
        QSignalSpy finishedSpy(&counter, &AsyncCounter::finished);
        QVERIFY(finishedSpy.wait(1000));

        QCOMPARE(spy.count(), 5);

        // Проверяем аргумент последнего испускания сигнала
        QList<QVariant> lastEmission = spy.takeLast();
        QCOMPARE(lastEmission.at(0).toInt(), 5);
    }

    void testIntermediateValues()
    {
        AsyncCounter counter;
        QSignalSpy spy(&counter, &AsyncCounter::valueChanged);

        counter.startCountingTo(3);
        QSignalSpy finishedSpy(&counter, &AsyncCounter::finished);
        QVERIFY(finishedSpy.wait(1000));

        QCOMPARE(spy.count(), 3);
        QCOMPARE(spy.at(0).at(0).toInt(), 1); // первое испускание: value == 1
        QCOMPARE(spy.at(1).at(0).toInt(), 2); // второе испускание: value == 2
        QCOMPARE(spy.at(2).at(0).toInt(), 3); // третье испускание: value == 3
    }
};

QTEST_MAIN(TestAsyncCounter)
#include "test_asynccounter.moc"

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

QSignalSpy spy(&counter, &AsyncCounter::valueChanged) начинает отслеживать испускания сигнала valueChanged с момента создания спая. После запуска асинхронного счётчика тест использует finishedSpy.wait(1000) — это блокирует тест не более чем на секунду, но возвращает управление сразу, как только сигнал finished будет испущен, не дожидаясь полного таймаута искусственно.

spy.at(i).at(0) обращается к первому аргументу i-го испускания сигнала — поскольку valueChanged(int value) имеет один аргумент, индекс аргумента всегда 0; для сигналов с несколькими аргументами они были бы доступны по индексам 0, 1, 2 и так далее в порядке объявления параметров.

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

  • Создание QSignalSpy после того, как сигнал уже был испущен, не «ловит» прошлые испускания — спай должен быть создан до выполнения действия, которое вызовет интересующий сигнал, иначе тест будет ошибочно показывать count() == 0.
  • wait() без указания таймаута использует значение по умолчанию (обычно 5000 мс), что может незаметно сделать тестовый набор медленным, если ожидаемый сигнал никогда не приходит (например, из-за бага) — каждый отдельный тест начинает «зависать» на полный таймаут, что особенно болезненно при множестве таких тестов в CI.
  • Использование QSignalSpy для проверки сигналов, которые могут быть испущены до создания спая в многопоточном коде — гонка между запуском фоновой операции и созданием спая может привести к ложным провалам тестов, зависящим от тайминга; стоит структурировать код так, чтобы спай создавался строго до старта асинхронной операции.
  • Сравнение QVariant напрямую без приведения к конкретному типу может давать неожиданные результаты при сравнении значений разных, но «похожих» типов (например, int и qlonglong) — явное приведение через .toInt(), .toString() и так далее, как показано в примере, делает сравнение предсказуемым.