Qt Testing Guide: Unit and Integration Testing for Qt/C++ Applications
Qt applications are tested with QTest, Qt's built-in unit testing framework. Unlike third-party frameworks bolted onto C++, QTest understands Qt's object model—you can test signals, slots, models, and widgets with purpose-built APIs rather than workarounds.
This guide covers QTest setup, unit testing Qt classes, testing signals and slots, testing models and views, automating GUI interactions, and running tests in CI.
QTest Framework Overview
QTest is part of the Qt framework. Enable it in your .pro file or CMakeLists:
# CMakeLists.txt (Qt 6)
find_package(Qt6 REQUIRED COMPONENTS Core Widgets Test)
add_executable(MyAppTests
tests/main.cpp
tests/test_calculator.cpp
tests/test_filemodel.cpp
)
target_link_libraries(MyAppTests
Qt6::Core
Qt6::Widgets
Qt6::Test
MyAppLib # link against your library, not the executable
)
enable_testing()
add_test(NAME MyAppTests COMMAND MyAppTests)Structure your project so business logic lives in a library (MyAppLib) that both the main executable and tests link against. This avoids linking the main() function twice.
Basic Unit Test Structure
// tests/test_calculator.h
#pragma once
#include <QObject>
class TestCalculator : public QObject {
Q_OBJECT
private slots:
void initTestCase(); // runs once before all tests
void cleanupTestCase(); // runs once after all tests
void init(); // runs before each test
void cleanup(); // runs after each test
void testAdd();
void testSubtract();
void testDivideByZero();
void testAdd_data(); // data provider for testAdd
};// tests/test_calculator.cpp
#include <QtTest>
#include "test_calculator.h"
#include "calculator.h"
void TestCalculator::initTestCase() {
qDebug() << "Starting calculator tests";
}
void TestCalculator::cleanupTestCase() {
qDebug() << "Calculator tests complete";
}
void TestCalculator::init() {
// Fresh state before each test if needed
}
void TestCalculator::cleanup() {}
void TestCalculator::testAdd() {
Calculator calc;
QCOMPARE(calc.add(2, 3), 5);
QCOMPARE(calc.add(-1, 1), 0);
QCOMPARE(calc.add(0, 0), 0);
}
void TestCalculator::testSubtract() {
Calculator calc;
QCOMPARE(calc.subtract(10, 3), 7);
QCOMPARE(calc.subtract(0, 5), -5);
}
void TestCalculator::testDivideByZero() {
Calculator calc;
QVERIFY_THROWS_EXCEPTION(std::invalid_argument, calc.divide(10, 0));
}
// Data-driven test
void TestCalculator::testAdd_data() {
QTest::addColumn<int>("a");
QTest::addColumn<int>("b");
QTest::addColumn<int>("expected");
QTest::newRow("positive") << 2 << 3 << 5;
QTest::newRow("negative") << -1 << -2 << -3;
QTest::newRow("zero") << 0 << 0 << 0;
QTest::newRow("mixed") << 10 << -5 << 5;
}
void TestCalculator::testAdd() {
QFETCH(int, a);
QFETCH(int, b);
QFETCH(int, expected);
Calculator calc;
QCOMPARE(calc.add(a, b), expected);
}
QTEST_MAIN(TestCalculator)
#include "test_calculator.moc"Testing Signals and Slots
QTest's QSignalSpy captures signal emissions for inspection:
// src/downloader.h
class Downloader : public QObject {
Q_OBJECT
public:
void startDownload(const QUrl& url);
signals:
void progressChanged(int percent);
void downloadComplete(const QString& filePath);
void downloadFailed(const QString& error);
};// tests/test_downloader.cpp
#include <QtTest>
#include <QSignalSpy>
#include "downloader.h"
class TestDownloader : public QObject {
Q_OBJECT
private slots:
void testProgressSignalEmitted() {
Downloader dl;
QSignalSpy spy(&dl, &Downloader::progressChanged);
dl.startDownload(QUrl("http://example.com/file.zip"));
// Wait up to 5 seconds for signals
QTRY_VERIFY_WITH_TIMEOUT(spy.count() > 0, 5000);
// Check signal argument
QList<QVariant> args = spy.takeFirst();
int progress = args.at(0).toInt();
QVERIFY(progress >= 0 && progress <= 100);
}
void testDownloadFailsForInvalidUrl() {
Downloader dl;
QSignalSpy failSpy(&dl, &Downloader::downloadFailed);
QSignalSpy completeSpy(&dl, &Downloader::downloadComplete);
dl.startDownload(QUrl("not-a-valid-url"));
QTRY_COMPARE_WITH_TIMEOUT(failSpy.count(), 1, 3000);
QCOMPARE(completeSpy.count(), 0);
QString error = failSpy.at(0).at(0).toString();
QVERIFY(!error.isEmpty());
}
void testNoSignalsBeforeStart() {
Downloader dl;
QSignalSpy spy(&dl, &Downloader::progressChanged);
// Don't call startDownload
QTest::qWait(100);
QCOMPARE(spy.count(), 0);
}
};
QTEST_MAIN(TestDownloader)
#include "test_downloader.moc"Testing Qt Models
QAbstractItemModel subclasses have a standardized interface. Qt provides QAbstractItemModelTester to validate model compliance:
// tests/test_filemodel.cpp
#include <QtTest>
#include <QAbstractItemModelTester>
#include "filemodel.h"
class TestFileModel : public QObject {
Q_OBJECT
private:
QString m_testDir;
void createTestFiles() {
QDir dir(m_testDir);
QFile(dir.filePath("alpha.txt")).open(QIODevice::WriteOnly);
QFile(dir.filePath("beta.txt")).open(QIODevice::WriteOnly);
dir.mkdir("subdir");
}
private slots:
void initTestCase() {
QTemporaryDir tmpDir;
tmpDir.setAutoRemove(false);
m_testDir = tmpDir.path();
createTestFiles();
}
void testModelPassesAbstractTests() {
FileModel model(m_testDir);
// This runs Qt's built-in compliance tests
QAbstractItemModelTester tester(&model,
QAbstractItemModelTester::FailureReportingMode::Fatal);
}
void testRowCountMatchesFiles() {
FileModel model(m_testDir);
// 2 files + 1 directory = 3 entries
QCOMPARE(model.rowCount(), 3);
}
void testDataReturnsFileName() {
FileModel model(m_testDir);
QModelIndex idx = model.index(0, 0);
QString name = model.data(idx, Qt::DisplayRole).toString();
QVERIFY(!name.isEmpty());
}
void testSortByName() {
FileModel model(m_testDir);
model.sort(0, Qt::AscendingOrder);
QString first = model.data(model.index(0, 0)).toString();
QString second = model.data(model.index(1, 0)).toString();
QVERIFY(first <= second);
}
void testAddFileEmitsRowsInserted() {
FileModel model(m_testDir);
QSignalSpy spy(&model, &FileModel::rowsInserted);
QFile newFile(QDir(m_testDir).filePath("gamma.txt"));
newFile.open(QIODevice::WriteOnly);
model.refresh();
QTRY_COMPARE_WITH_TIMEOUT(spy.count(), 1, 2000);
}
};
QTEST_MAIN(TestFileModel)
#include "test_filemodel.moc"Testing Widgets
Widget tests require QApplication and can simulate user interactions:
// tests/test_loginwidget.cpp
#include <QtTest>
#include <QApplication>
#include <QLineEdit>
#include <QPushButton>
#include <QLabel>
#include "loginwidget.h"
class TestLoginWidget : public QObject {
Q_OBJECT
private slots:
void testLoginButtonDisabledWhenEmpty() {
LoginWidget widget;
widget.show();
QVERIFY(QTest::qWaitForWindowExposed(&widget));
QPushButton* loginBtn = widget.findChild<QPushButton*>("loginButton");
QVERIFY(loginBtn);
QVERIFY(!loginBtn->isEnabled());
}
void testLoginButtonEnabledWhenCredentialsFilled() {
LoginWidget widget;
widget.show();
QVERIFY(QTest::qWaitForWindowExposed(&widget));
QLineEdit* usernameField = widget.findChild<QLineEdit*>("usernameField");
QLineEdit* passwordField = widget.findChild<QLineEdit*>("passwordField");
QPushButton* loginBtn = widget.findChild<QPushButton*>("loginButton");
QTest::keyClicks(usernameField, "admin");
QTest::keyClicks(passwordField, "secret");
QVERIFY(loginBtn->isEnabled());
}
void testInvalidCredentialsShowsError() {
LoginWidget widget;
widget.show();
QVERIFY(QTest::qWaitForWindowExposed(&widget));
QLineEdit* usernameField = widget.findChild<QLineEdit*>("usernameField");
QLineEdit* passwordField = widget.findChild<QLineEdit*>("passwordField");
QPushButton* loginBtn = widget.findChild<QPushButton*>("loginButton");
QTest::keyClicks(usernameField, "wrong");
QTest::keyClicks(passwordField, "credentials");
QTest::mouseClick(loginBtn, Qt::LeftButton);
QLabel* errorLabel = widget.findChild<QLabel*>("errorLabel");
QTRY_VERIFY_WITH_TIMEOUT(errorLabel->isVisible(), 3000);
QVERIFY(!errorLabel->text().isEmpty());
}
void testSuccessfulLoginEmitsSignal() {
LoginWidget widget;
QSignalSpy spy(&widget, &LoginWidget::loginSuccessful);
widget.show();
QVERIFY(QTest::qWaitForWindowExposed(&widget));
QLineEdit* usernameField = widget.findChild<QLineEdit*>("usernameField");
QLineEdit* passwordField = widget.findChild<QLineEdit*>("passwordField");
QPushButton* loginBtn = widget.findChild<QPushButton*>("loginButton");
QTest::keyClicks(usernameField, "admin");
QTest::keyClicks(passwordField, "correct_password");
QTest::mouseClick(loginBtn, Qt::LeftButton);
QTRY_COMPARE_WITH_TIMEOUT(spy.count(), 1, 5000);
}
};
// Use QTEST_MAIN for widget tests — creates QApplication
QTEST_MAIN(TestLoginWidget)
#include "test_loginwidget.moc"QTest Macros Reference
| Macro | Purpose |
|---|---|
QCOMPARE(actual, expected) |
Fails with detailed output if not equal |
QVERIFY(condition) |
Fails if condition is false |
QVERIFY2(condition, message) |
Fails with custom message |
QTRY_COMPARE(a, b) |
Retries for 5 seconds (async-friendly) |
QTRY_VERIFY(condition) |
Retries for 5 seconds |
QTRY_COMPARE_WITH_TIMEOUT(a, b, ms) |
Retries with custom timeout |
QSKIP(message) |
Skip test with reason |
QEXPECT_FAIL(...) |
Mark expected failure |
QBENCHMARK { } |
Measure performance |
Running Tests in CI
# .github/workflows/test.yml
name: Test Qt App
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Qt
uses: jurplel/install-qt-action@v3
with:
version: '6.5.0'
modules: 'qtbase'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1-mesa-dev xvfb
- name: Configure
run: cmake -B build -DCMAKE_BUILD_TYPE=Debug
- name: Build
run: cmake --build build --target MyAppTests
- name: Run tests (headless)
run: xvfb-run -a ctest --test-dir build --output-on-failureFor Windows CI, replace xvfb-run with windows-latest runner — Qt widgets work natively without a virtual display.
Common Pitfalls
Missing .moc include. Every test file using Q_OBJECT needs #include "testfile.moc" at the bottom. Forgetting it causes linker errors.
Not using QTRY_* for async operations. QCOMPARE checks immediately. If you're waiting for a signal from an async operation, use QTRY_COMPARE or QTRY_VERIFY to poll with a timeout.
Widget tests without QApplication. Use QTEST_MAIN for widget tests — it creates a QApplication. QTEST_APPLESS_MAIN is only for tests with no Qt event loop dependency.
Testing implementation details. Test behavior via signals and the public API. Don't access private members or test internal state directly.
Summary
QTest is a mature, Qt-native testing framework that handles everything from pure C++ unit tests to widget interaction tests. QSignalSpy makes signal testing straightforward. QAbstractItemModelTester validates model compliance with zero extra code. Data-driven tests via _data() slots eliminate test repetition.
Structure your project with a shared library, keep business logic separate from UI, and you'll have a fast, comprehensive test suite that runs easily in CI.