简介:《利用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_MAIN
QTest 提供的入口宏有三个,选错的表现不是编译失败,而是用例跑起来行为诡异,比如控件创建了但事件循环不转、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 slots,QTest 靠元对象系统枚举槽函数来发现用例,写成普通成员函数不会被调用,进程跑完退出码还是 0,你会误以为全绿。
2.2 qmake 与 CMake 两套构建接法
老项目大多还是 qmake,新项目基本转到 CMake,Demo 里两套都给了,避免接手时先花半天改构建系统。
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 才知道要生成它。如果漏了 include,AUTOMOC 可能什么都不生成,链接期才爆。另一个是add_test的NAME,别和可执行文件名冲突到难以分辨,CI 报告里靠这个名字定位失败用例。
构建和运行就两行:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug 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_unique<Repository>(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(QList<int>{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::addColumn<int>("a"); QTest::addColumn<int>("b"); QTest::addColumn<int>("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 QList<QVariant> 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 += testlib,CMake 下缺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_PLATFORM=offscreen,让 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_TYPE=Debug \ -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_coverage=1 lcov --remove coverage.info '/usr/*' '*/Qt/*' '*/build-cov/*' \ --output-file coverage.clean genhtml coverage.clean --output-directory coverage_html第一行抓数据,branch_coverage=1让分支覆盖率也统计进来,这个指标比行覆盖率更能反映条件判断有没有被覆盖到。第二行做过滤,必须把系统头文件和 Qt 自身的源文件剔掉,否则报告里会出现大量你根本没写过的文件,把整体数字稀释得毫无参考价值。第三行出 HTML,打开coverage_html/index.html就能按目录逐层下钻,点进具体文件能看到哪一行没跑到。
一个实际经验:先别追求数字,把行覆盖率跑到六成左右再去看分支覆盖,效果更明显。项目里加进来第一批用例时,最常见的高价值目标是那些带多个if条件返回的错误分支,往往写三条用例就能把某个模块的分支覆盖率翻一倍,而且这些分支恰恰是线上最容易出问题的地方。
从那以后我每次给一个没有测试的模块补第一道用例,都会强制先跑一次覆盖率基线,把它记在任务单里,然后每加一批用例就对比一次。没有基线的时候,你对“覆盖率在涨”这件事完全没有判断力,写了几十行用例可能全堆在一段已经覆盖过的简单代码上。这套 Demo 里工程结构、构建脚本和用例模板我都放在一起了,照着改一下被测类名就能接进自己的项目,希望帮到你。
本文还有配套的精品资源,点击获取