简介:围绕Qt QTest框架的单元测试示例包,面向已掌握C++与Qt基础、希望为普通应用程序及动态库代码建立自动化验证的开发者。示例以可运行工程组织测试类与测试函数,覆盖初始化与清理、数据驱动用例、QVERIFY系列断言、断言失败提示、事件模拟等常用能力,以及测试可执行文件的构建与运行流程;针对DLL内部非导出类,则演示友元测试类和仅测试用导出接口两种访问策略,便于读者权衡封装性与可测性。压缩包为zip格式,共13个文件,约6KB,由5个cpp源文件、4个h头文件和4个pro工程文件组成,分别承载示例实现、接口声明与qmake构建配置,体量轻巧,适合导入Qt环境后按普通应用、单元测试、DLL三个模块拆分阅读。已有1467人学习,可用作QTest入门练手、课程示例或既有项目的测试改造参考。
1. 从一次偶发崩溃说起:QTest 这套单元测试 Demo 能挡住什么
接手一个跨平台工具模块时,最怕的不是编译不过,而是那种一周冒一次、只在某台机器上蹦出来的偶发崩溃。手点界面基本抓不住,血泪经验只有一条:得有能在本地几秒钟跑完的单元测试。这份 QTest Demo 就是围绕 Qt 自带的 QtTest 模块搭的最小可运行骨架——一个被测类、一个测试类、CMake 与 qmake 双构建、外加数据驱动和异步信号等待的示例。它不追求覆盖率数字好看,而是把「用例怎么声明、参数怎么表格化、异步信号怎么等、CI 上怎么跑」这几件事落成能照抄的结构。适合写过 Qt 代码但没系统用过 QTest 的人,也适合老项目想补测试却不知从哪下手的人。
2. QTest 的骨架:一个测试类从声明到被 make check 认出来
QTest 的学习曲线怪在入口:它的“魔法”不在 API,而在元对象系统。理解不了Q_OBJECT、private slots、moc这三者的关系,代码写得再对也会出现“用例数 0 passed”这种玄学现象。这一章先把骨架立住,再谈构建接入。
2.1 为什么选 QTest 而不是自己写断言宏
很多人第一反应是写个assert_eq(a, b)宏加main函数,够用且零依赖。这套方案在纯算法模块上确实能跑,但一旦被测代码带QObject、信号槽、定时器,自造轮子就开始漏风:你得自己处理事件循环,自己实现超时等待,自己设计失败信息的输出格式,还要自己解决“一个用例崩了怎么不影响后面用例”的隔离问题。
QTest 在这几件事上是现成的。QSignalSpy负责捕获信号并记录次数与参数,QTRY_COMPARE负责在事件循环里反复重试直到超时,崩溃用例由框架单独隔离,失败输出会打印实际值与期望值以及所在文件行号。更实际的一点是它跟构建系统绑得很紧:qmake 下加一行CONFIG += testcase就能生成make check目标,CMake 下add_test一挂就能被 CTest 统一调度。
代价是它绑定 Qt 生态。纯 STL 项目引入 QtTest 只为测试,体积上不划算,这种情况我更倾向 Catch2 或 GoogleTest。但只要项目本身已经链了 QtCore,QTest 就是边际成本最低的选择——不用多引一个第三方依赖,不用额外的包管理配置,测试代码里还能直接用QString、QList、QVariant做断言对象。选型判断就这一条:被测代码已经依赖 Qt,就上 QTest;没依赖,别为了测试硬拉 Qt 进来。
2.2 最小测试工程的三段结构与 QTEST_MAIN 的作用
一个能跑的 QTest 工程只需要三段:被测代码、测试类、一个把测试类跑起来的main。第三段由宏代劳。
先看被测代码,这里刻意放了两个函数,一个是纯计算,一个会抛异常,方便后面演示两类断言:
// calc.h #pragma once #include <stdexcept> class Calc { public: int add(int a, int b) const; // 除数为 0 时抛异常,用于演示 QVERIFY_THROWS_EXCEPTION double divide(double a, double b) const; };// calc.cpp #include "calc.h" int Calc::add(int a, int b) const { return a + b; } double Calc::divide(double a, double b) const { if (b == 0.0) { throw std::invalid_argument("divide by zero"); } return a / b; }被测代码本身没有任何测试痕迹,这一点很重要:不要为了好测而在生产类里塞friend class TestXxx,那会让测试和被测算计绑死,重构时互相拖累。
测试类必须继承QObject并带Q_OBJECT宏,用例写成private slots成员函数。框架靠 moc 生成的元信息枚举槽函数,槽名就是用例名:
// tst_calc.cpp #include <QtTest> #include "calc.h" class TestCalc : public QObject { Q_OBJECT private slots: // 整个测试类执行前后各跑一次,适合建库、起服务 void initTestCase(); void cleanupTestCase(); // 每个用例执行前后各跑一次,适合清状态 void init(); void cleanup(); void testAdd_data(); // 提供参数表,本身不是用例 void testAdd(); // 真正的用例,靠 QFETCH 取参数 void testDivideByZero(); }; void TestCalc::initTestCase() { qInfo() << "suite start"; } void TestCalc::cleanupTestCase() { qInfo() << "suite end"; } void TestCalc::init() {} void TestCalc::cleanup() {} void TestCalc::testDivideByZero() { Calc c; // 断言抛出的异常类型,第二个参数是异常类型 QVERIFY_THROWS_EXCEPTION(std::invalid_argument, c.divide(1.0, 0.0)); } // QTEST_MAIN 展开出一个 main:创建应用对象、调用 QTest::qExec、返回结果码 QTEST_MAIN(TestCalc) #include "tst_calc.moc" // 单文件测试时必须手动包含 moc 产物QTEST_MAIN展开后会创建QApplication(当工程链了 widgets 时)或QCoreApplication,然后调用QTest::qExec(&tc, argc, argv),把返回值当进程退出码。这意味着 CI 里判断成败只看退出码是不是 0,不需要解析日志。
最后那行#include "tst_calc.moc"是最容易漏的一句。moc 处理带Q_OBJECT的类时,如果类定义在.cpp里而不是头文件里,AUTOMOC 会生成一个同名 moc 文件,必须手动包含,否则链接期报undefined reference to vtable for TestCalc。另一种做法是把测试类声明搬到tst_calc.h,让构建系统按常规头文件处理,团队里两种风格都有人用,我更倾向单文件加 moc 包含,因为一个测试文件就是一个完整单元,挪动方便。
还有一个变体:QTEST_GUILESS_MAIN用QCoreApplication起进程,适用于纯逻辑测试。它比QTEST_MAIN快一点点,但如果被测代码里任何一个地方创建了QWidget,进程会直接 abort。除非我确认整个测试二进制不碰 GUI,否则默认用QTEST_MAIN。
2.3 qmake 与 CMake 两套构建接入
老工程多半是 qmake,新工程基本是 CMake,两套都得会写,不然换个仓库就卡住。
qmake 版本的关键三行是QT += testlib、CONFIG += testcase、CONFIG += console:
# tst_calc.pro QT += core testlib QT -= gui # 纯逻辑测试去掉 gui,启动更快 CONFIG += c++17 console testcase CONFIG -= app_bundle # macOS 下别打成 .app,否则 ctest 找不到可执行文件 TEMPLATE = app TARGET = tst_calc SOURCES += tst_calc.cpp calc.cpp HEADERS += calc.hCONFIG += testcase会让 qmake 生成一个check目标,直接make check就跑测试,退出码透传给 make。CONFIG -= app_bundle这条是 macOS 上的血泪经验:默认会产出.app目录,CTest 按路径找二进制时找不到,报“Unable to find executable”。
CMake 版本稍长,核心是把被测代码编成静态库再链接,避免测试工程重复编译源文件:
# CMakeLists.txt cmake_minimum_required(VERSION 3.16) project(calc_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 测试类的 Q_OBJECT 靠它处理 # Qt6 写法;Qt5 换成 find_package(Qt5 COMPONENTS Core Test REQUIRED) find_package(Qt6 REQUIRED COMPONENTS Core Test) # 被测代码单独成库,测试只链接,不重复编译 add_library(calc STATIC calc.cpp calc.h) add_executable(tst_calc tst_calc.cpp) target_link_libraries(tst_calc PRIVATE calc Qt6::Test) # Qt5 下是 Qt5::Test enable_testing() add_test(NAME tst_calc COMMAND tst_calc)三个参数值得单独解释。CMAKE_AUTOMOC ON必须开,否则Q_OBJECT不会被处理,前面说的 vtable 未定义错误就是这么来的。add_library(... STATIC ...)把被测代码独立出来,好处是后续加第二个测试二进制时不用重复编译,也方便主程序和测试共享同一份实现。target_link_libraries里Qt6::Test是导入目标,会自动带上 include 路径和编译定义,不要手写-lQt6Test。
跑起来就三步:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build -j cd build && ctest --output-on-failure--output-on-failure建议永远带上,只在失败时打印日志,成功时不刷屏。不加这个参数,CI 上失败后只能看到一个“Failed”,还得重跑一次才能看到具体哪条断言挂了,白等一轮构建。
3. 数据驱动:把三十条重复用例压成一张 QTest::addColumn 表
一个加法函数要测正数、负数、零、边界值,复制粘贴十个void testAdd1()、testAdd2()是新人最常见的写法,结果维护时改一个期望值要改十处。QTest 的数据驱动机制把这件事压成一张表:用例逻辑只写一份,参数行随便加。
3.1 _data() 槽与 QFETCH 的配对规则
规则很简单:testXxx_data()负责填表,testXxx()负责消费,两个函数名严格配对,框架自动先调_data再按行循环调testXxx。
void TestCalc::testAdd_data() { // 声明三列,列名后面 QFETCH 要用,必须完全一致 QTest::addColumn<int>("a"); QTest::addColumn<int>("b"); QTest::addColumn<int>("expected"); // 每行一个标签,标签会出现在失败日志里 QTest::newRow("both-positive") << 1 << 2 << 3; QTest::newRow("with-negative") << -5 << 3 << -2; QTest::newRow("all-zero") << 0 << 0 << 0; QTest::newRow("int-max") << 2147483647 << 0 << 2147483647; } void TestCalc::testAdd() { // 按列名取值,类型和顺序由宏参数决定 QFETCH(int, a); QFETCH(int, b); QFETCH(int, expected); Calc c; QCOMPARE(c.add(a, b), expected); }三个细节容易踩。第一,QTest::newRow的标签必须唯一,重复标签框架只保留第一条,后面的被静默丢弃,你会以为写了四行结果只跑了两行。第二,QFETCH的列名是字符串匹配,写错一个字母编译能过、运行时报QFETCH: No such column,而且报错位置在用例里不在数据函数里,第一次遇到很难定位。第三,列的类型必须和<<进去的值类型严格可转换,比如列声明成int却塞了个qint64大数,会静默截断,测试通过但测的是错的数。
数据驱动最实际的收益是失败定位。单个用例失败只告诉你“testAdd 挂了”,数据驱动会告诉你“testAdd:int-max 挂了”,一眼能看出是哪一个输入组合。CI 日志里这一行差异能省掉十分钟排查。
3.2 参数表怎么设计:从边界值到错误码
表怎么填,比怎么写代码更考验功夫。我的习惯是按四类输入组织,每类至少一行:
| 类别 | 目的 | 示例行 |
|---|---|---|
| 常规值 | 保证主路径能跑通 | both-positive |
| 边界值 | 抓溢出、空值、长度 0 | int-max、empty-string |
| 异常输入 | 验证错误处理分支 | divide-by-zero |
| 状态相关 | 覆盖前置条件不满足的路径 | not-initialized |
异常输入的测法要单独说。Qt 5.11 之后有QVERIFY_THROWS_EXCEPTION,可以断言异常类型;更早的版本或者被测代码用返回码而不是异常时,就得自己包一层 try-catch 加QFAIL。两种写法都别偷懒写成“只验证不崩溃”,因为不崩溃并不等于错误处理正确,返回的错误码是不是预期那个,必须断言。
void TestCalc::testDivideError_data() { QTest::addColumn<double>("a"); QTest::addColumn<double>("b"); QTest::addColumn<bool>("shouldThrow"); QTest::newRow("normal") << 10.0 << 2.0 << false; QTest::newRow("zero-divisor") << 10.0 << 0.0 << true; QTest::newRow("zero-by-zero") << 0.0 << 0.0 << true; } void TestCalc::testDivideError() { QFETCH(double, a); QFETCH(double, b); QFETCH(bool, shouldThrow); Calc c; if (shouldThrow) { QVERIFY_THROWS_EXCEPTION(std::invalid_argument, c.divide(a, b)); } else { // 浮点比较用 QCOMPARE 的模糊匹配特化,不要写 == QCOMPARE(c.divide(a, b), a / b); } }这里有个隐藏坑:一个数据函数对应的用例里,如果部分行期望抛异常、部分行期望正常返回,分支必须写在同一个函数内,框架不支持按行切换用例函数。所以列设计时要把控制流的开关做成一个bool列,或者干脆拆成两个数据函数,宁可多写一个_data,也别在一个用例里堆三重 if。
3.3 跑起来:命令行参数与失败输出的读法
QTest生成的可执行文件本身就是个带参数的工具,-help能看到全集。日常最常用的几个:
./tst_calc -functions # 只列出所有用例名,不执行,写 CI 白名单时有用 ./tst_calc -v1 # 每个用例开始/结束打一行,看卡在哪 ./tst_calc -o result.xml,xml # 输出 JUnit 风格 XML,给 CI 解析 ./tst_calc -silent # 只输出失败信息,本地快速回归用 ./tst_calc testAdd:int-max # 只跑指定用例的指定数据行 ./tst_calc -maxwarnings 0 # 有任何警告就当失败,金融类项目常用testAdd:int-max这个语法是“函数名:数据行标签”,冒号是分隔符。调试单个失败行时非常省时间,尤其是数据驱动表有几十行的情况,不用一遍遍跑全量。
失败输出的格式值得认一遍,很多人看到一屏红字就慌,其实结构很固定:
FAIL! : TestCalc::testAdd(int-max) Compared values are not the same Actual (c.add(a, b)): 2147483647 Expected (expected) : -2147483648 Loc: [tst_calc.cpp(41)]四行分别是用例与数据行、断言类型、实际值、期望值、源码位置。Actual后面括号里的表达式是QCOMPARE第一个参数的原文,这个特性靠宏实现,所以第一个参数不要写成带副作用的表达式,比如QCOMPARE(counter++, 5),宏可能展开两次导致自增两次。这个坑我踩过,调试了半小时才发现是断言宏干的。
4. 异步与 GUI:QSignalSpy、QTRY_COMPARE 和事件循环的配合
同步函数好测,异步才见真章。网络回调、定时器、动画、线程池,这些路径的共同点是结果不立刻产生,一段sleep之后断言看起来能过,换台机器就挂。
4.1 为什么 sleep 会毁掉异步测试
QTest::qSleep(500)是阻塞睡眠,它压根不处理事件循环。被等待的信号在主线程的事件队列里排着,主线程睡着,信号永远发不出来,等到 sleep 结束再断言,结果是“信号未收到”,可实际上逻辑没问题。这种用例的典型表现是本地偶尔过、CI 稳定挂,属于最消耗信任的一类失败——跑几次之后团队就开始无视红色了。
正确的等待方式分两种。知道要等哪个信号,用QSignalSpy::wait(),它内部跑局部事件循环,信号一到立刻返回,超时才放弃:
class Loader : public QObject { Q_OBJECT public: void start() { // 50ms 后发信号,模拟异步回调 QTimer::singleShot(50, this, [this] { emit finished(42); }); } signals: void finished(int code); }; void TestLoader::testFinishedSignal() { Loader loader; QSignalSpy spy(&loader, &Loader::finished); // 信号签名对不上时 isValid 为 false,先拦一道 QVERIFY(spy.isValid()); loader.start(); // 等待信号,参数是超时毫秒;返回 false 表示超时 QVERIFY(spy.wait(1000)); QCOMPARE(spy.count(), 1); QCOMPARE(spy.at(0).at(0).toInt(), 42); }不知道具体信号、只知道某个状态最终会变,用QTRY_COMPARE,它按固定间隔轮询表达式直到成立或超时。
void TestLoader::testStateEventuallyDone() { Loader loader; loader.start(); // 默认超时 5s,内部会持续处理事件循环 QTRY_COMPARE_WITH_TIMEOUT(loader.state(), Loader::Done, 3000); }QTRY_COMPARE的默认超时是 5 秒,CI 上机器慢的时候容易擦边,我习惯显式写_WITH_TIMEOUT并给 3 到 5 秒。超时设太长的坏处是失败时整个测试套件被拖住,一个挂起用例拖十秒,二十个就是三分多钟。
4.2 QSignalSpy 捕获信号的三个断言点
QSignalSpy不只是“等信号”,它记录的是完整的调用序列,所以能断言三个维度:
第一是次数。spy.count()是不是恰好 1,这个能抓住重复发射的 bug,比如重连逻辑写错导致回调触发两次,界面上看不出问题但业务侧会重复扣款。
第二是参数。spy.at(0).at(0)拿到的是QVariant,需要toInt()、toString()转回来。参数类型是自定义结构体时,得先qRegisterMetaType注册,否则队列连接下参数会丢,spy.at(0).size()直接是 0,排查起来很反直觉。
第三是顺序。多个信号之间的先后关系同样能断言,比如“先started再finished”:
void TestLoader::testSignalOrder() { Loader loader; QSignalSpy spyStarted(&loader, &Loader::started); QSignalSpy spyFinished(&loader, &Loader::finished); loader.start(); QVERIFY(spyFinished.wait(1000)); QCOMPARE(spyStarted.count(), 1); // 两个 spy 各自独立,这里只能验证都发生了,顺序需要额外时间戳 QVERIFY(!spyStarted.isEmpty()); }严格验证顺序需要额外手段,比如在槽里往QList<QString>里追加名字再比对列表。这个做法有点土,但比依赖时间戳稳。
4.3 QTRY_COMPARE 的超时参数与 QBENCHMARK 的边界
QBENCHMARK是 QTest 附带的性能测量宏,很多人顺手用它做性能回归,但它有几个边界必须知道。
void TestCalc::benchmarkAdd() { Calc c; volatile int sink = 0; // 防止编译器把整个调用优化掉 QBENCHMARK { sink = sink + c.add(1, 2); } }第一,它测的是循环体内的耗时,框架会自动决定重复次数,输出的是平均值不是单次值,波动比专业 benchmark 工具大。第二,它默认不参与成败判定,只打印结果,所以放进 CI 也不会因为变慢而失败;要做性能门禁得自己读结果再断言。第三,volatile那个变量不能省,否则开优化后编译器发现结果没人用,直接把循环删掉,测出来是 0 纳秒,看起来性能完美,实际上什么都没测。
我的用法是:QBENCHMARK只放在本地跑,用来对比改动前后的量级,不进 CI 的必过项。CI 上只保留功能用例,避免因为机器负载抖动导致误报。真要做性能回归,得用固定硬件加多次采样取中位数,那是另一套工程。
异步测试还有一条常被忽略:测试结束时要确保没有残留的定时器和未完成的异步任务。cleanup()里除了清状态,还要QCoreApplication::processEvents()把队列里剩下的都排掉,否则上一个用例遗留的回调可能在下个用例里触发,造成“单独跑能过、全量跑就挂”的诡异现象。
5. 避坑排查:QTest 里最容易翻车的五件事
下面五条都是实际调试中反复出现的,按“现象 → 原因 → 解决”写,遇到同类症状直接对号入座。
5.1 用例数显示 0 passed,一个都没执行
现象:程序正常退出,输出Totals: 0 passed, 0 failed, 0 skipped,退出码 0,CI 上绿灯一片,实际上什么都没测。
原因:绝大多数是测试类没有Q_OBJECT宏,或者用例函数没写在private slots下。框架靠 moc 枚举槽函数,普通成员函数它不认识,直接跳过。另一种可能是.cpp末尾漏了#include "tst_xxx.moc",但这种情况通常会先链接报错,反而好发现。
解决:先跑./tst_calc -functions,输出为空就说明问题在声明层。检查三处:类是否继承QObject、是否带Q_OBJECT、用例函数是否在private slots:段内。还有一点,Q_OBJECT必须写在类体最开头附近,写在public:之后有时候也能过,但和某些编译器特性冲突,统一放第一行最稳。
5.2 QCOMPARE 对浮点和自定义类型编译不过或永远失败
现象一:QCOMPARE(0.1 + 0.2, 0.3)稳定失败,打印两个值看起来完全一样。 现象二:QCOMPARE(customObj, expected)编译报错,提示找不到operator==或QTest::toString。
原因:浮点数存在二进制表示误差,直接==必然不等;自定义类型则是因为宏展开后需要operator==比较和toString打印,缺一个就编不过。
解决:浮点用QCOMPARE是安全的,Qt 对double有模糊比较特化,但更明确的做法是写QVERIFY(qAbs(a - b) < 1e-9),把容差显式写在代码里,读代码的人一眼知道精度要求。自定义类型补两个函数,且必须放在全局命名空间:
struct Point { int x; int y; }; // 必须在全局命名空间,不能塞进类的私有段 bool operator==(const Point &lhs, const Point &rhs) { return lhs.x == rhs.x && lhs.y == rhs.y; } namespace QTest { template <> char *toString(const Point &p) { // 返回 QString::toUtf8 的副本地址不可行,这里用静态缓冲简化 static char buf[64]; std::snprintf(buf, sizeof(buf), "Point(%d, %d)", p.x, p.y); return buf; } }toString特化不是必须的,没有它失败日志只会打印类型名,但排查时能看到具体字段值,值得花五分钟补上。注意toString返回的指针要在框架使用期间有效,用static缓冲或者new后不管,前者简单但非线程安全,测试场景基本够用。
5.3 GUI 用例在没有显示器的环境下直接崩
现象:本地跑得好好的,扔进容器化 CI 后报This application failed to start because no Qt platform plugin could be initialized,或者直接段错误。
原因:QTEST_MAIN会创建QApplication,后者需要一个平台插件。无头环境没有 X11 或 Wayland,插件初始化失败,进程退出。
解决:在测试入口设置QT_QPA_PLATFORM=offscreen环境变量,Qt 会加载一个不依赖显示服务的内存后端:
# CMake + CTest 场景下设置测试环境变量 set_tests_properties(tst_widget PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") # 命令行临时验证 QT_QPA_PLATFORM=offscreen ./tst_widget另一个变体是误用QTEST_GUILESS_MAIN后又在用例里创建了QWidget,那会在构造时崩在QCoreApplication没有 GUI 能力上。判断标准很简单:测试二进制里只要出现QWidget或QQuickWindow,就必须用QTEST_MAIN。
5.4 单独跑能过、全量跑就挂
现象:./tst_calc testFoo通过,全量执行时testFoo失败,重排用例顺序后结果又不一样。
原因:用例间共享了可变状态。常见的三种是全局单例没有重置、临时文件或数据库没清、静态成员变量跨用例累积。还有一种隐蔽的:前一个用例启动了异步任务没等它结束,回调在下一个用例里被触发。
解决:把清理逻辑放cleanup(),每个用例结束后统一执行;对单例提供resetForTest()之类的接口,或者干脆用依赖注入让每个用例拿到独立实例。临时目录用QTemporaryDir,析构自动删,别手写路径拼字符串。异步任务在cleanup()里加一步确认:QTRY_VERIFY(loader.isIdle()),等它真空闲了再进下一个用例。
5.5 本地稳定通过,CI 上偶发失败
现象:同一份代码,本地连跑二十次全过,CI 上大概十次里挂一次,失败信息是“等待信号超时”。
原因:典型的时序敏感。QTest::qWait(100)这种固定等待在快机器上够用,在负载高的 CI 容器里不够。另一种是QSignalSpy::wait(100)给了个刚好卡在边缘的超时值。
解决:所有等待都改成条件等待,超时给足余量。QSignalSpy::wait的最小值我一般设 1000 毫秒,QTRY_COMPARE_WITH_TIMEOUT设 3000 毫秒,本地多等两秒无所谓,CI 上少一次误报能省一次全量回归。同时给测试加 CTest 超时,避免真挂死时卡住整个流水线:
set_tests_properties(tst_loader PROPERTIES TIMEOUT 60)还有一条排查习惯:偶发失败出现后,不要立刻重跑试图让它过,先把失败日志连跑五次收集起来。同一条用例在不同机器上失败点不一样,往往能直接定位到哪个等待时间设短了。
6. 接进流水线:覆盖率统计与失败重跑的一个具体做法
测试写完不接流水线,等于没有。这一章落到两件具体小事:怎么拿到覆盖率数字,以及怎么处理那些确实存在的偶发失败。
覆盖率用 gcov + lcov,和 CMake 配合只多两个编译参数:
# 1) 构建时开启覆盖率插桩,Debug + O0 保证行号准确 cmake -S . -B build-cov -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_CXX_FLAGS="--coverage -O0 -g" cmake --build build-cov -j # 2) 跑测试,gcda 计数文件会生成在构建目录 cd build-cov && ctest --output-on-failure # 3) 采集并过滤掉系统头和 Qt 头,只留业务代码 lcov --capture --directory . --output-file coverage.info \ --rc branch_coverage=1 \ --exclude '/usr/*' --exclude '*/Qt*' --exclude '*/tst_*' # 4) 生成 HTML,本地打开看哪几行没进过 genhtml coverage.info --output-directory cov_html --branch-coverage几个参数值得说明。--coverage同时开启-fprofile-arcs和-ftest-coverage,少了任何一个都不会生成.gcda。-O0不能省,开优化后编译器会重排甚至内联,行覆盖对不上源码。--exclude '*/tst_*'把测试代码本身排除掉,否则覆盖率虚高——测试文件自己总是 100% 被执行的。分支覆盖--branch_coverage=1建议开,行覆盖看起来 90% 的模块,分支覆盖可能只有 60%,那些没走到的else分支往往就是线上出问题的地方。
Qt 项目里覆盖率数字通常会比非 GUI 项目低一截,因为界面构造、事件分发这些路径很难在单元测试里完整走通。我的经验值是业务逻辑模块行覆盖 70% 以上、分支覆盖 55% 以上就算合格,UI 层不强求。盯着 100% 去补测试,最后补出来的多是为了覆盖而覆盖的空断言,维护成本比收益高。
第二件事是偶发失败的处理。CTest从 3.17 开始支持失败重跑,这个特性救过我很多次:
# 失败的用例最多重试两次,仍失败才算真失败 ctest --output-on-failure --repeat until-pass:3 -j8配套做法是给已知的时序敏感用例打标签,把它们和核心用例分开调度:
# 标记为 flaky,不参与主流水线的必过门禁 set_tests_properties(tst_loader PROPERTIES LABELS "flaky" TIMEOUT 60 ENVIRONMENT "QT_QPA_PLATFORM=offscreen")主流水线用ctest -E flaky排除它们,保证核心用例零容忍;另开一条低频流水线用ctest -L flaky专门跑,失败只告警不阻断。这样做的理由很实际:偶发用例如果混在主门禁里,团队很快就会习惯性点重跑,等到真正的问题也被当成抖动忽略掉,测试就失去意义了。分离开之后,抖动有独立的地方暴露,核心门禁保持干净。
从那以后我每加一个异步用例,都会先在本地用--repeat until-fail:20连跑二十次,确认它真的稳,才敢合进主干。这个习惯看着笨,但比在 CI 上被动地追查抖动省心得多。希望帮到你。
本文还有配套的精品资源,点击获取