Qt Test 深入:从 QCOMPARE 到数据驱动与基准测试



Qt Test 是 Qt 自带的单元测试框架,随 Qt 一起分发,不需要额外依赖。它的定位很明确:测试 C++ 类和函数的逻辑,尤其是那些和 QObject、信号槽、事件循环打交道的代码——这些用 Google Test 之类的通用框架测起来会很别扭。

一、最小可运行的测试

一个测试类就是一个继承 QObject 的类,私有槽就是测试函数:

#include <QtTest>
#include "calculator.h"

class TestCalculator : public QObject {
    Q_OBJECT

private slots:
    void initTestCase();      // 整个类跑之前,执行一次
    void init();              // 每个测试函数之前
    void cleanup();           // 每个测试函数之后
    void cleanupTestCase();   // 整个类跑完,执行一次

    void add();               // 真正的测试函数
    void divideByZero();

private:
    Calculator *m_calc = nullptr;
};

void TestCalculator::init()    { m_calc = new Calculator; }
void TestCalculator::cleanup() { delete m_calc; m_calc = nullptr; }

void TestCalculator::add() {
    QCOMPARE(m_calc->add(2, 3), 5);
    QCOMPARE(m_calc->add(-1, 1), 0);
}

void TestCalculator::divideByZero() {
    QVERIFY_EXCEPTION_THROWN(m_calc->divide(1, 0), std::invalid_argument);
}

QTEST_MAIN(TestCalculator)      // 生成 main()
#include "test_calculator.moc"  // 单文件写法必须包含

四个特殊函数的执行顺序是考点:initTestCase → (init → 测试函数 → cleanup) × N → cleanupTestCase。init/cleanup 每个用例都跑,用来保证用例之间互不污染。

另外三个 QTEST_* 宏的区别也常被问:

  • QTEST_MAIN — 创建 QApplication(有 widgets 时)或 QCoreApplication
  • QTEST_APPLESS_MAIN — 不创建应用对象,纯逻辑测试用,启动最快
  • QTEST_GUILESS_MAIN — 创建 QCoreApplication,有事件循环但无 GUI,适合网络/定时器测试

二、断言宏

QVERIFY(condition);                    // 条件为真
QVERIFY2(condition, "失败时的说明");     // 带描述,排查时省事
QCOMPARE(actual, expected);            // 相等,失败时打印两边的值
QVERIFY_EXCEPTION_THROWN(expr, Type);  // 必须抛出指定异常
QFAIL("不该走到这里");
QSKIP("依赖的服务未启动");               // 跳过,标记为 SKIP 而非 FAIL
QEXPECT_FAIL("", "已知 bug QTBUG-123", Continue);  // 预期失败

优先用 QCOMPARE 而不是 QVERIFY(a == b)。前者失败时会打印 Actual: 4, Expected: 5,后者只告诉你条件为假,还得自己加日志。

另一个关键行为:QCOMPARE 失败会立即返回当前测试函数(宏里含 return),后续语句不执行。这和 Squish 的 test.compare 失败后继续跑恰好相反。

2.1 浮点数比较

QCOMPARE 对 float / double 会自动用模糊比较(qFuzzyCompare),相对误差容忍到大约 1e-5(float)和 1e-12(double)。这是好事,但也有个陷阱:qFuzzyCompare 不能处理 0,因为它算的是相对误差。比较结果是否为零要用 qFuzzyIsNull:

QCOMPARE(result, 0.0);              // 可能意外失败
QVERIFY(qFuzzyIsNull(result));      // 正确写法

三、数据驱动测试

这是 Qt Test 最实用的特性。同一个测试逻辑跑多组输入,写法是给测试函数配一个 _data() 后缀的同名函数:

void TestCalculator::add_data() {
    QTest::addColumn<int>("a");
    QTest::addColumn<int>("b");
    QTest::addColumn<int>("expected");

    QTest::newRow("正数相加")   << 2  << 3  << 5;
    QTest::newRow("含负数")     << -1 << 1  << 0;
    QTest::newRow("零")        << 0  << 0  << 0;
    QTest::newRow("溢出边界")   << INT_MAX << 0 << INT_MAX;
}

void TestCalculator::add() {
    QFETCH(int, a);              // 从当前数据行取值
    QFETCH(int, b);
    QFETCH(int, expected);

    QCOMPARE(m_calc->add(a, b), expected);
}

好处是失败输出会带上行名(FAIL! : TestCalculator::add(溢出边界)),一眼看出是哪组数据挂了。行名要写得有意义,别用 row1 row2。

四、测试信号槽

这是 Qt Test 相比通用测试框架的核心优势。QSignalSpy 能记录信号的发射次数和每次的参数:

void TestContactModel::addContact() {
    ContactModel model;
    QSignalSpy insertSpy(&model, &ContactModel::rowsInserted);
    QSignalSpy changeSpy(&model, &ContactModel::dataChanged);

    model.addContact({"Jifang", "guo@example.com"});

    QCOMPARE(insertSpy.count(), 1);           // 发了一次
    QCOMPARE(changeSpy.count(), 0);           // 没有多余的信号

    // 检查信号参数:take/at 返回 QList<QVariant>
    const QList<QVariant> args = insertSpy.takeFirst();
    QCOMPARE(args.at(1).toInt(), 0);          // first row
    QCOMPARE(args.at(2).toInt(), 0);          // last row
}

异步信号要用 wait(),它内部会跑事件循环:

void TestDownloader::download() {
    Downloader d;
    QSignalSpy spy(&d, &Downloader::finished);

    d.start(QUrl("http://example.com/file"));

    // 等信号,最多 5 秒;Qt5.14+ 也可用 QVERIFY(spy.wait(5000))
    QVERIFY(spy.wait(5000));
    QCOMPARE(spy.count(), 1);
}

