
简介这份演示工程围绕Qt的QTest框架展开面向已具备C与Qt基础、希望为普通应用程序及动态链接库补齐单元测试的开发者重点解决类方法与DLL内部非导出类方法难以独立验证的问题。压缩包为zip格式共13个文件以5个cpp源文件、4个头文件和4个pro工程文件为主源码、声明与构建配置相互配套整体约6KB体量轻便便于快速查阅和二次改造。目前已有1467人学习说明其示例思路具有一定参考价值。内容覆盖测试类组织、数据驱动测试、QVERIFY等断言使用以及针对DLL非导出方法的友元测试类和测试专用导出接口两种策略并保留普通应用与简单DLL两条示例线。读者可据此理解QTest测试用例的分层组织方式对照工程结构搭建自己的测试骨架并借鉴内部方法可测性与封装之间的取舍思路。1. 从“测试跑绿了但Bug还在”说起QTest Demo到底测什么把 QTest 接进一个已有模块最反直觉的体验是测试全绿不代表代码没问题只代表你问的问题它答对了。这份利用 QTest 进行单元测试的 Demo重点不在教你写几个 QVERIFY而是把测试目标怎么建、断言怎么选、信号怎么等、桩对象怎么插这四件事串成一条可复现的线。它适合已经能写 C 类、但测试长期靠手工点界面的开发者也适合想把回归测试塞进构建流程的维护者。下面按实际拆包顺序讲不铺概念直接落到能编译、能跑、能定位失败的细节上。2. QTest工程骨架CMake目标、QTEST_MAIN与moc怎么接上2.1 先拆目录业务库、测试目标与构建脚本三层拿到一份 QTest Demo先别急着看断言写法先看目录里谁依赖谁。常见做法是把被测逻辑编成静态库或对象库测试可执行文件只负责链接这个库和 Qt Test 模块。这样测试目标不会把业务源码重复编译一遍后面加第二个、第三个测试文件时也不会互相牵扯。业务库里不要出现QTest头文件测试宏只留在tests目录这是保持依赖方向干净的第一条线。位置职责容易犯的错src/放被测类与纯逻辑把QVERIFY塞进业务头文件tests/放测试类、测试数据与辅助 Fake测试类忘记Q_OBJECTCMakeLists.txt定义库、测试可执行文件、注册 ctest只add_executable不add_test示例里的Calculator只是占位名换成你工程里的解析器、状态机、协议拆包器都行。关键是让测试目标通过target_link_libraries拿到业务库而不是用#include ../src/xxx.cpp这种把实现直接拖进测试文件的做法。后者在单文件 Demo 里能跑一旦被测类带Q_OBJECT或静态初始化就会出现重复符号和 moc 找不到的问题。2.2 CMake里把QTest接上find_package、AUTOMOC与add_testQTest 的构建接入不复杂难在几个开关要同时打开找 Qt Test 组件、开启 AUTOMOC、注册 ctest。下面这段 CMake 是常见骨架find_package同时兼容 Qt6 和 Qt5 的写法实际环境只命中一个主版本。add_test里的命令直接写测试目标名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 依赖 moc 处理 find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core Test) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Test) add_library(demo_core STATIC src/calculator.cpp ) target_include_directories(demo_core PUBLIC src) target_link_libraries(demo_core PUBLIC Qt${QT_VERSION_MAJOR}::Core) enable_testing() add_executable(tst_calculator tests/tst_calculator.cpp) target_link_libraries(tst_calculator PRIVATE demo_core Qt${QT_VERSION_MAJOR}::Test ) add_test(NAME tst_calculator COMMAND tst_calculator)这段脚本里CMAKE_AUTOMOC ON决定测试类里的Q_OBJECT能不能被扫描到如果关掉它编译期通常报undefined reference to vtable for TestCalculator不是链接器坏了是 moc 没生成。find_package里的Core和Test要同时出现测试目标链接Qt::Test业务库只需要Qt::Core。add_test的NAME是 ctest 里显示的名字COMMAND是测试可执行文件不写add_test时ctest列表为空但直接运行测试程序仍然能跑这个差异很容易让人误判“测试已经接进构建”。参数上target_include_directories(demo_core PUBLIC src)让测试目标能直接#include calculator.h不用写相对路径。target_link_libraries的PRIVATE表示测试可执行文件自己用 Qt Test不向业务库传播测试依赖。若你的工程用 qmake对应做法是把QT testlib写进测试.pro再通过CONFIG testcase注册但目录拆分和依赖方向不变。CMake 这边还有一个细节测试目标链接业务库时业务库如果是STATIC测试程序会把它整块链进去如果是SHARED运行时动态库路径要能被找到CI 上常见失败是ctest报找不到共享库而不是断言失败。2.3 测试类骨架Q_OBJECT、private slots与QTEST_MAIN测试类的骨架比业务类短但每个关键字都有作用。Q_OBJECT让 moc 生成元对象信息private slots里的槽函数会被 QTest 当成测试用例或生命周期钩子QTEST_MAIN展开出一个main里面调用QTest::qExec。下面这个测试类覆盖了初始化、数据驱动用例、普通断言和信号断言类名和文件名要能对上测试目标。#include QtTest #include calculator.h class TestCalculator : public QObject { Q_OBJECT private slots: void initTestCase(); // 整个测试类开始前执行一次 void add_handlesPositiveAndNegative_data(); // 数据驱动准备表格 void add_handlesPositiveAndNegative(); // 数据驱动按行执行 void divide_byZeroReturnsError(); // 普通用例 void emitsResultChanged(); // 信号用例 }; void TestCalculator::initTestCase() { qInfo() 测试开始前只执行一次适合准备只读资源; } void TestCalculator::add_handlesPositiveAndNegative_data() { QTest::addColumnint(a); QTest::addColumnint(b); QTest::addColumnint(expected); QTest::newRow(正数相加) 2 3 5; QTest::newRow(正负相加) 7 -2 5; QTest::newRow(零值边界) 0 0 0; } void TestCalculator::add_handlesPositiveAndNegative() { QFETCH(int, a); QFETCH(int, b); QFETCH(int, expected); Calculator calc; QCOMPARE(calc.add(a, b), expected); } void TestCalculator::divide_byZeroReturnsError() { Calculator calc; const auto result calc.divide(10, 0); QVERIFY(!result.ok); QCOMPARE(result.error, QStringLiteral(division by zero)); } void TestCalculator::emitsResultChanged() { Calculator calc; QSignalSpy spy(calc, Calculator::resultChanged); calc.add(1, 2); QCOMPARE(spy.count(), 1); QCOMPARE(spy.takeFirst().at(0).toInt(), 3); } QTEST_MAIN(TestCalculator) #include tst_calculator.mocQTEST_MAIN(TestCalculator)里的类名必须和继承QObject的测试类一致宏会生成入口并执行所有private slots。文件末尾的#include tst_calculator.moc是单文件测试的常见写法文件名要和源文件名对应如果工程把测试类拆成头文件和实现文件moc 的包含位置要跟着调整否则会出现“类声明了但用例没被发现”。initTestCase只跑一次适合准备只读数据每个用例前的清理用init每个用例后的清理用cleanup不要把所有重置逻辑都塞进initTestCase否则第二个用例会拿到第一个用例留下的状态。参数上QFETCH(int, a)的变量名必须与addColumn的列名完全一致类型也要一致列名写expected取的时候写成expectedValue就会在运行期报找不到列。QSignalSpy spy(calc, Calculator::resultChanged)第二个参数是信号成员指针信号参数会按顺序存进QVariantList所以spy.takeFirst().at(0).toInt()取的是第一个信号参数。这个骨架跑通后再谈断言选择才有意义。3. 断言与数据驱动QCOMPARE类型边界、_data()和QSignalSpy时序3.1 断言不是越多越好QVERIFY、QCOMPARE、QTRY_*的取舍QVERIFY 只回答“真不真”QCOMPARE 会打印实际值和期望值定位失败时后者更省时间。我的习惯是前置条件用 QVERIFY比如指针非空、文件能打开结果比对用 QCOMPARE尤其是整数、字符串、枚举。浮点数不要直接 QCOMPARE用qFuzzyCompare或QVERIFY(std::abs(a-b) eps)否则打印出来一堆小数位人眼也判断不了是精度问题还是逻辑问题。void TestParser::parseHeader() { Parser parser; const Header header parser.parseHeader(A:1\r\nB:2\r\n); QVERIFY(!header.fields.isEmpty()); // 前置条件至少解析出字段 QCOMPARE(header.fields.value(A), 1); // 结果比对显示实际/期望 QCOMPARE(header.fields.value(B), 2); }这段代码里QVERIFY失败会直接结束当前用例后面的 QCOMPARE 不再执行如果字段为空是预期内的分支就不该用它当前置条件。QCOMPARE(header.fields.value(A), 1)的两个参数类型要能比较QMapQString,int::value返回int和字面量1对得上如果左边返回QString右边写1某些编译器下会退化成指针比较或直接编译失败。字符串比较统一用QStringLiteral(...)枚举比较保证两边同类型自定义结构体要么提供operator要么在失败时补一个QTest::toString特化不然报错信息只有“实际值不等于期望值”排查时像看黑匣子。异步逻辑不要用QTest::qWait(500)硬等。固定睡眠在本地可能过在 CI 上会因为机器慢而翻车QTest 提供了QTRY_VERIFY、QTRY_COMPARE和带超时的QTRY_COMPARE_WITH_TIMEOUT它们会反复处理事件循环直到条件成立或超时。超时值不是越长越好一般按业务上限给 1 到 3 秒失败时能快速暴露问题而不是把测试拖成几分钟。3.2 数据驱动_data()槽与QTest::addColumn的类型对齐数据驱动测试适合输入输出关系明确、边界值多的函数。_data()后缀不是装饰QTest 会自动把xxx_data()和xxx()关联起来每一行newRow都会单独执行一次xxx()失败信息里带行名定位比循环断言清楚。下面这段用三列描述输入、期望输出和是否应产生告警行名写清楚场景后面看日志时不用猜。void TestNormalizer::normalize_data() { QTest::addColumnQString(input); QTest::addColumnQString(expected); QTest::addColumnbool(shouldWarn); QTest::newRow(空串) QStringLiteral() QStringLiteral() false; QTest::newRow(前后空格) QStringLiteral( 42 ) QStringLiteral(42) false; QTest::newRow(非法字符) QStringLiteral(4x2) QStringLiteral(4x2) true; } void TestNormalizer::normalize() { QFETCH(QString, input); QFETCH(QString, expected); QFETCH(bool, shouldWarn); QString warning; const QString actual normalizeInput(input, warning); QCOMPARE(actual, expected); QCOMPARE(!warning.isEmpty(), shouldWarn); }addColumnQString和QFETCH(QString, input)必须成对列名大小写也要一致。newRow的第一个参数是行名重复行名不会阻止编译但失败输出会混淆所以行名要带场景比如“空串”“前后空格”“非法字符”。如果列类型是自定义结构体先Q_DECLARE_METATYPE再让类型支持拷贝和比较否则QTest::addColumn那一步就会编译失败。数据驱动不是把所有用例都塞进一张大表表太大的时候拆成多个_data()每个表只覆盖一类边界维护成本更低。3.3 信号与异步QSignalSpy::wait、QTRY_COMPARE_WITH_TIMEOUT信号测试最容易翻车的地方是“信号什么时候发”。同步发射的信号构造 QSignalSpy 后调用被测函数再查count()就够了异步发射的信号要等事件循环跑起来。QSignalSpy::wait(ms)会阻塞等待指定信号返回 false 表示超时QTRY_COMPARE_WITH_TIMEOUT则适合轮询某个状态直到它等于期望值。void TestWorker::finishedOnce() { Worker worker; QSignalSpy spy(worker, Worker::finished); QTimer::singleShot(0, worker, Worker::start); QVERIFY(spy.wait(2000)); // 等信号不在测试里写固定 sleep QCOMPARE(spy.count(), 1); // 只允许完成一次 } void TestWorker::stateBecomesReady() { Worker worker; worker.startAsync(); QTRY_COMPARE_WITH_TIMEOUT(worker.state(), Worker::Ready, 2000); }spy.wait(2000)的参数是毫秒超时后返回 false后面的QCOMPARE不会执行如果信号是同步发射的wait可能直接返回 false因为信号已经过去了所以同步场景不要硬套wait。QTRY_COMPARE_WITH_TIMEOUT的第三个参数同样是毫秒它会在超时前反复处理事件适合状态机、网络回调、定时器触发这类没有单一信号可等的场景。测试里出现QTest::qWait(1000)时先问自己我等的到底是哪个信号或哪个状态能落到信号就用 QSignalSpy能落到状态就用 QTRY固定睡眠只留给你确实无法观察的外部进程。4. 测试替身落地接口隔离、手写Fake与QSignalSpy的分工4.1 先让类可测构造函数注入与接口最小化很多类不是“难测”而是“没法在测试里构造”。构造函数里直接 new 数据库连接、直接读系统时间、直接发网络请求测试就只能连真实环境跑一次慢一次失败还不可复现。常见做法是把外部依赖抽成一个最小接口通过构造函数传入业务类只依赖接口不依赖具体实现。这样测试里可以塞一个手写 Fake生产代码里塞真实实现依赖方向从“业务类自己造依赖”变成“调用方注入依赖”。struct Clock { virtual ~Clock() default; virtual qint64 nowMs() const 0; }; class ExpiryService { public: explicit ExpiryService(Clock* clock) : m_clock(clock) {} bool isExpired(qint64 deadlineMs) const { return m_clock-nowMs() deadlineMs; } private: Clock* m_clock; };Clock只暴露nowMs()不把整个时间库搬进来ExpiryService的构造函数只收一个Clock*不自己在内部取当前时间。参数上deadlineMs用毫秒时间戳测试里可以精确控制“刚好等于截止时间”和“超过 1 毫秒”两个边界。接口越小Fake 越好写如果接口里有十个方法测试类就得实现十个空函数最后 Fake 本身变成维护负担。4.2 手写Fake的边界只模拟你控制不了的东西Qt 工程里没有强制绑定某个 Mock 框架手写 Fake 是最常见的落地方式。Fake 不是“假数据生成器”它要能按测试需要推进状态比如时钟向前拨、队列里塞一条消息。下面这个 FakeClock 只做一件事保存当前毫秒数并允许测试推进时间。class FakeClock : public Clock { public: explicit FakeClock(qint64 now) : m_now(now) {} qint64 nowMs() const override { return m_now; } void advance(qint64 delta) { m_now delta; } private: qint64 m_now; }; void TestExpiryService::expiresAfterDeadline() { FakeClock clock(1000); ExpiryService service(clock); QVERIFY(!service.isExpired(1200)); // 1000 未超过 1200 clock.advance(201); QVERIFY(service.isExpired(1200)); // 1201 超过 1200 }FakeClock(1000)的初始时间由测试指定advance(201)把时间推到 1201正好跨过截止时间。这里没有真实等待也没有qWait测试执行时间是毫秒级。Fake 的边界要守住只模拟业务真正依赖的行为不要在 Fake 里复刻真实实现的全部逻辑如果 Fake 越写越像生产代码说明接口切得不对应该回到 4.1 重新拆依赖。4.3 Fake、Stub、Spy、Mock怎么选一张表落地替身类型作用在 QTest 里的常见写法适用场景Stub返回固定值手写类方法直接 return 常量只关心输入不关心中间调用Fake可工作的简化实现FakeClock、内存队列需要推进状态、模拟时间Spy记录调用或信号QSignalSpy验证信号次数、参数Mock预设期望并校验调用第三方框架或手写断言需要验证“有没有按顺序调用”QSignalSpy 是 Qt 测试里最顺手的 Spy不需要额外依赖能记录信号次数和参数。Mock 不是越多越好验证“某方法被调用了几次”往往让测试和实现绑死重构时一改就红。我的取舍是能通过返回值或最终状态判断的不用 Mock需要观察信号和回调的用 QSignalSpy需要控制时间的用 Fake只有跨模块协议、必须验证调用顺序时才考虑 Mock。5. QTest避坑排查五个从“用例消失”到“进程闪退”的翻车点5.1 构建能过但 ctest 列表为空测试目标没注册现象是测试可执行文件编译成功手动运行也能跑但ctest显示没有测试。原因通常是 CMake 里只写了add_executable漏了enable_testing()或add_test。先跑ctest -N看列表再检查顶层 CMakeLists 是否在add_subdirectory(tests)之前调用了enable_testing()。解决方式是把enable_testing()放在顶层add_test(NAME tst_calculator COMMAND tst_calculator)放在测试目标定义之后然后用ctest --output-on-failure跑一遍。ctest -N ctest --output-on-failure ./tst_calculator -functionsctest -N只列不跑适合确认注册是否生效--output-on-failure让失败用例的输出直接打到终端-functions列出测试类里被识别到的槽函数用例“消失”时先看这个列表比反复改断言快。5.2 QCOMPARE 类型不匹配编译失败或输出看不懂现象是 QCOMPARE 那一行编译不过或者失败时只打印两个看不出差别的值。原因常见于QString和const char*混比、枚举和整数混比、自定义结构体没有operator。解决方式是统一类型字符串用QStringLiteral枚举两边保持同类型自定义类型先提供比较运算符浮点数改用qFuzzyCompare或带容差的 QVERIFY。不要用强制转换把类型问题压过去转换后失败信息会更难读。5.3 单例与全局状态污染单独跑过全量跑挂现象是某个用例单独运行通过和别的用例一起跑就失败。原因多半是单例、静态缓存、全局配置没有在每个用例后重置。initTestCase只执行一次不能承担“每个用例都清理”的职责要用cleanup()或init()把状态恢复到基线。更彻底的做法是让测试目标一次只跑一个测试类或者用QTest::qExec分别执行多个测试对象减少共享状态的影响。5.4 信号测试的时序坑processEvents 与 wait 用错现象是本地跑绿CI 上偶发失败日志里信号次数是 0。原因是用固定qWait等异步信号机器一慢就超时或者信号已经同步发射却又调用spy.wait()等一个已经过去的信号。解决方式是先判断信号是同步还是异步同步信号直接查count()异步信号用spy.wait(ms)或QTRY_COMPARE_WITH_TIMEOUT。测试里尽量不写“等 500 毫秒”而是写“等到某个条件成立最多 2 秒”。5.5 崩溃没有失败用例进程直接退出现象是测试进程闪退ctest 只报一个非零退出码看不到具体是哪个用例挂了。原因可能是空指针、数组越界、断言里解引用了无效对象或者异常跨出了测试槽。解决方式先用-functions缩小范围再用-v2看最后执行的用例编译时打开地址检查工具把崩溃点定位到行。测试里对可能为空的返回值先用QVERIFY拦住不要让解引用发生在断言之前否则 QTest 还没来得及记录失败进程已经没了。./tst_calculator -v2 -o result.txt,txt ./tst_calculator divide_byZeroReturnsError-v2输出每个用例的执行细节-o result.txt,txt把结果写到文件单个用例名可以直接作为参数只跑那一个。先定位再修比在几十个用例里猜快得多。6. 进阶跑法测试套件、-v2日志与一次通过前的检查表测试文件多起来后不要把所有用例塞进一个可执行文件。常见做法是按模块拆成多个测试目标每个目标链接自己的业务库ctest 里用-R按名字筛CI 上并行跑。日志方面本地排查用-v2CI 上保留-o输出文件失败时直接看文件里的实际值、期望值和行号。QTest 的参数不花哨但组合起来能省很多来回。检查项命令或动作看什么测试是否注册ctest -N列表里有没有目标名用例是否被发现./tst_xxx -functions槽函数是否齐全失败详情ctest --output-on-failure实际值、期望值、行号单用例复现./tst_xxx 用例名能否独立复现构建产物cmake --build build --target tst_xxx是否只编测试目标还有一个容易忽略的技巧给测试目标加编译选项把警告当错误处理尤其是未使用变量和类型转换警告。QTest 的QCOMPARE模板在类型不匹配时给出的编译信息很长提前把警告暴露出来比在 CI 日志里翻半天强。需要对照源码时先看tests目录里的测试类再回到CMakeLists.txt对目标名和链接项顺序反过来容易在构建脚本里迷路。从那以后我每次新增一个 QTest 用例都会先跑一遍tst_xxx -functions和ctest --output-on-failure确认用例真的被识别、失败信息真的能读再动业务代码。希望帮到你。本文还有配套的精品资源点击获取