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. UseQCOMPAREwhen comparing two values;QVERIFYfor conditions.
Q2: Why use QSignalSpy instead of manually connecting signals in tests?
QSignalSpyrecords 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_COMPAREretries 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. RegularQCOMPAREwould 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 useQTRY_COMPAREwhich 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 inheritsQObject, has test functions as private slots, and usesQTEST_MAIN. Register all test executables withadd_test()in CMake forctestintegration.
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
| Resource | Link |
|---|---|
| Qt Docs: QTest | https://doc.qt.io/qt-6/qtest.html |
| Qt Docs: QSignalSpy | https://doc.qt.io/qt-6/qsignalspy.html |
| Qt Docs: Qt Test Overview | https://doc.qt.io/qt-6/qttest-index.html |
| Qt Docs: QTest Benchmarks | https://doc.qt.io/qt-6/qtest-tutorial.html |
Next tutorial → Cross-Platform Deployment