简介:这份压缩包面向使用 Qt 进行 C++ 开发、希望系统补齐单元测试实践的开发者,核心是用 QTest 框架演示普通应用类与 DLL 内部非导出类方法的测试思路,覆盖测试用例设计、数据驱动输入和断言校验等关键环节。示例按普通应用、单元测试与简单 DLL 分块组织,展示测试类如何编写初始化与清理逻辑,并给出通过友元或测试导出接口访问 DLL 内部方法的做法,便于读者理解可测试性与封装之间的取舍。资源共 13 个文件,以 5 个 cpp 源文件、4 个 h 头文件和 4 个 pro 工程文件为主,压缩包约 6KB,目录紧凑,适合作为入门示例工程查阅。已有 1467 人学习,对需要快速搭建 QTest 示例、参考断言与数据驱动写法,或研究 DLL 内部类测试方案的 Qt 初中级开发者较有价值。
1. 单元测试这件事,QTest 的 Demo 到底能省掉多少返工
改一行加法逻辑,构建过了,界面也起得来,结果三天后接口对账发现小数位全错——这种返工很多人经历过一次,就再也不想经历第二次。这份 Demo 是一套能直接跑的 Qt 单元测试骨架:一个纯逻辑的被测类、一个继承QObject的测试类、CMake 与 qmake 两份构建脚本,外加数据驱动、信号监听和命令行输出 JUnit XML 的示例。它解决的不是“要不要写测试”这种讨论了一万遍的问题,而是测试目录一直是空的、不知道QTEST_MAIN该怎么放、也不知道 CI 上怎么读结果这些实际卡点。适合能写 Qt 业务代码但没搭过测试工程的开发者,也适合接手老项目、想补一层回归保障的人。下面按骨架、断言、数据驱动、排错、进 CI 五块拆开写。
2. 把测试挂进 Qt 工程:QTest 骨架的四个文件和三种 main 宏
QTest 的门槛不在于断言写不出来,而在于工程结构没搭对。见过不少工程,tst_xxx.cpp写了几百行,ctest一跑显示零个用例,然后就开始怀疑 framework 有 bug。实际上问题基本都集中在三处:被测代码和测试代码之间的链接关系、moc 的生成时机,以及主宏选错了。
这套 Demo 的文件结构很克制,四个文件:
| 文件 | 作用 | 关键点 |
|---|---|---|
calculator.h/calculator.cpp | 被测类,只依赖 QtCore | 不含 main,不碰界面 |
tst_calculator.cpp | 测试类与用例 | 末尾必须 include 生成的.moc |
CMakeLists.txt | 构建与注册用例 | AUTOMOC、add_test、TIMEOUT |
tst_calculator.pro | qmake 老工程的等价写法 | QT += testlib、CONFIG += testcase |
2.1 被测类与测试类的边界:为什么被测代码要抽成静态库
先看测试类的骨架,这是整份 Demo 里最该抄的一段:
// tst_calculator.cpp #include <QtTest> #include "calculator.h" // 被测类,纯 QtCore,不依赖 QWidget class TestCalculator : public QObject { Q_OBJECT private slots: // 必须是 slots,普通成员函数不会被当成用例 void initTestCase(); // 整个套件开始前跑一次:加载配置、建临时目录 void cleanupTestCase(); // 全部用例结束后跑一次:清临时文件 void init(); // 每个用例前跑:重置全局状态 void cleanup(); // 每个用例后跑 void testAdd(); // 用例名即报告里的用例名 }; void TestCalculator::initTestCase() { qInfo() << "Calculator 套件开始执行"; } void TestCalculator::cleanupTestCase() {} void TestCalculator::init() {} void TestCalculator::cleanup() {} void TestCalculator::testAdd() { Calculator c; // 局部对象,用例之间不共享状态 QCOMPARE(c.add(2, 3), 5); QCOMPARE(c.add(-1, 1), 0); } QTEST_GUILESS_MAIN(TestCalculator) // 生成 main,无 GUI 依赖 #include "tst_calculator.moc" // 类定义写在 cpp 里,必须手动包含 moc 产物这段代码有三个容易被忽略的细节。第一,private slots不是可选项,QTest 靠元对象系统枚举槽函数来发现用例,写成普通private:成员函数,编译能过,运行时就是零个用例。第二,末尾的#include "tst_calculator.moc"对应的是“类定义写在 .cpp 文件里”这个事实,moc 会为它生成一个同名产物,漏掉就是undefined reference to vtable。第三,用例内部用的是局部对象而不是类成员,这是刻意的——init()和cleanup()更适合作业于成员状态,而每行断言依赖的对象越独立,失败定位越快。
把被测类抽成静态库是常见做法:
add_library(calc_core STATIC calculator.cpp calculator.h) target_link_libraries(calc_core PUBLIC Qt6::Core)理由不是“架构好看”,而是测试可执行文件和业务可执行文件共享同一份编译产物。如果测试直接把calculator.cpp再编一遍,一旦业务侧开了不同的编译宏(比如某个精度开关),测试跑的就是另一份代码,绿得再漂亮也不作数。
2.2 一份 CMake 接入脚本:AUTOMOC、add_test 与超时属性
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) # 关键:测试类的 Q_OBJECT 靠它生成 moc find_package(Qt6 REQUIRED COMPONENTS Core Test) add_library(calc_core STATIC calculator.cpp calculator.h) target_link_libraries(calc_core PUBLIC Qt6::Core) enable_testing() # 不开这个,add_test 不会生成 CTestTestfile add_executable(tst_calculator tst_calculator.cpp) target_link_libraries(tst_calculator PRIVATE calc_core Qt6::Test) add_test(NAME calculator_suite COMMAND tst_calculator) set_tests_properties(calculator_suite PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" # CI 无显示环境 TIMEOUT 60 # 卡死的用例不会拖垮整条流水线 FAIL_REGULAR_EXPRESSION "QWARN") # 可选:出现告警也算失败 )参数上值得说明的是三个属性。ENVIRONMENT里的QT_QPA_PLATFORM=offscreen在纯逻辑测试里其实用不上,但只要套件里有任何一个用例间接碰到了QWidget,没有它就会在无头环境里直接退出。TIMEOUT是后悔药,曾经有个用例因为等待一个永远不来的信号,把构建机挂到超时上限。FAIL_REGULAR_EXPRESSION是严格模式的开关,视项目风格决定要不要加,一旦加上,qWarning打出来的未处理分支也会变成红叉,对补测试的阶段很有用。
构建和第一次运行:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build -j cd build ctest --output-on-failure # 先看整体过没过 ./tst_calculator -v2 # 失败时再拉细节-v2会打印每个用例的进入与退出,以及断言失败的表达式两侧的值,定位“哪个用例的第几行”比看 ctest 的汇总高效得多。
2.3 qmake 老工程怎么平移:testlib 与 CONFIG += testcase
手上一堆.pro的项目不少,平移过去只要四行:
QT += testlib QT -= gui # 纯逻辑测试,去掉 gui 减少依赖 CONFIG += testcase console # testcase 让 make check 认识它 TARGET = tst_calculator SOURCES += tst_calculator.cpp calculator.cpp HEADERS += calculator.hCONFIG += testcase的效果等价于 CMake 的add_test,之后make check就能把用例跑起来。QT -= gui需要注意,如果原工程默认带QT += core gui,不去掉的话,测试二进制会多带一份界面栈,在无头机器上更容易出问题。
2.4 三个 main 宏的选型:QTEST_MAIN、QTEST_GUILESS_MAIN、QTEST_APPLESS_MAIN
这三个宏经常被随手挑一个,选错的表现是“本地跑得好,CI 上莫名退出”。区别如下:
QTEST_MAIN生成带QApplication的 main,套件里涉及QWidget、QWindow、事件循环的用例必须用它。QTEST_GUILESS_MAIN生成带QCoreApplication的 main,逻辑类、网络、文件、QSignalSpy全场景够用,是绝大多数纯逻辑测试的正确选择。QTEST_APPLESS_MAIN不创建任何Q*Application对象,只适合完全不碰事件循环的极简测试,一旦用了QTimer或QSignalSpy::wait(),等待会直接返回失败,因为根本没有事件循环在跑。
注意:不确定选哪个的时候,先上
QTEST_GUILESS_MAIN。它同时具备事件循环和轻依赖,只有确认要用控件时才升级到QTEST_MAIN。
3. 断言与数据驱动:QCOMPARE、_data() 与 QSignalSpy 的参数怎么设
骨架搭好之后,真正决定这套测试有没有价值的是断言写得对不对。见过太多“写了 200 个用例,一个 bug 都没抓到”的测试集,问题往往不是覆盖率,而是断言只验证了“不崩溃”。
3.1 QCOMPARE 与 QVERIFY 的分工:失败输出里能看到什么
两个宏的区别不在严格程度,而在失败时给你多少信息:
void TestCalculator::testDivide() { Calculator c; // 值比较:失败时会打印 actual 与 expected,以及在数据驱动时的行标签 QCOMPARE(c.divide(10, 2), 5); // 前置条件:只关心真假,失败只报表达式本身 QVERIFY(!c.isError()); // 带说明的版本:错误信息里追加自定义上下文,适合排查环境相关失败 QVERIFY2(c.divide(1, 0) == 0, "除零时应返回 0 并置错误标志"); // 浮点比较不要直接 QCOMPARE QVERIFY(qAbs(c.sqrt(2.0) - 1.41421356) < 1e-8); }一个实践上的分工是:能用QCOMPARE的地方不用QVERIFY。QCOMPARE会在失败信息里带上实际值和期望值,而QVERIFY只能告诉你“这个表达式是假”——当表达式里嵌了三层函数调用时,这句话等于没信息。QVERIFY2留给了那些“失败原因无法用值表达”的场景,比如前置条件没满足、外部资源没就绪。
浮点比较是一个经典的翻车点。QCOMPARE对float、double确实有模糊比较的语义,但它内部用的qFuzzyCompare在数值接近 0 时几乎等同于精确比较——比较1e-12和2e-12会直接判不相等。稳妥写法是显式给 epsilon,或者用qFuzzyIsNull()处理“应该为零”的断言。
3.2 数据驱动:addColumn / newRow 与 _data() 的命名约束
同一段逻辑要验十组参数,复制十遍用例是最常见的偷懒方式,后果是失败时根本分不清是哪组数据挂了。数据驱动测试就是为这个场景准备的:
void TestCalculator::testDivide_data() { QTest::addColumn<int>("lhs"); QTest::addColumn<int>("rhs"); QTest::addColumn<int>("expected"); QTest::addColumn<bool>("isError"); QTest::newRow("整数整除") << 10 << 2 << 5 << false; QTest::newRow("有余数截断") << 7 << 2 << 3 << false; QTest::newRow("负数除法") << -7 << 2 << -3 << false; QTest::newRow("除数为零") << 1 << 0 << 0 << true; } void TestCalculator::testDivide() { QFETCH(int, lhs); // 变量名必须与 addColumn 的列名一字不差 QFETCH(int, rhs); QFETCH(int, expected); QFETCH(bool, isError); Calculator c; QCOMPARE(c.divide(lhs, rhs), expected); QCOMPARE(c.isError(), isError); }约束有三条,全部都是编译期或者第一行断言就能暴露的:
_data()后缀是硬性的。框架靠槽函数名testXxx去找testXxx_data,写成testDivideData或者dataTestDivide,数据表根本不会被加载,用例仍会执行,但只跑一次、且QFETCH会直接失败。addColumn的类型名必须是注册过的元类型。基本类型、QString、QByteArray没问题;自定义结构体要先Q_DECLARE_METATYPE,否则运行时报 “Unknown type”。- 行名(
newRow的第一个参数)是失败报告里唯一能区分数据的标识,别写row1、row2。用例一多,报告里全是row7的时候你会想重来一遍。
提示:
QFETCH的类型写错不会被编译器拦住,而是在运行时抛断言。类型对不上时优先检查addColumn里的类型字符串,而不是怀疑数据本身。
3.3 信号与异步:QSignalSpy 的 isValid、wait 与 QTRY_COMPARE 的超时
只要被测代码里有信号、定时器、异步回调,赤裸裸的QCOMPARE就会变成玄学——本机过得去,构建机上十次挂三次。这不是概率问题,是时序问题。Demo 里给了一段标准写法:
void TestLoader::testFinishedSignal() { Loader loader; QSignalSpy spy(&loader, &Loader::finished); loader.start(":/data/sample.txt"); // 信号签名写错时 isValid() 立刻为 false,比等超时快得多 QVERIFY(spy.isValid()); // wait 会跑事件循环,最多等 3000ms,收到信号即返回 true QVERIFY(spy.wait(3000)); QCOMPARE(spy.count(), 1); const QList<QVariant> args = spy.takeFirst(); QCOMPARE(args.at(0).toInt(), 42); // 第一个参数:解析出的记录数 }参数上要留意三处。QSignalSpy的构造函数有两种形式,用函数指针版本(&Loader::finished)能在编译期校验信号存在,用字符串版本(SIGNAL(finished(int)))写错了只会静默失败,所以先判isValid()。wait()的毫秒数是等待上限而不是固定延时,超时后返回false,比QTest::qWait(3000)后再断言count()可靠得多——后者在慢机器上照样挂。
对于“状态最终会变成某个值”这类断言,轮询比监听更合适:
// 默认 5 秒超时、50ms 重试间隔;显式版本可以改超时 QTRY_COMPARE_WITH_TIMEOUT(worker.state(), Worker::Done, 8000); // 轮询某个表达式成立 QTRY_VERIFY_WITH_TIMEOUT(worker.processedCount() >= 100, 8000);QTRY_*系列在没有事件循环的QTEST_APPLESS_MAIN下不会推进,这是它最常见的失效原因,遇到“永远等不到”的情况先确认主宏选对了没有。
4. 避坑与排查:五个让 QTest 假通过的翻车现场
测试比不写更可怕的状态是“假通过”——它给了你错误的信心。下面五条都是在这套 Demo 的复现过程中真实撞到的,按现象、原因、解决拆开。
4.1 五种典型故障的定位路径
第一种:跑出来Totals: 0 passed, 0 failed, 0 skipped。
现象是二进制能编出来、能执行,但一个用例都不跑,退出码还是 0,CI 上是绿的。原因是测试函数没声明在private slots区域,写成了普通成员函数,元对象系统枚举不到。解决办法是把private slots:这一行补上,并且确保类里有Q_OBJECT宏。排查时可以先用./tst_calculator -functions列出被识别的用例名,输出为空就说明问题在元对象层面,不在断言。
第二种:链接期报undefined reference to vtable for TestCalculator。
现象是编译通过、链接失败,报错指向测试类的虚表。原因是类定义写在了.cpp文件里,Q_OBJECT展开的代码需要 moc 生成,而 moc 的产物要被显式包含进同一个翻译单元。解决办法是在.cpp末尾加#include "tst_calculator.moc";如果文件叫tst_calculator.cpp,这里的名字就必须完全一致。另一种情况是 CMake 里漏了set(CMAKE_AUTOMOC ON),此时 moc 压根没被调用。
第三种:浮点用例间歇性失败,重跑又过。
现象是同一条断言在本地十次过一次,在构建机上全挂。原因是用QCOMPARE比较double时,qFuzzyCompare对接近零的值退化成精确比较,而不同平台的浮点累加顺序不同,末位差异被放大。解决办法是换成显式 epsilon 断言QVERIFY(qAbs(a - b) < 1e-8),或者在业务侧把“应当为零”的情况单独判qFuzzyIsNull(),不要让一个断言同时承担两种语义。
第四种:数据驱动用例报Unknown type或数据行数为 0。
现象是testXxx_data()存在、addColumn也写了,但执行时报找不到列或者一行数据都没有。原因通常是两处:_data()没放进private slots,或者addColumn用了未注册的自定义类型。解决办法是先确认函数签名,再对自定义类型补Q_DECLARE_METATYPE(MyStruct);如果类型里带QObject派生成员,改用指针或者干脆拆成多列基本类型,别在数据表里塞对象。
第五种:CI 上跑 GUI 用例直接卡死到超时。
现象是本地一切正常,流水线上直到TIMEOUT才被杀掉,日志里只有Could not connect to display或者一行空白。原因是运行环境没有可用的平台插件。解决办法是给测试设QT_QPA_PLATFORM=offscreen;如果这个用例本身根本不需要控件,更彻底的做法是把测试类改成QTEST_GUILESS_MAIN,从根上砍掉界面依赖。
注意:上面五种里有四种的退出码都是 0。判断“测试有没有真的跑起来”,不要只看 CI 绿不绿,要核对报告里的用例总数。
4.2 命令行排查手册:-functions、-v2 与 -o 的组合用法
QTest 生成的可执行文件本身就是个命令行工具,排错时用对了参数能省掉大量加日志的功夫:
| 参数 | 用途 | 典型场景 |
|---|---|---|
-functions | 只列出被识别的用例名,不执行 | 怀疑用例没被发现 |
-v2 | 输出每个用例的进入/退出与断言详情 | 定位具体失败断言 |
-o out.xml,junitxml | 输出 JUnit 格式报告 | 接 CI 的测试报告面板 |
-skip name | 跳过指定用例(支持通配) | 排查是否某个用例污染了后续 |
-repeat n | 重复执行整个套件 n 次 | 抓间歇性失败 |
-maxwarnings n | 出现 n 条告警即判失败 | 收紧qWarning |
组合用法上,抓间歇性问题一般是这么走:
# 只看用例清单,确认规模 ./tst_calculator -functions # 重复 50 次,输出到日志,事后翻失败那几次 ./tst_calculator -repeat 50 -v2 > /tmp/qtest_repeat.log 2>&1 # 一次性定位:跳过首个可疑用例,看后续是否还挂 ./tst_calculator -skip testLoaderAsync -v2 # 生成 JUnit 报告自检一次,确认 CI 能读到 ./tst_calculator -o ./report.xml,junitxml-repeat是这个工具里最有性价比的参数。间歇性失败往往是状态残留或者时序竞争,靠读代码猜十次不如重复跑五十次,日志里失败的那几轮通常就指向同一行。-skip则用来验证用例之间的污染关系——如果跳过 A 之后 B 就不挂了,问题就在 A 的cleanup()没做干净,而不是 B 本身。
5. 进 CI 前的最后一步:JUnit XML 输出与一次强制自检
测试在本地绿了,只说明这台机器上绿了。落到流水线上还要过两道关:结果格式和依赖环境。Demo 里已经把-o report.xml,junitxml这条命令留好了位置,接进脚本只有几行:
#!/usr/bin/env bash set -euo pipefail cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build -j"$(nproc)" export QT_QPA_PLATFORM=offscreen # 用 ctest 跑,失败时保留输出;同时让测试二进制自己产一份 JUnit 报告 ctest --test-dir build --output-on-failure || EXIT_CODE=$? ./build/tst_calculator -o ./build/report.xml,junitxml || true exit "${EXIT_CODE:-0}"set -euo pipefail配上|| EXIT_CODE=$?的组合是刻意的:ctest失败时要继续往下走,把报告产出来再退出,否则流水线上只能看到一句“测试失败”,看不到是哪条断言。|| true挂在报告生成那行也是同理——报告生成失败不该掩盖真正的测试失败原因。
有几个验证点是我每次接新项目都会走一遍的。第一,故意改坏一行被测逻辑,确认流水线真的变红,而不是“跑了但没断言”。第二,把-repeat 20临时挂上去,看有没有间歇性失败,这类问题在单次运行里基本藏得住。第三,打开生成的report.xml看一眼tests和failures两个属性,如果tests是 0,说明测试进程压根没被发现,前面所有的绿都没有意义。
| 检查项 | 通过标准 | 不通过时的方向 |
|---|---|---|
| 用例总数 | 与预期用例数一致 | 检查private slots与 moc |
| 执行耗时 | 单套件小于 CI 的TIMEOUT | 找qWait与固定延时 |
| 重复稳定性 | -repeat 20零失败 | 查cleanup()的状态清理 |
| 报告可读 | report.xml含用例名 | 检查-o的格式参数 |
从那以后我每次给新工程接 QTest,都会先干一件看起来很多余的事:把QCOMPARE故意改成错误的期望值,跑一遍,看着它红一次。没亲眼见过它失败,就不算这套测试真的接通了。上面这些参数和踩坑点,你按顺序走一遍,基本能绕开我当初绕了半天的弯路,希望帮到你。
本文还有配套的精品资源,点击获取