Qt Testing Guide: Unit and Integration Testing for Qt/C++ Applications

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-failure

For 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.

Read more