Qt Testing (QTest)

QTest framework, unit testing Qt classes, QSignalSpy, GUI testing, and integrating tests with CMake and CI.

6 min read
48603 chars

Why QTest?

QTest is Qt’s built-in unit testing framework. It integrates with CMake, supports GUI event simulation, and includes QSignalSpy for testing signals — essential for testing Qt’s event-driven code.

QTest features:
• QCOMPARE, QVERIFY — assertions
• QSignalSpy         — verify signals emitted
• QTest::mouseClick  — simulate user input
• Data-driven tests  — test one function with many inputs
• Benchmarks         — measure performance

Minimal Test Class

// test_counter.h
#include <QtTest>
#include "counter.h"  // class under test

class TestCounter : public QObject {
    Q_OBJECT

private slots:
    // Called before each test
    void init() {
        m_counter = new Counter;
    }

    // Called after each test
    void cleanup() {
        delete m_counter;
        m_counter = nullptr;
    }

    void testInitialValue()   { QCOMPARE(m_counter->value(), 0); }
    void testIncrement()      { m_counter->increment(); QCOMPARE(m_counter->value(), 1); }
    void testSetValue()       { m_counter->setValue(42); QCOMPARE(m_counter->value(), 42); }
    void testNegativeValue()  { m_counter->setValue(-5); QCOMPARE(m_counter->value(), -5); }

private:
    Counter *m_counter = nullptr;
};

QTEST_MAIN(TestCounter)
#include "test_counter.moc"

CMakeLists.txt for Tests

find_package(Qt6 REQUIRED COMPONENTS Test)

# Unit test
add_executable(test_counter test_counter.cpp)
target_link_libraries(test_counter PRIVATE Qt6::Test MyLibrary)
add_test(NAME counter_tests COMMAND test_counter)

# Enable CTest
include(CTest)
enable_testing()

Run:

cd build
cmake --build .
ctest --output-on-failure

QTest Assertions

// QCOMPARE — checks equality, prints mismatch
QCOMPARE(actual, expected);
QCOMPARE(sensor.temperature(), 36.5f);

// QVERIFY — checks bool condition
QVERIFY(device.isConnected());
QVERIFY2(file.exists(), "Config file missing");

// QFAIL — force failure with message
if (unsupportedCase) QFAIL("Not implemented yet");

// QSKIP — skip test conditionally
#ifndef Q_OS_LINUX
QSKIP("Linux-only test");
#endif

// QTRY_COMPARE — retry for async operations (up to 5s default)
QTRY_COMPARE(spy.count(), 1);

// QTRY_VERIFY
QTRY_VERIFY(device.isConnected());

QSignalSpy — Testing Signals

#include <QSignalSpy>

void testSignalEmitted() {
    Counter counter;

    // Spy on the signal
    QSignalSpy spy(&counter, &Counter::valueChanged);

    counter.setValue(10);

    // Verify it was emitted once
    QCOMPARE(spy.count(), 1);

    // Verify the argument
    QList<QVariant> args = spy.at(0);
    QCOMPARE(args.at(0).toInt(), 10);
}

void testSignalNotEmittedWhenSameValue() {
    Counter counter;
    counter.setValue(5);
    QSignalSpy spy(&counter, &Counter::valueChanged);

    counter.setValue(5);  // same value — should NOT emit

    QCOMPARE(spy.count(), 0);
}

Data-Driven Tests

void testSetValue_data() {
    QTest::addColumn<int>("input");
    QTest::addColumn<int>("expected");

    QTest::newRow("positive")  << 42   << 42;
    QTest::newRow("zero")      << 0    << 0;
    QTest::newRow("negative")  << -10  << -10;
    QTest::newRow("max-int")   << INT_MAX << INT_MAX;
}

void testSetValue() {
    QFETCH(int, input);
    QFETCH(int, expected);

    Counter c;
    c.setValue(input);
    QCOMPARE(c.value(), expected);
}

GUI Testing — Simulate User Input

#include <QtTest>
#include <QPushButton>
#include <QLineEdit>

