首页 / 资讯中心 / 文章详情

QTest单元测试实战:骨架、数据驱动、信号与CI门禁

QTest单元测试实战:骨架、数据驱动、信号与CI门禁 ★ FEATURED ARTICLE
简介这份Demo面向具备一定C基础的Qt开发者聚焦单元测试在实际项目中的落地重点解决普通应用程序类方法与DLL内部非导出类方法难以独立验证的问题。内容围绕QTest展开覆盖测试用例组织、初始化与清理、数据驱动测试、断言写法以及通过友元测试类或专用测试导出接口访问DLL内部实现的两种思路并展示如何用qmake构建和运行测试。压缩包共13个文件包含5个cpp源文件、4个h头文件和4个pro工程文件约6KB源码与头文件分别对应普通应用、单元测试和DLL示例工程文件便于按模块编译调试。已有1467人学习下载适合希望快速搭建QTest测试骨架、对照理解测试分层与DLL测试策略的读者参考复用也可作为团队补充单元测试的入门范例并配有清晰的工程拆分便于按需裁剪到现有Qt项目。1. QTest 不是跑一下就行一个数值模块的测试从红到绿一个数值计算模块手工在界面上点了三个月都没出事加了一个除零边界之后全线崩——这类问题在 QTest 单元测试面前基本藏不住。这份 Demo 就是一个可编译、可跑通、可直接抄结构的 Qt 单元测试样例工程用 QTest 起一个测试类把被测逻辑拆成独立源文件配 qmake 和 CMake 两套构建脚本跑出断言、数据驱动、信号捕获、界面模拟点击四类用例。它适合已经写过 Qt 业务代码、但没系统搭过单元测试的人也适合想把测试接进流水线的熟手——照着改类名和被测对象半小时能出第一份报告。2. 测试类骨架QObject、private slots 与四个生命周期钩子QTest 的测试类就是一个普通的 QObject 子类。这点和那些注册制的测试框架差别很大后者靠宏把自由函数登记进全局表而 Qt Test 走的是自己的元对象系统——moc 扫描到你声明在private slots区里的每一个槽函数就把它登记成一条独立用例运行时按声明顺序逐条调用。理解这条机制后面所有为什么我的测试函数没跑为什么链接报 vtable 错误都能自己推出来不用去翻别人的踩坑帖。2.1 最小可编译工程的 CMake 与 qmake 两套写法先摆目录。一个能被你直接抄走的 Demo 结构大概长这样被测逻辑单独一个calculator.h/calculator.cpp测试类一个tst_calculator.cpp外加一份构建脚本。被测逻辑必须是独立编译单元这是整个工程唯一的硬性架构要求——不能把业务代码内联进测试文件否则覆盖率统计会失真后面讲报告门禁时会再提一次。CMake 写法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 靠它展开 find_package(Qt6 REQUIRED COMPONENTS Core Test) enable_testing() # 打开 ctest 入口 add_executable(tst_calculator tst_calculator.cpp calculator.cpp calculator.h ) target_include_directories(tst_calculator PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(tst_calculator PRIVATE Qt6::Core Qt6::Test) add_test(NAME tst_calculator COMMAND tst_calculator)几个参数值得单独说。CMAKE_AUTOMOC ON是这条链路的命门Q_OBJECT宏展开后依赖 moc 生成元对象代码关掉它编译器会直接报 vtable 相关的未定义引用。find_package里的Test组件对应Qt6::Test目标Qt5 环境下换成Qt5::Test其余写法一致。add_test只是把可执行文件登记给 ctest真正的断言执行还是 QTest 自己做两者职责不重叠——ctest 负责什么时候跑、跑哪个QTest 负责跑完之后红还是绿。qmake 写法QT testlib core QT - gui # 纯逻辑测试不需要 widgets CONFIG console c17 CONFIG - app_bundle TARGET tst_calculator TEMPLATE app SOURCES tst_calculator.cpp calculator.cpp HEADERS calculator.hQT testlib是必须的漏了会在#include QtTest处报找不到头文件。QT - gui是可选优化去掉 widgets 依赖后启动更快、CI 镜像更小但如果被测代码里出现了 QWidget 就必须加回来。CONFIG - app_bundle只对 macOS 有意义避免生成.app目录导致脚本里的相对路径多出一层。2.2 initTestCase / init / cleanup / cleanupTestCase 的边界这四个函数名是固定契约函数体由你写签名必须是void xxx()而且必须放在 private slots 区。函数调用时机典型用途initTestCase()所有用例之前仅一次建数据库连接、读配置文件、注册自定义元类型cleanupTestCase()所有用例之后仅一次关连接、删临时目录、释放全局单例init()每条用例之前重置被测对象到初始状态cleanup()每条用例之后清理本条用例产生的临时文件血泪经验集中在init()和cleanup()上。很多人把被测对象写成测试类的成员变量在构造函数里初始化一次就完事结果第一条用例改了状态第二条用例读到脏数据报错位置和真实原因差了十万八千里。正确做法是把被测对象放进init()里创建、cleanup()里销毁或者干脆在每条用例内部用栈对象——后者的隔离性最好代价只是多几次构造开销。initTestCase()失败会直接中止整个测试后面的用例一条都不跑而init()失败只会跳过当前这一条继续下一条。这个差异在排查时非常有用如果日志里只看到 initTestCase 的失败信息就结束了基本可以锁定是环境问题缺文件、缺环境变量、缺注册类型而不是业务逻辑写错了。2.3 QTEST_MAIN 三个变体GUI、无界面、纯逻辑测试文件末尾必须放一个宏来生成 main 函数QTest 提供了三个// 纯逻辑测试不创建任何应用对象启动最快 QTEST_APPLESS_MAIN(TestCalculator) // 需要事件循环、信号槽但没有界面 QTEST_GUILESS_MAIN(TestCalculator) // 需要 QApplication测 QWidget QTEST_MAIN(TestCalculator)选错的表现很典型。用QTEST_APPLESS_MAIN去测信号槽QSignalSpy 永远是 0因为整个进程里没有事件循环信号发出后没有分发过程这回事。反过来纯数值计算硬套QTEST_MAIN会额外拉起一个 QApplication在无显示环境的容器里直接崩在启动阶段日志里只有一句连接显示服务失败看起来像测试代码的问题其实是启动方式选错了。我的默认选择是被测代码里只要出现 QObject 派生类的信号槽就用 GUILESS只有真的要做鼠标键盘模拟才用 MAIN。这条规则能省掉八成环境问题。还有一个细节容易漏。如果测试类写在 .cpp 文件里而不是头文件里QTEST_MAIN之前必须加一行包含#include calculator.h #include QtTest class TestCalculator : public QObject { Q_OBJECT private slots: void initTestCase(); void cleanupTestCase(); void divide_normal(); void divide_byZero_data(); void divide_byZero(); }; // ... 各个函数的实现 ... QTEST_MAIN(TestCalculator) #include tst_calculator.moc // 文件名必须与 .cpp 同名由 moc 生成这行的作用是把 moc 为当前 .cpp 生成的那份元对象代码包含进来参与编译。qmake 和 CMake 的 AUTOMOC 都会在构建目录生成这个文件但编译器不知道去哪找得手动 include。漏了它的报错是undefined reference to TestCalculator::staticMetaObject或者一串 vtable 相关的链接错误第一次见很容易怀疑人生。3. 断言与数据驱动把 3 条用例扩成 30 条断言宏不多常用的就五六个QVERIFY、QCOMPARE、QVERIFY2、QTRY_VERIFY、QTRY_COMPARE再加上QEXPECT_FAIL。数量少是好事少到你可以把每个的脾气都摸清楚不用在几十个断言宏里做选择困难。3.1 QCOMPARE 与 QVERIFY 的分工以及浮点数的坑最关键的差异是失败时的信息量。QCOMPARE会打印实际值和期望值的对比QVERIFY只打印布尔表达式本身的文本。同样是失败前者告诉你拿到 4想要 5后者只告诉你这个表达式是 false还得回头翻代码。void TestCalculator::divide_normal() { Calculator c; // 值比较一律用 QCOMPARE失败时能看到两边的具体数值 QCOMPARE(c.divide(10, 2), 5); // 只关心真假时用 QVERIFY但尽量控制数量 QVERIFY(c.divide(9, 3) 0); // 需要附加上下文时用 QVERIFY2第二个参数会打进报告 QVERIFY2(c.divide(9, 3) 0, 整除结果应为正数); }浮点数是个真坑。QCOMPARE对 double 内部走qFuzzyCompare而qFuzzyCompare在任一操作数接近 0 时是失效的——它的实现是拿两个数做相对误差比较期望值为 0.0 时这个比较恒不成立。// 翻车写法期望 0 时 QCOMPARE 会无故变红 QCOMPARE(c.divide(0, 5), 0.0); // 报错Actual 0, Expected 0 // 正确写法一用 qFuzzyIsNull 判零 QVERIFY(qFuzzyIsNull(c.divide(0, 5))); // 正确写法二自定容差适合结果非零的场景 const double eps 1e-9; QVERIFY(std::abs(c.divide(1.0, 3.0) - 0.3333333333) eps);这个坑的迷惑性在于报告里打印出来的实际值和期望值看起来一模一样都是 0但断言就是红的。第一次碰到会怀疑编译器或者框架有 bug实际上是自己用错了工具。另一个隐性行为是短路QVERIFY和QCOMPARE失败后会立即从当前测试函数返回后面的断言不再执行。所以一条用例里如果第一个断言就挂了后面的日志一概没有别以为是代码没跑到。3.2 _data() 与 QFETCH表格驱动的固定套路数据驱动是 QTest 里最值得抄的部分。约定很朴素有一个叫xxx()的测试函数就配一个叫xxx_data()的函数框架自动先调_data准备好数据表再拿每一行数据调一次xxx。void TestCalculator::divide_byZero_data() { QTest::addColumnint(a); // 被除数 QTest::addColumnint(b); // 除数 QTest::addColumnbool(isValid); // 期望这次调用是否成功 QTest::newRow(正常整除) 10 2 true; QTest::newRow(除不尽) 7 2 true; QTest::newRow(除零) 1 0 false; QTest::newRow(负数除零) -1 0 false; } void TestCalculator::divide_byZero() { QFETCH(int, a); QFETCH(int, b); QFETCH(bool, isValid); Calculator c; const auto result c.safeDivide(a, b); QCOMPARE(result.hasValue(), isValid); }参数说明addColumn类型(列名)声明一列newRow(标签)起一行后面的按列顺序依次填值。列名在QFETCH时必须一字不差否则运行时直接 abort报 Unknown field。三条硬性约束值得记牢_data的返回值必须是 void 且不接受参数QFETCH只能写在测试函数体开头它本质是声明一个同名局部变量写到中间会编译不过每行的列数必须和 addColumn 的个数一致多了少了都是运行时才炸编译期没有任何保护。提示newRow的标签会原样出现在测试报告里写成中文或者业务场景描述排查时比 row1、row2 有用得多。数据驱动的价值不只是少写重复代码。当你要补一个边界值时改一行数据就多一条用例回归成本几乎为零——这是说服团队愿意持续写测试的关键比任何规范文档都管用。3.3 失败信息怎么读-v2 与 QCOMPARE 的调试输出默认输出很精简只告诉你哪条 FAIL 了。加-v2能看到每条用例的开始结束、执行耗时、以及每个断言的展开信息。# 列出所有测试函数不执行 ./tst_calculator -functions # 列出某个数据驱动用例的全部数据标签 ./tst_calculator -datatags # 详细模式带每条用例的起止和耗时 ./tst_calculator -v2 # 只跑数据表里的某一行 ./tst_calculator divide_byZero:除零一条失败记录的典型长相FAIL! : TestCalculator::divide_byZero(除零) Actual (result.hasValue()) is false, expected true Loc: [tst_calculator.cpp(41)]定位思路很直接括号里的除零是数据行标签直接告诉你哪一行挂了Loc是断言所在行号。如果要看的不是断言本身而是它的上游状态就在断言前加qDebug()QTest 会把标准输出一并捕获进报告不用单独去翻终端。已知缺陷用QEXPECT_FAIL标记void TestCalculator::knownIssue() { // 第二个参数是数据行标签数据驱动时必须精确匹配行名 // 第三个参数 Abort 表示该断言失败后中止本条用例Continue 则继续往下跑 QEXPECT_FAIL(除零, safeDivide 对除零返回默认值待修, Abort); QCOMPARE(Calculator().safeDivide(1, 0).hasValue(), false); }标记成QEXPECT_FAIL的用例失败时报告显示 XFAIL不计入测试失败但如果它意外通过了会显示 XPASS 并计为失败。这个设计是刻意的——强制你定期回来清理已知缺陷标记别让它变成永久豁免。4. 信号、异步与 GUIQSignalSpy 与模拟输入的实测路径测信号槽是 Qt 项目绕不开的一环也是最容易写出看起来绿了其实什么都没测的地方。核心工具就一个QSignalSpy但它对事件循环、元类型注册、平台插件三件事都有依赖链条上任何一环断了都是静默失败。4.1 QSignalSpy 捕获信号与参数校验class Counter : public QObject { Q_OBJECT signals: void valueChanged(int newValue); public slots: void setValue(int v) { if (v ! m_v) { m_v v; emit valueChanged(v); } } private: int m_v 0; }; void TestCounter::emit_once() { Counter c; QSignalSpy spy(c, Counter::valueChanged); // Qt5 起支持函数指针语法 c.setValue(5); QCOMPARE(spy.count(), 1); const QListQVariant args spy.takeFirst(); QCOMPARE(args.at(0).toInt(), 5); }参数说明spy 构造时绑定对象和信号之后每次发射都会往内部列表追加一条记录。count()是发射次数at(i)只读取第 i 条takeFirst()读取并删除第一条。建议用takeFirst因为同一个 spy 连续断言多条信号时读取位置很容易搞混。自定义类型是主要翻车点。信号参数如果不是 Qt 内建类型QSignalSpy 内部要用 QVariant 承载必须先注册struct Sample { int id; }; Q_DECLARE_METATYPE(Sample) // 放在 initTestCase() 里全局注册一次 qRegisterMetaTypeSample(Sample);漏了注册的表现是运行时报QSignalSpy: Unable to handle parameter sample of type Sample然后直接 abort。这个错误在本地跑的时候很显眼但塞进 CI 日志里经常被前后几百行输出淹没。4.2 模拟输入的正确姿势界面测试的价值不在于能点就行而在于验证交互链路。下面这段测的是输入框内容变化能不能正确传导到标签上void TestWidget::typing_updates_label() { QLineEdit edit; QLabel label; QObject::connect(edit, QLineEdit::textChanged, label, QLabel::setText); edit.show(); // 窗口没暴露出来就发按键事件会丢必须等 QVERIFY(QTest::qWaitForWindowExposed(edit)); QTest::keyClicks(edit, hello); QCOMPARE(label.text(), QString(hello)); }关键点控件必须先show()再等窗口暴露qWaitForWindowExposed返回 false 说明窗口压根没起来用它做QVERIFY能提前拦住后续一堆莫名其妙的失败。keyClicks是逐字符输入keyClick是单次按键组合键用QTest::keyClick(w, Qt::Key_A, Qt::ControlModifier)。鼠标同理QPushButton btn(OK); QSignalSpy clicked(btn, QPushButton::clicked); btn.show(); QVERIFY(QTest::qWaitForWindowExposed(btn)); QTest::mouseClick(btn, Qt::LeftButton); // 默认点控件中心 QCOMPARE(clicked.count(), 1);如果需要测拖拽或者按下不松开就用mousePress/mouseRelease分开调用中间插一次qWait。4.3 offscreen 平台插件与无头容器里的运行参数界面测试进容器最容易撞的一条报错是qt.qpa.xcb: could not connect to display。解决办法是切到 offscreen 平台插件它是 Qt 内置的不需要额外安装任何显示服务# 手动跑一次 QT_QPA_PLATFORMoffscreen ./tst_widget # 在 ctest 里给单个测试设环境变量 set_tests_properties(tst_widget PROPERTIES ENVIRONMENT QT_QPA_PLATFORMoffscreen)顺带说清qWait和QTRY_*的区别。qWait(ms)是阻塞式地跑一段事件循环睡够时间就返回不保证条件成立QTRY_VERIFY和QTRY_COMPARE内部按固定间隔轮询条件成立立刻返回默认 5 秒后超时。等待异步结果一律用后者// 超时时长可以覆盖这里给到 10 秒 QTRY_COMPARE_WITH_TIMEOUT(spy.count(), 2, 10000);用qWait硬等的写法在本地很稳进了负载高的 CI 机器就开始随机红属于典型的玄学失败来源。5. 排查记录五个让 QTest 红变绿的定位过程这一节按现象 → 原因 → 解决的格式记五条实际撞过的坑。前两条在构建期中间两条在运行期最后一条在持续集成环节。5.1 构建期的两类报错坑一链接报 vtable 相关错误现象编译单个 .cpp 时没问题链接阶段报undefined reference to vtable for TestCalculator或undefined reference to TestCalculator::staticMetaObject。原因两种情况。一是构建系统里没开 AUTOMOCCMake 缺CMAKE_AUTOMOC ONqmake 缺QT testlib带来的 moc 支持元对象代码压根没生成。二是测试类定义在 .cpp 里但文件末尾漏了#include tst_calculator.moc代码生成了却没参与编译。解决先看构建目录里有没有tst_calculator.moc有就是漏 include没有就是 AUTOMOC 没开。这行 include 必须放在文件最末尾而且文件名要和 .cpp 完全同名。坑二测试跑完显示 Totals: 0 passed现象编译链接都正常运行输出Totals: 0 passed, 0 failed, 0 skipped一条用例都没执行进程还正常退出。原因测试函数没写在private slots:区里。QTest 靠 moc 扫描槽函数来登记用例写在public或者普通private下的函数在元对象里根本不存在框架自然看不见。少数情况是类里漏了Q_OBJECT宏整个元对象系统都没启用。解决把所有测试函数挪进private slots:。如果测试是从别的框架迁移过来的注意 Qt Test 不认public slots以外的注册方式别指望用宏包装一层。5.2 运行期的两类静默失败坑三QSignalSpy 收不到信号现象spy.count()恒为 0或者spy.at(0).at(0)取出来是个无效 QVarianttoInt()得到 0。原因三个可能。一是用了QTEST_APPLESS_MAIN进程里没有事件循环信号发出后没有分发过程。二是信号参数是自定义类型但没调qRegisterMetaTypespy 无法承载。三是信号是跨线程的默认队列连接下信号还没投递到目标线程断言就跑完了。解决先换成QTEST_GUILESS_MAIN验证第一条再在initTestCase()里补注册跨线程场景改用QTRY_COMPARE(spy.count(), 1)把等待交给轮询机制别用qWait硬等。坑四数据驱动用例运行时直接崩现象进程 abort日志里只有一句QFETCH: Unknown field a或者干脆段错误没有输出。原因QFETCH的列名和addColumn的列名不一致大小写、空格、拼写都算或者某一行的个数和列数对不上导致后面的数据错位。解决addColumn和QFETCH建议直接从同一个常量字符串复制别手打。行数列数对不上这种错误没有编译期保护写完数据表先跑一遍-datatags看看框架认出来的表结构对不对。另外QFETCH必须写在函数最开头中间插一行qDebug都可能影响变量声明顺序。5.3 持续集成与覆盖率上的哑火点坑五界面测试在容器里秒崩覆盖率始终为 0现象本地全绿进了 CI 容器第一条用例就崩报could not connect to display另一台机器上测试能跑但 lcov 生成的报告里业务代码一行都没覆盖到。原因前者是没设 offscreen 平台插件容器里没有显示服务。后者更隐蔽——编译选项--coverage只加在了测试可执行目标上被测的源文件是通过静态库链接进来的库本身没带插桩所以报告里只有测试文件的行。解决界面测试统一在测试属性里注入QT_QPA_PLATFORMoffscreen。覆盖率则要求插桩选项同时作用于被测目标和测试目标# 对被测库和测试可执行文件都要加缺一不可 target_compile_options(calc_lib PRIVATE --coverage -O0 -g) target_link_options(calc_lib PRIVATE --coverage) target_compile_options(tst_calculator PRIVATE --coverage -O0 -g) target_link_options(tst_calculator PRIVATE --coverage)-O0也别省。开优化后内联会把多行代码合并成一个行号lcov 统计出来会莫名其妙地少一截看起来像测试没覆盖到实际上是编译器把行号抹平了。6. 报告与门禁把 QTest 的输出接进流水线跑通只是第一步能不能让流水线读懂结果才是分水岭。QTest 的报告输出靠-o控制格式和文件可以同时指定多份# 一份给人看一份给 CI 解析 ./tst_calculator -o result.xml,junitxml -o -,txt # 限制警告数量超过就判定失败 ./tst_calculator -maxwarnings 0 # 对不稳定用例重复跑三次任意一次通过即算过 ./tst_calculator -repeat 3 divide_byZero格式可选txt、xml、junitxml、tap、csv其中junitxml是绝大多数流水线能直接吞下的格式单条用例的耗时和失败信息都在里面。-o -,txt里的短横线表示输出到标准输出方便容器里直接看日志。超时是另一个必设项。QTest 对单条测试函数有内置超时保护默认值偏长容器里一条卡死的用例能把整条流水线拖到人工介入。用环境变量压下来export QTEST_FUNCTION_TIMEOUT30000 # 单位毫秒单条用例 30 秒封顶我一般在 CI 脚本里固定成本地默认值的一半宁可让它红也别让它挂着。最后说个验证习惯。新写的测试用例我会强制做一次反向验证把被测函数的实现故意改错一行确认对应用例确实变红再改回来。这一步听起来多余但真能抓出那种断言写得太松、永远为真的假绿用例——尤其是QVERIFY(xxx ! nullptr)这种指针非空和逻辑正确完全是两码事。从那以后我每次提交新的测试文件都会挑一条用例走一遍这个流程图个心安。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站