
简介《利用QTest进行单元测试的Demo》面向已具备Qt基础、希望为普通应用程序与动态链接库补充单元测试的开发者聚焦测试类组织、数据驱动用例、断言校验以及DLL内部非导出方法的访问策略。压缩包共13个文件以5个cpp源码、4个h头文件和4个pro工程配置为主源码对应测试用例与示例实现头文件承载测试类和接口声明pro文件便于用qmake构建各子项目整体仅6KB结构轻量。已有1467人学习适合作为QTest入门与工程落地的参考样本。读者可从中了解普通应用类与DLL内部类的测试拆分方式参考友元测试类和仅测试用导出接口两种思路并对照数据驱动与断言写法整理自己的测试用例从而更快搭建可运行的Qt单元测试骨架对测试组织方式和DLL测试边界形成直观认识。1. 一份能直接跑的 QTest 单元测试 Demo从“改一行炸三处”到可复现的回归网改一行代码登录、导出、设置三个界面一起崩——没有测试的 Qt 工程里这种场面几乎是日常。这套 QTest 单元测试 Demo 针对的就是这个痛点它用 Qt 自带的 Qt Test 框架演示了怎么把业务逻辑从界面里剥出来、怎么用断言把行为钉死、怎么用信号监听把异步时序的不确定性压下去最后串进 CTest 和覆盖率报告。它适合三类人刚接手一个没有测试的 Qt 老项目、想补上第一层回归网的正在给团队定测试规范、需要一份能照着改的模板的以及被“改完不敢提交”折腾过、想搞清楚 QTest 边界在哪的熟手。这套 Demo 不走演示流程它复现的是真实工程里你会撞上的那些问题。2. 最小可跑闭环testlib 链接、三种 main 宏与四个生命周期钩子先把最少的代码跑起来比先读文档有用得多。QTest 的最小闭环只有三件事工程里链上 Test 模块、测试类继承QObject并把用例写成私有槽、末尾用一个 main 宏把入口补上。这三件事任何一件漏了报错信息都不会直接指向问题本身所以先把骨架搭对。2.1 三种 main 宏怎么选QTEST_MAIN、QTEST_GUILESS_MAIN、QTEST_APPLESS_MAINQTest 提供的入口宏有三个选错的表现不是编译失败而是用例跑起来行为诡异比如控件创建了但事件循环不转、QSignalSpy::wait永远超时。QTEST_MAIN会构造一个QApplication适用于任何涉及QWidget、QWindow的 GUI 测试。QTEST_GUILESS_MAIN构造的是QCoreApplication适合只依赖QObject事件循环、不碰界面的用例比如网络、文件、定时器、线程。QTEST_APPLESS_MAIN完全不构造应用对象适合纯算法、纯数据结构的测试跑得最快但任何依赖事件循环的东西在里面都不能用。选择规则很简单用例里出现QWidget就用第一个出现QTimer/QSignalSpy::wait/QThread就用第二个两者都没有就用第三个。我一般默认从QTEST_GUILESS_MAIN起手因为它既支持事件循环又不依赖显示环境CI 上跑得稳等真的要测界面了再换成QTEST_MAIN代价只是改一行。// tst_calculator.cpp —— 纯逻辑测试的最小骨架 #include QtTest #include calculator.h class TestCalculator : public QObject { Q_OBJECT private slots: void initTestCase(); // 整个用例集开始前跑一次 void cleanupTestCase(); // 整个用例集结束后跑一次 void init(); // 每个用例前跑一次 void cleanup(); // 每个用例后跑一次 void testAdd(); void testDivideByZero(); }; void TestCalculator::initTestCase() { qInfo() 开始跑 Calculator 用例集; } void TestCalculator::cleanupTestCase() {} void TestCalculator::init() {} void TestCalculator::cleanup() {} void TestCalculator::testAdd() { Calculator calc; QCOMPARE(calc.add(2, 3), 5); } void TestCalculator::testDivideByZero() { Calculator calc; QVERIFY(calc.divide(1, 0).isNull()); // 返回 Optional 风格的结果 } // 不依赖 QCoreApplication选 APPLESS QTEST_APPLESS_MAIN(TestCalculator) #include tst_calculator.moc这段代码有两个容易忽略的点。第一Q_OBJECT写在.cpp里必须手动#include tst_calculator.moc否则 moc 生成的元对象代码无法链接进来报错是undefined reference to vtable for TestCalculator看起来和测试毫无关系。第二用例函数必须是private slotsQTest 靠元对象系统枚举槽函数来发现用例写成普通成员函数不会被调用进程跑完退出码还是 0你会误以为全绿。2.2 qmake 与 CMake 两套构建接法老项目大多还是 qmake新项目基本转到 CMakeDemo 里两套都给了避免接手时先花半天改构建系统。qmake 的接法短得离谱关键是QT testlib和CONFIG testcase两行# tst_calculator.pro QT testlib QT - gui # 纯逻辑测试去掉 GUI 依赖构建更快 CONFIG testcase # 让 make check 能跑到这个目标 CONFIG console TARGET tst_calculator TEMPLATE app SOURCES tst_calculator.cpp calculator.cpp HEADERS calculator.hCONFIG testcase的作用是把目标注册成make check的一部分不加也能手动跑但接不进批量执行。QT - gui是个惯性动作很多人忘了加结果在无显示环境的构建机上白拉一堆图形依赖。CMake 这边要稍微多写几行重点是find_package里带上Test组件以及add_test把目标注册给 CTest# CMakeLists.txt cmake_minimum_required(VERSION 3.16) project(qtest_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # .cpp 里的 Q_OBJECT 靠它生成 moc find_package(Qt6 REQUIRED COMPONENTS Core Test) enable_testing() add_library(calc STATIC calculator.cpp calculator.h) target_link_libraries(calc PUBLIC Qt6::Core) add_executable(tst_calculator tst_calculator.cpp) target_link_libraries(tst_calculator PRIVATE calc Qt6::Test) add_test(NAME tst_calculator COMMAND tst_calculator)参数上值得说的是CMAKE_AUTOMOC。它在扫描源文件时会把带Q_OBJECT的文件交给 moc 处理但.cpp里的Q_OBJECT需要你把生成的.moc文件显式 include 进来AUTOMOC 才知道要生成它。如果漏了 includeAUTOMOC 可能什么都不生成链接期才爆。另一个是add_test的NAME别和可执行文件名冲突到难以分辨CI 报告里靠这个名字定位失败用例。构建和运行就两行cmake -S . -B build -DCMAKE_BUILD_TYPEDebug cmake --build build -j cd build ctest --output-on-failure--output-on-failure是必须加的默认 ctest 只在失败时给一行摘要没有 QTest 打印的哪一行断言挂了、期望值实际值各是多少。2.3 用例生命周期四个钩子的执行顺序initTestCase、init、cleanup、cleanupTestCase这四个钩子的执行顺序决定了你把准备代码放在哪里才不会互相污染。实际顺序是initTestCase一次 → 对每个用例依次init→ 用例函数 →cleanup→ 全部跑完cleanupTestCase一次。这个顺序意味着两件事。第一initTestCase里适合放“整个用例集只做一次且很贵”的事比如起一个本地服务、加载一个几 MB 的配置文件、连接一次数据库放进init里会让每个用例都重来一遍跑十个用例就是十倍开销。第二init和cleanup才是每个用例的隔离层成员变量、临时目录、全局状态都应该在这里重置。一个典型写法void TestRepository::initTestCase() { // 只在最开头建一次临时目录避免每个用例都做 IO QVERIFY(m_tempDir.isValid()); m_dbPath m_tempDir.filePath(test.db); } void TestRepository::init() { // 每个用例都从干净的库开始防止上一条数据串到下一条 m_repo std::make_uniqueRepository(m_dbPath); QVERIFY(m_repo-open()); } void TestRepository::cleanup() { m_repo-close(); m_repo.reset(); QFile::remove(m_dbPath); }这里m_tempDir是QTemporaryDir成员isValid()返回 false 说明临时目录都建不出来多半是磁盘或权限问题早失败早定位。init里用QVERIFY而不是直接构造是因为 QTest 在init失败时会跳过当前用例并标记为失败而不是让后面的用例在空指针上再崩一次——这个行为差异能省掉大量排查时间。还有一点钩子函数本身也是槽同样受 QTest 的失败即返回语义约束。init里一旦某个QVERIFY失败当前用例直接不执行cleanup仍会被调用所以清理逻辑不要假设对象一定构造成功判空是必要的。3. 断言与数据驱动QVERIFY、QCOMPARE 的失败信息差与 _data() 表驱动用例写多了会发现测试代码的质量差异主要不在覆盖率而在失败时能不能一眼看出问题。QVERIFY(true)和QCOMPARE(a, b)在语义上都能表达“这里应该成立”但挂掉时给出的信息量差了一个数量级这个差别在 CI 上、在别人接手你的用例时会被无限放大。3.1 QVERIFY 与 QCOMPARE 的失败信息差异QVERIFY(cond)只判断真假失败时输出的是FAIL! : TestX::testY() cond returned FALSE具体值一个都不给。QCOMPARE(actual, expected)失败时会同时打印实际值和期望值还会尝试用toString输出对QString、容器、自定义类型都能给出可读内容。所以规则很明确只要比较的是两个值一律用QCOMPARE只有布尔性质的条件指针非空、列表非空、状态位为真才用QVERIFY。QVERIFY还有一个变体QVERIFY2(cond, message)第二个参数是失败时附加的说明。这在条件本身没法和期望值对比时特别有用比如校验一个解析结果是否合法void TestParser::testRejectMalformedInput() { Parser p; const ParseResult r p.parse({invalid json); QVERIFY2(!r.ok(), 非法输入居然解析成功了检查词法层的错误分支); QCOMPARE(r.errorCode(), ParseError::Syntax); }两行断言的分工很清楚QVERIFY2负责拦住“根本没进错误分支”这种情况并给出人能读的原因QCOMPARE负责确认进的是哪个错误分支。如果只写第一行失败时你只知道没报错不知道报的是哪个错。关于返回值语义也要清楚QVERIFY和QCOMPARE失败后都会从当前测试函数return不是抛异常也不是继续往下跑。所以一个用例里断言失败点之后的代码不会执行资源释放逻辑放在断言之后是有风险的该用 RAII 的地方别指望后面的代码兜底。从 Qt 6.4 起QCOMPARE拆分成了QCOMPARE_EQ、QCOMPARE_NE、QCOMPARE_LT、QCOMPARE_LE、QCOMPARE_GT、QCOMPARE_GE一组宏语义更明确失败信息也更针对。新项目直接用新宏老代码里混用也没问题。3.2 浮点比较与容器比较的写法浮点数是断言的重灾区。QCOMPARE(qSqrt(2.0), 1.41421356)这种写法看着对实际会因为最后几位不同而挂而且挂得毫无规律。正确做法有两种看场景选。一种是显式容差最可控void TestGeometry::testDistance() { const double d distance(QPointF(0, 0), QPointF(3, 4)); // 显式给容差比 QCOMPARE 直接比 double 稳得多 QVERIFY(qAbs(d - 5.0) 1e-9); // 需要相对误差时大数场景换成比例判断 QVERIFY(qAbs((d - 5.0) / 5.0) 1e-9); }另一种是用QCOMPARE配合qFuzzyCompare适合两个量级相近的浮点QVERIFY(qFuzzyCompare(value, 0.1));但qFuzzyCompare对 0 附近会失效比较的双方有一个是 0 时它会直接返回 false这是历史实现留下的行为。所以涉及可能为 0 的量老老实实用绝对容差。容器比较反而是 QTest 的强项。QCOMPARE(QListint{3, 1, 2}, expected)失败时会把两个列表逐元素打印出来能直接看出第几个元素不一致。对于自定义类型只要实现了operator和QTest::toString的友元重载失败信息里就能输出可读内容否则打印的是一串地址等于没有信息。3.3 _data() 表驱动把重复用例压成表同一段逻辑要测十组输入复制十遍函数是最糟的做法——改一处断言要改十个地方还很容易漏。QTest 的表驱动机制把这个过程压缩成“一个_data()槽 一个用例槽”。规则是用例函数叫testAdd配套的数据函数必须叫testAdd_data两者都声明为private slots_data()在前、用例在后。_data()里用QTest::addColumn声明列类型和列名用QTest::newRow起一个带名字的行依次填值。用例函数里用QFETCH按类型和列名取值。void TestCalculator::testAdd_data() { QTest::addColumnint(a); QTest::addColumnint(b); QTest::addColumnint(expected); QTest::newRow(正数) 2 3 5; QTest::newRow(一正一负) 7 -3 4; QTest::newRow(零) 0 0 0; QTest::newRow(全负) -4 -6 -10; } void TestCalculator::testAdd() { QFETCH(int, a); QFETCH(int, b); QFETCH(int, expected); Calculator calc; QCOMPARE(calc.add(a, b), expected); }newRow的名字不是装饰它直接出现在失败信息里格式是TestCalculator::testAdd(一正一负)。所以行名要写成能说明这一行在验什么别用row1、case2出问题时等于又少一条线索。列类型上有个限制必须是 QTest 认识的类型基本类型、QString、QByteArray、QVariant都没问题自定义结构体需要注册元类型再包进QVariant。实际工程里我一般把复杂输入序列化成QByteArray或 JSON 字符串放进表里用例内再解析这样表保持可读也不用到处做类型注册。还有一处容易踩QFETCH的类型必须和addColumn声明的完全一致写错时编译能过运行期断言失败报的是QFETCH: Type mismatch而且不会告诉你哪一行出的问题。数据驱动还有个好处是能配合QTest::setBenchmarking做简易性能基线同一个流程跑不同规模的数据时间写在报告里。不过这是锦上添花先把功能断言写对更重要。3.4 预期失败与跳过QEXPECT_FAIL 和 QSKIP现实工程里总有一些“已知有问题但暂时不修”的用例。直接注释掉等于丢掉了监控而让它一直红着又会让整条 CI 失去意义。QEXPECT_FAIL就是给这种情况准备的它声明“接下来这个断言我预期它会失败”如果真失败了用例标记为 XFAIL 而不是 FAIL如果它意外通过了标记为 XPASS同样算失败——因为这说明你的预期过时了该把标记删掉。void TestExporter::testUnicodeFilename() { // 已知在某个平台的文件系统上会退化先标记预期失败 QEXPECT_FAIL(, 文件名编码问题待修复见任务单, Continue); Exporter e; QVERIFY(e.exportTo(报告-2024.csv)); }第三个参数Continue表示断言失败后继续往下跑Abort表示立即返回。一般用Continue让后面的断言也能给出信息。QSKIP(原因)则是直接跳过当前用例标记为 SKIP。适合“当前环境不具备条件”的场景比如依赖外部服务、依赖特定平台、依赖某些只在发布构建里才有的资源。跳过的用例在报告里是可见的不会像注释掉那样悄无声息地消失这一点比注释强太多。这两个宏的共同价值是让“暂时不通过”变成一种被记录的状态而不是被隐藏。团队里如果有人习惯性注释掉失败用例这通常是个信号——要么这个用例本身设计得不好维护要么它暴露的问题没人愿意认领。4. 信号与异步时序QSignalSpy 和 QTRY_COMPARE 怎么把 flaky 用例钉死QTest 用例里最常见的一类不稳定失败flaky来自时序断言跑得比被测代码快或者事件循环根本没转起来。表现是本地跑十次全绿CI 上跑三次挂一次日志里看不出任何异常。这类问题靠加QTest::qWait(1000)硬等通常只是把概率降低没解决根因还白白拖慢整个测试套件。4.1 QSignalSpy 的三种等待方式QSignalSpy的作用是挂在一个信号上记录它被触发了多少次、每次带什么参数。用法上第一步永远是isValid()因为信号签名写错时构造出来的 spy 是无效的后续所有操作静默失败。#include QSignalSpy void TestWorker::testFinishedSignal() { Worker worker; // 新式写法编译期就能校验信号存在性 QSignalSpy spy(worker, Worker::finished); QVERIFY2(spy.isValid(), 信号连接失败检查信号签名是否与声明一致); worker.start(); // 等最多 5 秒返回 false 说明信号压根没发出来 QVERIFY(spy.wait(5000)); QCOMPARE(spy.count(), 1); const QListQVariant args spy.takeFirst(); QCOMPARE(args.at(0).toInt(), 42); }这里有三个参数值得说清。wait(5000)是阻塞等待单位毫秒期间会转事件循环如果信号已经发过了它会立刻返回 true不会白等。count()拿到的是一次或多次触发累计数异步代码里信号重复发射是常见缺陷这个断言能抓到。takeFirst()会把记录取走并从列表里移除想取全部参数就遍历spy本身它支持下标访问。QSignalSpy还能直接连 lambda 之外的普通函数也可以用来做“信号不该发出”的负向断言void TestValidator::testNoSignalOnInvalidInput() { Validator v; QSignalSpy spy(v, Validator::accepted); v.validate(###); QTest::qWait(200); // 给异步校验留出窗口 QCOMPARE(spy.count(), 0); // 明确断言没有触发 }负向断言的坑在于你必须给一个“足够长”的观察窗口否则“信号迟到”会被误判成“信号没发”。这个窗口长度取决于被测逻辑的最坏延迟经验值是正常路径耗时的三到五倍写死在代码里时最好加注释说明依据。4.2 QTRY_COMPARE 的重试机制与超时参数QTRY_COMPARE是处理“最终一致”这类断言的正解它在一个超时窗口内反复求值一旦相等就立即通过超时才失败。内部实现是轮询加事件循环处理默认间隔和总超时可以在main里用QTest::qWaitForWindowExposed之外的环境变量或QTEST_FUNCTION_TIMEOUT调整也可以在单条断言上用带超时的版本。void TestDownloader::testStateReachesReady() { Downloader d; d.start(); // 最多等 5 秒期间自动转事件循环并重试比较 QTRY_COMPARE_WITH_TIMEOUT(d.state(), Downloader::Ready, 5000); // 数值型断言同理比等固定时间再比较稳定得多 QTRY_VERIFY_WITH_TIMEOUT(d.bytesReceived() 0, 3000); }和QTest::qWait加QCOMPARE的写法对比差别在失败时的行为固定睡眠的版本失败了你只知道“1 秒后它还没好”无法判断是差一点还是根本没动QTRY_*失败时会告诉你实际值是什么而成功时会立刻返回不浪费一秒。整个测试套件跑下来这一项能省掉的可观时间。参数选择上超时不要拍脑袋给个整数。合理的做法是先量一遍正常情况下的耗时再乘三到五倍。给得太长真出问题时排查要等很久给得太短CI 机器一慢就假失败。我一般把这类超时统一收在一个头文件里定义成常量避免散落在各处无法统一调整。还有一个容易忽略的细节QTRY_*系列在等待期间会调用QCoreApplication::processEvents这意味着它会让其他排队的信号和槽也跑起来。如果被测对象依赖“中间不会有别的事件插进来”这个特性会带来干扰这种情况需要改用更精确的同步手段比如用QSemaphore或 condition variable 在被测代码里显式通知。4.3 事件循环与 processEvents 的正确姿势很多初学者在测试里手动调QCoreApplication::processEvents()调一次不够就调三次再不够就套个循环。这种写法能过但不可维护因为没人知道为什么要调三次。问题的根源通常是信号是直接连接还是队列连接没搞清楚。同一线程内的默认连接是直接调用信号发射时槽同步执行根本不涉及事件循环跨线程或显式指定Qt::QueuedConnection时槽会排进事件队列必须等事件循环转起来才执行。测试里出现“信号发了但槽没跑”九成是把这两种情况搞混了。判断方法很直接看connect的第五个参数。没写就是Qt::AutoConnection同线程即直接连接跨线程即队列连接。测试代码里最好显式写出来别依赖默认推断因为被测对象的线程归属有时在测试里和在生产里不一样。对于必须等待事件循环的场景优先用QTRY_*系列其次用QSignalSpy::wait最后才是手动processEvents。手动调用最多用在“我要主动驱动一次事件处理”的场合比如测试里设置了一个定时器想让它在不真正等待的情况下立即触发这时可以配合QTest::qWait(0)。还有一种情况是测试QTimer::singleShot(0, ...)这类“下一轮事件循环执行”的语义QTest::qWait(1)或者QTRY_VERIFY都能覆盖不要用processEvents循环硬凑那种写法在加了新的异步逻辑后很容易失效。5. 避坑与排查五类让 QTest 工程跑不起来的真实故障下面这几条都是从实际工程里攒出来的症状各不一样但有个共同点报错信息几乎都不指向真正的原因。5.1 编译与链接阶段头文件、moc、链接库三条线现象一编译报QTest: No such file or directory或者链接期报undefined reference to QTest::qCompare一长串。原因是工程没把 Test 模块加进来。qmake 下缺QT testlibCMake 下缺find_package(Qt6 COMPONENTS Test)或者缺target_link_libraries(... Qt6::Test)。这两处经常只改了一处比如 CMake 里find_package加了但链接没加编译能过、链接全挂。解决办法是把两处对齐检查一遍顺手确认CMAKE_AUTOMOC或CONFIG moc没被关掉。现象二报undefined reference to vtable for TestXxx或者TestXxx::staticMetaObject找不到。原因是测试类里写了Q_OBJECT但没把生成的 moc 文件引进来。.cpp内的Q_OBJECT需要手动#include tst_xxx.moc且这一行必须放在文件最后。如果文件名和类名不一致或者编译输出目录变了导致相对路径失效也会报同样的错。解决是先确认 include 行拼写正确再把构建目录清掉重来一遍。增量构建下 moc 的依赖跟踪偶尔会失效rm -rf build重配一次往往就好了这个操作我做过太多次。现象三qmake 工程里make check什么都不跑返回 0。原因是没加CONFIG testcase或者TARGET名字和.pro文件名对不上导致目标没注册。这个坑的隐蔽之处在于它不会报错只是安静地什么也不做容易误以为测试通过了。解决是显式加CONFIG testcase再执行make check看输出里有没有实际执行的用例数。CI 配置里最好加一条“用例数为 0 就失败”的检查。5.2 运行与稳定性阶段状态串味和固定睡眠现象四单独跑某个用例总是通过整套一起跑就失败换个顺序又通过了。原因是用例之间共享了状态。常见来源有三个类成员变量没在init()里重置被测类内部用了单例或静态缓存临时文件路径写死在同一位置前一个用例没清理干净。解决办法是先把所有可变状态挪进init()重置临时资源用QTemporaryDir每个用例一个新目录。然后在本地用-functions参数单独跑一遍失败的用例确认它是干净的再跑整套对比。如果确认是静态缓存就得在被测类里补一个测试专用的重置接口这算是设计上的债早点还。现象五CI 上偶发失败日志里断言失败但值看着就是对的。原因是用了固定睡眠。QTest::qWait(500)在开发机上够用CI 机器负载高时就不够。另一种隐蔽情况是用了QCOMPARE去比较一个异步更新的值本地正好在断言前更新完了CI 上没有。解决办法是把所有“等一会儿再断言”改写成QTRY_COMPARE或QSignalSpy::wait让等待有明确的结束条件。改完之后如果还是偶发就去看被测对象有没有依赖真实时间的地方比如定时器的间隔被硬编码成很小这种情况下建议把它做成可注入的参数测试里调大。另外一个容易被算进 flaky 但实际不是的情况用例依赖执行顺序。比如前一个用例启动了服务后一个用例直接用。这种“隐式依赖”在单线程顺序执行下看起来没问题一旦开启并行执行ctest -j就会暴露。QTest 本身不保证用例之间的顺序别做这种假设。6. 进阶落地GUI 事件模拟的坐标陷阱与 gcov 覆盖率接进 CTest界面测试是最容易被跳过的一块因为它依赖显示环境在无头机器上跑不起来。绕过的办法是设置QT_QPA_PLATFORMoffscreen让 Qt 用离屏后端渲染控件能创建、事件能投递但不会真的打开窗口。这个环境变量在 CI 配置里加一行就够本地开发机不设置也不影响。有了离屏后端QTest::mouseClick和QTest::keyClick就能用了。这两个函数的核心参数是目标对象和坐标坐标必须相对于目标控件自身的坐标系而不是父窗口。很多人在按钮测试里传了QPoint(0, 0)点在了按钮左上角边框上某些样式下这个位置不响应点击于是断言失败又找不到原因。void TestDialog::testConfirmButton() { Dialog dlg; dlg.show(); // 等窗口真正暴露出来否则事件投递可能被丢弃 QVERIFY(QTest::qWaitForWindowExposed(dlg)); QSignalSpy spy(dlg, Dialog::confirmed); // 用 rect().center() 而不是硬编码坐标抗样式和布局变化 QTest::mouseClick(dlg.confirmButton(), Qt::LeftButton, Qt::NoModifier, dlg.confirmButton()-rect().center()); QCOMPARE(spy.count(), 1); }两个参数细节qWaitForWindowExposed的返回值一定要断言它在无显示环境下可能返回 false不加断言的话后面的事件投递全被静默丢弃表现出来的就是“点了没反应”。第二mouseClick的第三个参数是修饰键测试快捷键组合时通过它传Qt::ControlModifier之类比模拟两次按键可靠。覆盖率接入是另一个落地闭环。先把构建切到带插桩的配置cmake -S . -B build-cov -DCMAKE_BUILD_TYPEDebug \ -DCMAKE_CXX_FLAGS--coverage -O0 \ -DCMAKE_EXE_LINKER_FLAGS--coverage cmake --build build-cov -j cd build-cov ctest --output-on-failure--coverage等价于-fprofile-arcs -ftest-coverage必须在编译和链接两处都带上只加编译不加链接会出现运行期找不到__gcov_init的错误。-O0也不是可选项优化会合并或删除代码行导致覆盖率数据和源码行号对不上。跑完用例后生成报告lcov --capture --directory . --output-file coverage.info --rc branch_coverage1 lcov --remove coverage.info /usr/* */Qt/* */build-cov/* \ --output-file coverage.clean genhtml coverage.clean --output-directory coverage_html第一行抓数据branch_coverage1让分支覆盖率也统计进来这个指标比行覆盖率更能反映条件判断有没有被覆盖到。第二行做过滤必须把系统头文件和 Qt 自身的源文件剔掉否则报告里会出现大量你根本没写过的文件把整体数字稀释得毫无参考价值。第三行出 HTML打开coverage_html/index.html就能按目录逐层下钻点进具体文件能看到哪一行没跑到。一个实际经验先别追求数字把行覆盖率跑到六成左右再去看分支覆盖效果更明显。项目里加进来第一批用例时最常见的高价值目标是那些带多个if条件返回的错误分支往往写三条用例就能把某个模块的分支覆盖率翻一倍而且这些分支恰恰是线上最容易出问题的地方。从那以后我每次给一个没有测试的模块补第一道用例都会强制先跑一次覆盖率基线把它记在任务单里然后每加一批用例就对比一次。没有基线的时候你对“覆盖率在涨”这件事完全没有判断力写了几十行用例可能全堆在一段已经覆盖过的简单代码上。这套 Demo 里工程结构、构建脚本和用例模板我都放在一起了照着改一下被测类名就能接进自己的项目希望帮到你。本文还有配套的精品资源点击获取