没有 QSignalSpy 的场景可以用 QTRY_COMPARE / QTRY_VERIFY,它们会在超时前反复重试并跑事件循环:

QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 10, 3000);  // 3 秒内变成 10
QTRY_VERIFY(widget->isVisible());                        // 默认 5 秒

五、模拟 GUI 事件

Qt Test 也能做轻量的界面测试,注入的是真实的 Qt 事件:

void TestLoginDialog::submit() {
    LoginDialog dlg;
    dlg.show();
    QVERIFY(QTest::qWaitForWindowExposed(&dlg));   // 必须等窗口真正显示

    QTest::keyClicks(dlg.userEdit(), "admin");
    QTest::keyClicks(dlg.passEdit(), "secret");
    QTest::mouseClick(dlg.okButton(), Qt::LeftButton);

    QCOMPARE(dlg.result(), QDialog::Accepted);
}

常用的注入函数:QTest::mouseClick / mouseDClick / mouseMove / mouseDrag、keyClick / keyClicks / keySequence。

两个要点。第一,qWaitForWindowExposed 不能省,窗口没显示完就发事件会丢。第二,这不能替代 Squish——它测的是单个 widget 的交互,测不了跨窗口的完整业务流程,也没有对象映射这类可维护性设施。

六、性能基准测试

QBENCHMARK 会自动多次运行代码块并取稳定值:

void TestParser::benchmarkParse() {
    const QByteArray data = loadTestFile("large.json");

    QBENCHMARK {
        Parser::parse(data);
    }
}

// 只测一次(适合本身耗时很长的操作)
void TestParser::benchmarkOnce() {
    QBENCHMARK_ONCE { Parser::parse(hugeData); }
}

默认量的是墙上时钟(wall clock time,真实流逝时间)。命令行换后端能拿到更细的指标:-callgrind(Valgrind 工具集的指令计数器,最稳定,适合 CI 里做回归门槛)、-perf(Linux perf 性能事件)、-eventcounter(Qt 事件数)。

七、构建与运行

CMake 写法:

find_package(Qt6 REQUIRED COMPONENTS Test Core)
enable_testing()

qt_add_executable(test_calculator test_calculator.cpp calculator.cpp)
target_link_libraries(test_calculator PRIVATE Qt6::Test Qt6::Core)
add_test(NAME test_calculator COMMAND test_calculator)

常用命令行参数:

./test_calculator                      # 跑全部
./test_calculator add                  # 只跑 add 这个函数
./test_calculator "add:溢出边界"        # 只跑某个数据行
./test_calculator -v2                  # 详细输出,含每个 QCOMPARE
./test_calculator -functions           # 列出所有测试函数
./test_calculator -o results.xml,junitxml   # 输出 JUnit 给 CI
./test_calculator -maxwarnings 0       # 不限制警告数量

CI 里通常用 ctest --output-on-failure 统一驱动,失败时才打印详细输出。

八、局限与常见问题

  • 没有内置 mock(模拟对象)框架。要打桩(stub)得自己写接口 + 派生假实现,或者引入 GMock(Google Mock)混用(技术上可行,但两套 main 函数要处理)
  • 一个可执行文件通常只放一个测试类。想在一个 binary 里跑多个类需要自己写 main 依次 QTest::qExec,且信号槽的静态状态可能互相影响
  • 测试函数必须是 private slots。写成 public slots 也能跑,但 signals 或普通成员函数不会被识别,这是新手最常见的「测试没执行」原因
  • QCOMPARE 自定义类型要提供 operator==,想让失败输出好看还得特化 QTest::toString()
  • 依赖真实文件系统的测试记得用 QTemporaryDir,别往源码目录写文件

术语表

简称英文全称中文与说明
—unit test单元测试。针对单个类或函数的测试,与被测代码同进程
—test fixture测试夹具。initTestCase / init / cleanup / cleanupTestCase 四件套提供的测试环境准备与清理
—data-driven test数据驱动测试。_data() 函数配 QFETCH,同一逻辑跑多组输入
—fuzzy compare模糊比较。qFuzzyCompare 按相对误差比浮点数,不能用于 0
—signal spy信号侦听器。QSignalSpy 记录信号发射次数与参数
—benchmark基准测试。QBENCHMARK 多次运行取稳定值
—wall clock time墙上时钟时间。真实流逝时间,QBENCHMARK 的默认度量
—mock / stub模拟对象 / 打桩。替换真实依赖以隔离被测单元
GMockGoogle Mock谷歌的 C++ mock 框架,现已并入 GoogleTest
—Valgrind / CallgrindLinux 动态分析工具集 / 其中的指令计数器,用于稳定的性能回归度量
CIContinuous Integration持续集成
CTest—CMake 自带的测试驱动器,统一执行并汇总测试结果
JUnit XML—源自 Java JUnit 框架的测试报告格式,已成为 CI 工具的事实标准
GUIGraphical User Interface图形用户界面
mocMeta-Object Compiler元对象编译器。测试类必须写 Q_OBJECT 并包含 .moc 文件

小结

Qt Test 的甜点区是「和 Qt 机制耦合的逻辑代码」:model 的数据正确性、信号的发射时机、异步操作的完成状态。QSignalSpy + 数据驱动 + QTRY_* 三件套基本能覆盖日常需求。界面级的完整流程回归交给 Squish,两者分层配合。

posted @ 2026-09-14 17:44  起飞的乌龟  阅读(26)  评论(0)    收藏  举报