void testButtonClick() {
    QPushButton btn("Connect");
    QSignalSpy spy(&btn, &QPushButton::clicked);

    // Simulate click
    QTest::mouseClick(&btn, Qt::LeftButton);

    QCOMPARE(spy.count(), 1);
}

void testLineEditInput() {
    QLineEdit edit;

    // Focus and type
    edit.setFocus();
    QTest::keyClicks(&edit, "Hello Qt");

    QCOMPARE(edit.text(), QString("Hello Qt"));
}

void testKeyPress() {
    QLineEdit edit;
    edit.setText("Hello");
    edit.setFocus();

    QTest::keyClick(&edit, Qt::Key_Backspace);

    QCOMPARE(edit.text(), QString("Hell"));
}

Benchmark Tests

void benchmarkStringConcat_data() {
    QTest::addColumn<int>("n");
    QTest::newRow("100")  << 100;
    QTest::newRow("1000") << 1000;
}

void benchmarkStringConcat() {
    QFETCH(int, n);

    QBENCHMARK {
        QString result;
        for (int i = 0; i < n; ++i)
            result += QString::number(i) + ",";
    }
}

void benchmarkHashVsMap() {
    QStringList keys;
    for (int i = 0; i < 10000; ++i)
        keys << QString("key-%1").arg(i);

    QBENCHMARK {
        QHash<QString, int> hash;
        for (int i = 0; i < keys.size(); ++i)
            hash[keys[i]] = i;
        for (const auto &k : keys)
            hash.value(k);
    }
}

Run benchmarks: ./test_bench -benchmark


Visual: Test Lifecycle

┌─────────────────────────────────────────────────────┐
│              QTest Lifecycle                        │
│                                                     │
│  initTestCase()   ← runs once before all tests      │
│       │                                             │
│       ▼                                             │
│  ┌──────────────────────────────────────────────┐  │
│  │  For each test function:                     │  │
│  │                                              │  │
│  │  init()         ← before each test           │  │
│  │  testFoo()      ← the actual test            │  │
│  │  cleanup()      ← after each test            │  │
│  └──────────────────────────────────────────────┘  │
│       │                                             │
│       ▼                                             │
│  cleanupTestCase() ← runs once after all tests     │
└─────────────────────────────────────────────────────┘

Lab 1 — Testing a Serial Frame Parser

// frame_parser.h
class FrameParser : public QObject {
    Q_OBJECT
public:
    // Returns true if a complete frame was found
    bool feed(const QByteArray &data);
    QByteArray lastPayload() const;

signals:
    void frameReceived(const QByteArray &payload);
};

// test_frame_parser.cpp
class TestFrameParser : public QObject {
    Q_OBJECT

private slots:
    void testCompleteFrame() {
        FrameParser parser;
        QSignalSpy spy(&parser, &FrameParser::frameReceived);

        // [STX][len=3][0x01][0x02][0x03][CRC][ETX]
        QByteArray frame = QByteArray::fromHex("020301020300" "03");
        // Set CRC byte correctly
        bool found = parser.feed(frame);

        QVERIFY(found);
        QCOMPARE(spy.count(), 1);
        QByteArray payload = spy.at(0).at(0).toByteArray();
        QCOMPARE(payload, QByteArray::fromHex("010203"));
    }

    void testPartialFrame() {
        FrameParser parser;
        QSignalSpy spy(&parser, &FrameParser::frameReceived);

        // Feed half the frame
        parser.feed(QByteArray::fromHex("0203"));
        QCOMPARE(spy.count(), 0);  // no complete frame yet

        // Feed the rest
        parser.feed(QByteArray::fromHex("010203" "00" "03"));
        QCOMPARE(spy.count(), 1);
    }

    void testEmptyData() {
        FrameParser parser;
        QSignalSpy spy(&parser, &FrameParser::frameReceived);
        parser.feed(QByteArray());
        QCOMPARE(spy.count(), 0);
    }

    void testGarbage() {
        FrameParser parser;
        QSignalSpy spy(&parser, &FrameParser::frameReceived);
        parser.feed(QByteArray::fromHex("deadbeef"));
        QCOMPARE(spy.count(), 0);
    }
};

Lab 2 — Sensor Model Test

class TestSensorModel : public QObject {
    Q_OBJECT

