简介这套QTest单元测试Demo面向使用Qt框架的C开发者集中演示如何用QTest为普通Qt应用中的类方法以及DLL内部非导出类编写测试。资源包含13个文件主要由5个cpp、4个h和4个pro工程文件组成包体仅6KB结构精简便于快速阅读和复用。其中cpp与h覆盖被测试类、测试用例及访问非导出类的测试辅助接口pro文件则用于qmake构建配置。目前已有1462人学习样例中展示了测试类定义、TEST测试宏、数据驱动测试和QVERIFY断言的使用并给出通过友元或暴露测试接口访问DLL内部方法的两种思路。对于希望系统掌握Qt单元测试组织方法、提高代码质量的开发者来说这是一份可直接运行的参考Demo。 在项目里给Qt代码做单元测试不少人第一反应是去引入 Google Test 或者 Catch2折腾一通宏定义、编译链接之后发现和 Qt 的信号槽、元对象系统配合起来相当别扭。我一开始也是这样直到后来认认真真用了一轮 Qt 自带的 QTest 框架才发现这个官方测试框架其实被严重低估了。很多场景下它反而是最顺手的那把刀尤其是测试涉及 QObject 信号、界面交互的代码时QTest 几乎是零成本接入。这篇文章我就拿一个完整的、可以直接跑通的 Demo 来说事从工程结构、测试类写法、断言机制、数据驱动到信号验证和命令行运行把 QTest 单元测试的完整细节过一遍。无论你是刚开始接触 Qt 测试的新手还是在团队里负责搭测试体系的人这份内容都能直接参考着用。1. 为什么是 QTest一个被低估的框架1.1 和其他测试框架的差异先聊一个大家经常纠结的问题Qt 项目里做单元测试到底该用 QTest 还是第三方框架Google Test 功能确实强大断言丰富、mock 完善在纯 C 项目里是主流选择。但一旦进了 Qt 的地盘问题就开始出现了。Qt 的元对象系统依赖 moc 预处理QObject 的很多特性是运行时注册的信号槽的调用、事件循环的派发这些都是 Qt 特有的机制。Google Test 本身并不认识这些东西你想测试一个信号是不是被正确发出需要自己写一堆事件循环处理逻辑。Catch2 也是类似的情况在需要和 Qt 事件系统协同的时候总感觉隔了一层。QTest 把这个隔阂直接抹平了。它是 Qt 官方维护的测试框架包含在 QtTest 模块里和 QtCore、QtGui 这些基础模块平级。因为 QTest 本身就是依赖 Qt 的元对象系统运行的测试用例的发现、执行、信号验证、超时控制全都走的是 Qt 自己的那套机制所以测 QObject 派生类时有一种浑然天成的感觉。用一句话总结我的体感如果你测的是纯算法、纯数据结构Google Test 和 QTest 区别不大但只要你的代码和 QObject、信号槽、界面控件沾边QTest 的体验是碾压级别的。1.2 适用场景与边界QTest 适合什么场景最核心的是这几类逻辑类的单元测试比如对业务模型、工具类、算法封装做功能验证。信号槽行为的测试验证某个操作是否触发了预期信号以及信号携带的参数是否正确。界面控件的自动化测试QTest 提供了模拟鼠标键盘事件的能力可以直接驱动 QPushButton、QLineEdit 这些控件做交互测试。回归测试把历史 bug 重现成测试用例防止再次踩坑。当然它也有自己的边界QTest 本身不提供 mock 能力。如果你的被测对象依赖数据库、网络这些外部资源通常还需要配合你自己的桩模块来隔离依赖。这一点说实话不如老牌的 mock 框架方便我在项目里的做法是单独维护一套简单的桩类问题也不大。2. Demo 工程搭建从零开始的可运行测试说再多框架优势不如直接看一个能跑起来的 Demo。我这里的示例是一个简单的计算器类里面包含加、减、乘、除四个方法然后我们针对它写一个完整的 QTest 测试工程。2.1 被测对象一个简单的计算器类先看看被测对象很常规的一个类// calculator.h #ifndef CALCULATOR_H #define CALCULATOR_H class Calculator { public: Calculator(); double add(double a, double b); double subtract(double a, double b); double multiply(double a, double b); double divide(double a, double b); private: double m_lastResult; }; #endif // CALCULATOR_H// calculator.cpp #include calculator.h Calculator::Calculator() : m_lastResult(0.0) { } double Calculator::add(double a, double b) { m_lastResult a b; return m_lastResult; } double Calculator::subtract(double a, double b) { m_lastResult a - b; return m_lastResult; } double Calculator::multiply(double a, double b) { m_lastResult a * b; return m_lastResult; } double Calculator::divide(double a, double b) { if (b 0.0) { m_lastResult 0.0; return m_lastResult; } m_lastResult a / b; return m_lastResult; }这个类足够简单但已经覆盖了正常路径、异常路径除零、成员状态变化这几类测试关注点。实际项目的被测对象往往更复杂但测试的思路是一模一样的。2.2 测试类的骨架private slots 与测试生命周期接下来是重头戏写测试类。先把骨架列出来// testcalculator.h #ifndef TESTCALCULATOR_H #define TESTCALCULATOR_H #include QObject #include QtTest class TestCalculator : public QObject { Q_OBJECT private slots: void initTestCase(); void cleanupTestCase(); void init(); void cleanup(); void testAdd(); void testSubtract(); void testMultiply(); void testDivide(); void testDivideByZero(); private: Calculator *m_calc; }; #endif // TESTCALCULATOR_H这里有个非常关键的点测试函数必须是私有槽函数。QTest 的测试发现机制就是通过元对象系统扫描所有private slots下的函数然后逐个执行。你写成普通成员函数它是不会执行的写成了public slots也能跑但官方标准写法就是private slots遵循约定不会错。生命周期函数的作用如下initTestCase()在整个测试套件开始前调用一次适合做全局资源初始化比如打开数据库连接、加载配置文件。cleanupTestCase()整个测试套件结束后调用一次负责释放全局资源。init()每个测试函数执行前都会调用一次这里适合创建被测对象实例。cleanup()每个测试函数执行后调用一次适合清理测试产生的临时数据。这种套件级 用例级的双层生命周期设计很实用。比如我的这个 Demo如果每个测试函数里都手动new Calculator()再delete代码就会很冗余而且容易漏。把对象的创建和销毁放到init()/cleanup()里面每个用例之间天然隔离互不影响。测试函数的实现如下// testcalculator.cpp #include testcalculator.h void TestCalculator::initTestCase() { qInfo() Test suite started; } void TestCalculator::cleanupTestCase() { qInfo() Test suite finished; } void TestCalculator::init() { m_calc new Calculator(); } void TestCalculator::cleanup() { delete m_calc; m_calc nullptr; } void TestCalculator::testAdd() { QCOMPARE(m_calc-add(2.0, 3.0), 5.0); QCOMPARE(m_calc-add(-1.0, 1.0), 0.0); QCOMPARE(m_calc-add(0.0, 0.0), 0.0); } void TestCalculator::testSubtract() { QCOMPARE(m_calc-subtract(5.0, 3.0), 2.0); QCOMPARE(m_calc-subtract(0.0, 5.0), -5.0); } void TestCalculator::testMultiply() { QCOMPARE(m_calc-multiply(4.0, 3.0), 12.0); QCOMPARE(m_calc-multiply(0.0, 5.0), 0.0); QCOMPARE(m_calc-multiply(-2.0, 3.0), -6.0); } void TestCalculator::testDivide() { QCOMPARE(m_calc-divide(8.0, 2.0), 4.0); QCOMPARE(m_calc-divide(-6.0, 3.0), -2.0); } void TestCalculator::testDivideByZero() { QCOMPARE(m_calc-divide(1.0, 0.0), 0.0); }2.3 main 函数与构建配置写完了测试类还需要一个入口函数。QTest 提供了几个现成的宏用哪种取决于被测对象的性质// main.cpp #include QTest #include testcalculator.h QTEST_MAIN(TestCalculator)QTEST_MAIN是最常用的它会自动生成 main 函数同时还会初始化一个 QApplication 实例。对于要测 GUI 控件的场景这个宏是必需的因为很多控件需要 QApplication 环境才能正常工作。如果你明确知道自己只测非界面逻辑用QTEST_GUILESS_MAIN会更轻量它生成的是 QCoreApplication省去了界面环境初始化的开销。还有个QTEST_APPLESS_MAIN连 QCoreApplication 都不创建适用于完全不需要事件循环的纯逻辑测试。构建这块我习惯用 CMake。Qt6和Qt5的写法稍微有点差异但核心思路一致cmake_minimum_required(VERSION 3.16) project(QTestDemo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 REQUIRED COMPONENTS Test) add_executable(test_calculator main.cpp testcalculator.cpp calculator.cpp ) target_link_libraries(test_calculator PRIVATE Qt6::Test)这里有个细节需要注意CMAKE_AUTOMOC必须开启测试类里用了Q_OBJECT宏没有 moc 处理是无法编译的。如果你用 Qt Creator 新建测试工程向导生成的项目模板会自动把这些都配好。2.4 跑起来看结果构建成功后直接运行终端会输出这样的结果********* Start testing of TestCalculator ********* Config: Using QtTest library 6.5.0 Qt 6.5.0 PASS : TestCalculator::initTestCase() PASS : TestCalculator::testAdd() PASS : TestCalculator::testSubtract() PASS : TestCalculator::testMultiply() PASS : TestCalculator::testDivide() PASS : TestCalculator::testDivideByZero() PASS : TestCalculator::cleanupTestCase() Totals: 7 passed, 0 failed, 0 skipped, 0 blacklisted ********* Finished testing of TestCalculator *********看到Totals: 7 passed, 0 failed这个输出说明你的第一个 QTest Demo 已经成功跑起来了。3. 断言机制与数据驱动把测试写稳的关键3.1 三个核心断言宏怎么选QTest 里最常用的断言宏有三个QVERIFY、QCOMPARE和QVERIFY2。QVERIFY(condition)接收一个布尔表达式表达式为 true 就通过否则失败。它适合做条件判断类的断言比如判断一个指针是否为空、一个容器是否是空。QCOMPARE(actual, expected)比较两个值是否相等这是最常用的断言。它的好处是失败时会同时打印实际值和期望值一眼就能看出差异在哪里。比如QCOMPARE(m_calc-divide(8.0, 2.0), 4.0);如果返回的不是 4.0输出就会告诉你FAIL! : TestCalculator::testDivide() Compared doubles are not the same Actual (m_calc-divide(8.0, 2.0)): 3.5 Expected (4.0): 4这个失败信息对排查问题非常有用。但这里有个注意点QCOMPARE要求两个参数的类型是相同的如果类型不一致会直接编译报错。这其实是件好事很多隐蔽的类型转换问题在编译期就被拦住了。QVERIFY2(condition, message)是QVERIFY的增强版可以在断言失败时输出自定义的提示信息QVERIFY2(result, qPrintable(QString(Expected result to be true, but got false. Detail: %1).arg(detail)));写 QVERIFY2 的时候建议信息里带上关键上下文不然哪天测试挂了看到的是一串干巴巴的自定义消息排查起来一样费劲。3.2 数据驱动一套逻辑跑多组输入单元测试里经常遇到一种情况同一个功能要验证很多组输入输出。笨办法是写很多个测试函数或者在函数里写很多行 QCOMPARE。QTest 提供了一个优雅的机制——数据驱动测试把测试数据与测试逻辑分离。用法分三部分。首先是定义一个数据行生成函数名字有固定格式要求必须叫_data后缀void TestCalculator::testAdd_data() { QTest::addColumndouble(a); QTest::addColumndouble(b); QTest::addColumndouble(expected); QTest::newRow(positive numbers) 2.0 3.0 5.0; QTest::newRow(negative numbers) -1.0 -1.0 -2.0; QTest::newRow(with zero) 0.0 5.0 5.0; QTest::newRow(decimal numbers) 0.1 0.2 0.3; }然后在对应的测试函数里通过QFETCH宏取数据void TestCalculator::testAdd() { QFETCH(double, a); QFETCH(double, b); QFETCH(double, expected); QCOMPARE(m_calc-add(a, b), expected); }这个机制运行时会逐行执行测试函数。每一行数据相当于一个独立的子用例转储到报告里哪一个数据集失败一目了然不会像单函数里堆一堆 QCOMPARE 那样只知道add 函数挂了却不知道是哪组数据触发的。有一个经典坑testAdd_data()和testAdd()的名字必须完全对应比如testAdd_data是testAdd的数据提供者。如果名字拼错了QtTest 会自动把数据驱动标记为失败跑的时候会看到类似 No data available 的提示。3.3 浮点数比较的陷阱回看我的数据表里有0.1 0.2 0.3这组数据这个用例用QCOMPARE直接跑是必然失败的因为浮点数的二进制表示决定了0.1 0.2的结果并不是精确的0.3。这是计算机基础常识但手一快就会在测试里踩进去。QTest 针对这个场景提供了qFuzzyCompare专门用来做浮点数近似比较。用法是包裹一层 qFuzzyCompare 后用 QVERIFY 断言或者直接使用 Qt 6 提供的 QCOMPARE 浮点重载它会自动做近似比较。但要注意qFuzzyCompare对接近零的数值判定逻辑比较特殊如果被测值是 0 附近的小数建议自己加一个绝对误差的判断简单可靠bool almostEqual(double a, double b, double epsilon 1e-9) { return qAbs(a - b) epsilon; } QVERIFY(almostEqual(m_calc-add(0.1, 0.2), 0.3));我在实际项目里都是封装一批这样的辅助断言函数放在一个公共头文件里供所有测试使用避免每个测试文件里重复造轮子。4. 信号与 GUI 测试QTest 的另一半能力4.1 用 QSignalSpy 验证信号是否发出很多人的测试项目里被测对象不是简单的计算器而是一个 QObject 派生类它的业务逻辑会产生信号。比如一个网络请求管理器请求完成时发出finished(const QVariant result)信号。这种场景下你要验证的是某个操作后信号是否发出、参数是否正确。QTest 提供了QSignalSpy类专门做这件事。它像一个监听器挂在某个 QObject 的某个信号上记录信号发出的次数和参数列表class RequestManager : public QObject { Q_OBJECT public: using QObject::QObject; void start() { QVariant result QVariant(hello); emit finished(result); } signals: void finished(const QVariant result); };对应的测试void TestRequestManager::testFinishedSignal() { RequestManager manager; QSignalSpy spy(manager, RequestManager::finished); manager.start(); QCOMPARE(spy.count(), 1); QListQVariant arguments spy.takeFirst(); QCOMPARE(arguments.at(0).toString(), QString(hello)); }这里有一个非常容易踩的坑QSignalSpy必须在信号触发之前创建。如果你先调用了manager.start()再去创建 spy信号已经发完了spy.count()永远是 0。这个错误我犯过不止一次排查了半天才发现是监听器创建晚了。还有一个细节信号带参数名很重要。QSignalSpy依赖信号的参数类型信息来解析参数值如果信号声明时没有给参数起名字有些 Qt 版本下读取参数会得到空值。4.2 模拟鼠标键盘事件QTest 在 GUI 测试里的王牌能力是模拟用户交互。比如我们有一个登录窗口用户输入用户名密码后点击登录按钮窗口发出loginRequested(QString, QString)信号。测试可以直接驱动这些控件void TestLoginDialog::testLoginFlow() { LoginDialog dialog; QSignalSpy spy(dialog, LoginDialog::loginRequested); QLineEdit *usernameEdit dialog.findChildQLineEdit *(usernameEdit); QLineEdit *passwordEdit dialog.findChildQLineEdit *(passwordEdit); QPushButton *loginBtn dialog.findChildQPushButton *(loginBtn); QVERIFY(usernameEdit ! nullptr); QVERIFY(passwordEdit ! nullptr); QVERIFY(loginBtn ! nullptr); QTest::keyClicks(usernameEdit, admin); QTest::keyClicks(passwordEdit, 123456); QTest::mouseClick(loginBtn, Qt::LeftButton); QCOMPARE(spy.count(), 1); QCOMPARE(spy.takeFirst().at(0).toString(), QString(admin)); }这段代码里用findChild查找控件是可行的但它的前提是控件对象名必须设置好。生产代码里每个控件都设置setObjectName是个好习惯不光是测试需要调试的时候dumpObjectTree()排错也方便。有个很意外的体验是QTest::keyClicks是逐字符模拟键盘输入默认会往当前焦点控件发送按键事件所以被测控件必须要获得焦点。如果你的窗口没有显式show()有些平台下焦点逻辑会不正常稳妥的做法是测试里先dialog.show()并调用QTest::qWaitForWindowExposed(dialog)等待窗口真正显示出来再操作。4.3 时序等待qWait 的正确打开方式GUI 测试里异步场景很常见比如点击按钮后网络请求返回数据界面才刷新。这类测试不能直接断言得等一步。QTest 提供了两个等待函数QTest::qWait(ms)和QTest::qWaitFor(predicate, timeout)。qWait(ms)是固定等多少毫秒简单但低效。如果断言太快会挂太慢会拖累整个测试套件。更推荐qWaitFor配合一个条件表达式QTRY_VERIFY_WITH_TIMEOUT(spy.count() 1, 3000);QTRY_VERIFY_WITH_TIMEOUT会反复检查条件直到条件满足或者超时。这个机制比固定等待优雅多了响应快的情况下几乎瞬间通过响应慢也不会误报只要在超时时间内完成就算过。但这里有个很多人不知道的细节qWait和QTRY_*系列宏在执行期间会持续处理事件循环而不是干等。如果不处理事件循环Qt 内部那些依赖事件队列的信号连接可能永远不会执行导致测试永远等待超时。所以在测试函数里如果要写自定义的循环等待逻辑记得在循环体里调用QCoreApplication::processEvents()或者直接用 QTest 提供的现成方法。5. 集成 CI 与命令行运行细节5.1 Qt Creator 还是命令行本地开发时直接在 Qt Creator 里运行测试是最方便的。Qt Creator 对 QTest 有专门的支持面板可以列出所有测试函数单独运行某一个用例还能查看详细的输出日志。调试单个失败用例时这个方法效率极高。但到了 CI持续集成阶段一切都得命令行化。QTest 的可执行程序本身就是个命令行工具直接运行就好。为了让 CI 输出干净可读通常会加一个参数-silent这样每个用例只输出结果行不会刷一堆繁琐的日志。如果希望每个测试的输出更加紧凑还可以考虑-txt格式它在一些 CI 平台上更适合做日志归档。5.2 实用的命令行参数QTest 可执行程序支持一批命令行选项我用得最多的是这几个参数作用-functions列出所有测试函数名不执行测试-silent精简输出适合 CI 日志-o file,txt把测试结果同时输出到文件和终端-v1/-v2详细 / 更详细输出本地排查问题用-maxwarnings 0不限制警告信息数量-platform offscreen无头模式下跑 GUI 测试CI 必备最后这个-platform offscreen参数非常关键。CI 环境通常是纯命令行没有显示器直接跑会报 could not connect to display 之类的错误。加上这个参数后Qt 会使用 offscreen 平台插件在内存里模拟一个虚拟屏幕GUI 测试照样能执行。我在 Jenkins 上跑界面测试时这个参数是必加的。5.3 多测试工程的组织当测试用例变多之后把所有测试打成一个可执行文件会使每次运行时间变长失败排查也变得困难。我一般会按模块拆分成多个测试可执行文件比如test_calculator、test_request_manager、test_login_dialog每个可执行文件对应一个测试套件。CMake 里可以用循环批量添加set(TEST_TARGETS test_calculator test_request_manager test_login_dialog) foreach(test_target IN LISTS TEST_TARGETS) add_executable(${test_target} ${test_target}.cpp ${test_target}.h) target_link_libraries(${test_target} PRIVATE Qt6::Test) add_test(NAME ${test_target} COMMAND ${test_target} -silent) endforeach()这样ctest命令就能统一调度所有测试。CI 里执行ctest --output-on-failure哪个模块挂了会直接在报告里标红。6. 写测试过程中踩过的坑6.1 QCOMPARE 的参数类型不一致问题QCOMPARE 的编译期类型检查非常严格。比如拿QString和const char*直接比较会直接报编译错误。必须显式转换比如QCOMPARE(actual, QString(expected))。虽然看起来麻烦但这实际上避免了 C 风格比较时隐式转换带来的潜在坑。6.2 init 里初始化的变量在测试函数中被修改init()每个测试函数前都会执行所以测试函数里对成员变量的修改不会泄漏到下一个用例。听起来是理所当然但如果你习惯从对象只创建一次的角度写测试很容易写出依赖执行顺序的用例。这在 QTest 里是大忌同一个测试套件里的函数执行顺序是不保证的。每个用例的独立性QTest 在框架层面支持得很好剩下的就需要写测试的人遵守这个纪律。6.3 数据行函数不执行的原因如果testAdd_data()里的QTest::newRow没有被调用测试会静默通过不会报错。这经常让人摸不着头脑测试明明没数据怎么还是绿的。这是因为 QTest 默认会把没有数据行的数据驱动测试视为空套件直接跳过。排查这类问题时先看看是否真的添加了数据行。6.4 QSignalSpy 的监听起来太晚再强调一次这个最常见的坑。QSignalSpy构造时必须传入 QObject 指针和信号指针但它的监听从构造函数那一刻才开始生效。如果你的信号在 spy 构造之前就已经发出就永远监听不到。这个时序问题我在实际项目里踩了很多次尤其是重构代码时把触发的逻辑提前了几行测试就开始莫名失败。6.5 手动实现事件循环导致的死等有些老代码会这样写等待逻辑while (!condition) { // dead loop }这在 QTest 里必死无疑。等待期间不处理事件循环所有异步结果都不会过来条件永远不满足就成死循环了。正确做法是调QTest::qWait(10)或者直接用QTRY_VERIFY让事件循环有机会运转。7. 把 QTest 纳入日常开发节奏最后分享一点个人体会。单元测试这东西最怕的不是不会写而是坚持不下来。QTest 的优点在于它和 Qt 生态是一体的不像第三方测试框架那样要适配来适配去所以把它放进日常开发流程的阻力最小。我现在的习惯是每写完一个核心业务类顺手就把对应的测试类写了每次修完一个 bug先写一个复现这个 bug 的测试用例确认它跑红再回头修代码修到它跑绿。这个过程多花的时间并不多但给你积累下来的是一整套回归保障。如果你的团队里还有其他 Qt 开发者建议把测试工程模板沉淀下来之后的新模块直接用这个壳子输入业务代码就能跑。QTest 这条路一旦铺好后面就是水到渠成的事。本文还有配套的精品资源点击获取