    SensorDB *m_db = nullptr;

private slots:
    void initTestCase() {
        // Use in-memory DB for tests
        m_db = new SensorDB(":memory:", this);
    }

    void cleanupTestCase() {
        delete m_db; m_db = nullptr;
    }

    void testInsertAndRetrieve() {
        QVERIFY(m_db->insert("test-001", 36.5f, "°C"));

        auto hist = m_db->history("test-001", 10);
        QCOMPARE(hist.size(), 1);
        QVERIFY(qAbs(hist[0].second - 36.5f) < 0.001f);
    }

    void testAverage() {
        for (int i = 0; i < 5; ++i)
            m_db->insert("avg-test", static_cast<float>(i * 10), "°C");

        float avg = m_db->average("avg-test", 5);
        // Average of 0,10,20,30,40 = 20
        QVERIFY(qAbs(avg - 20.0f) < 0.1f);
    }

    void testHistoryLimit() {
        for (int i = 0; i < 200; ++i)
            m_db->insert("limit-test", static_cast<float>(i), "raw");

        auto hist = m_db->history("limit-test", 50);
        QCOMPARE(hist.size(), 50);
    }
};

Interview Questions

Q1: What is the difference between QCOMPARE and QVERIFY?

QCOMPARE(a, b) checks equality and prints both values on failure — more informative. QVERIFY(expr) checks a boolean condition and prints only the expression. Use QCOMPARE when comparing two values; QVERIFY for conditions.

Q2: Why use QSignalSpy instead of manually connecting signals in tests?

QSignalSpy records all emissions with their arguments without requiring a receiver object. You can check count, timing, and arguments precisely. Manual connections require extra code and can’t easily check argument values.

Q3: What is QTRY_COMPARE and when do you need it?

QTRY_COMPARE retries the comparison every 50ms for up to 5 seconds, pumping the event loop. Use it when testing asynchronous operations — signals emitted after a delay, timer-based behavior, or worker thread results. Regular QCOMPARE would fail immediately.

Q4: How do you test code that involves timers without waiting in real time?

Use QTest::qWait(ms) to process events for a specific duration, or use QTRY_COMPARE which handles this automatically. For unit testing, consider injecting a mock clock or using very short timer intervals.

Q5: How do you structure tests for a Qt project?

Create a separate tests/ directory with one test executable per class or module. Each test class inherits QObject, has test functions as private slots, and uses QTEST_MAIN. Register all test executables with add_test() in CMake for ctest integration.


Application: Full Test Suite Setup

# tests/CMakeLists.txt
include(CTest)
enable_testing()

function(add_qt_test test_name source_file)
    add_executable(${test_name} ${source_file})
    target_link_libraries(${test_name}
        PRIVATE Qt6::Test Qt6::Core DeviceLibrary)
    add_test(NAME ${test_name} COMMAND ${test_name})
endfunction()

add_qt_test(test_counter      test_counter.cpp)
add_qt_test(test_frame_parser test_frame_parser.cpp)
add_qt_test(test_sensor_db    test_sensor_db.cpp)
add_qt_test(test_config       test_config.cpp)
# Run all tests
ctest --output-on-failure

# Run a specific test
ctest -R test_frame_parser -V

# Run with coverage (gcov/lcov)
cmake -DCMAKE_BUILD_TYPE=Debug -DENABLE_COVERAGE=ON ..
make
ctest
lcov --capture --directory . --output-file coverage.info
genhtml coverage.info --output-directory coverage_html
// CI integration — GitHub Actions
// .github/workflows/test.yml
// steps:
//   - cmake -B build -DCMAKE_BUILD_TYPE=Debug
//   - cmake --build build
//   - cd build && ctest --output-on-failure

References

ResourceLink
Qt Docs: QTesthttps://doc.qt.io/qt-6/qtest.html
Qt Docs: QSignalSpyhttps://doc.qt.io/qt-6/qsignalspy.html
Qt Docs: Qt Test Overviewhttps://doc.qt.io/qt-6/qttest-index.html
Qt Docs: QTest Benchmarkshttps://doc.qt.io/qt-6/qtest-tutorial.html

Next tutorial → Cross-Platform Deployment