QTestLib(也就是Qt自带的Qt Test模块)是我在给C++/Qt项目补单元测试时最常用的一套工具。如果你已经写了一阵子Qt业务代码,但还没系统地给项目加测试,或者试过Google Test之后总觉得跟Qt的信号槽、事件循环配合得不够顺手,那这篇总结应该能帮你把QTestLib真正用起来。我会从工程配置、测试框架的核心机制、数据驱动、信号验证、GUI模拟、性能基准这几个方面,把我踩过的坑和一些实际建议一起写出来。
1. 先搞清楚QTestLib解决什么问题
1.1 为什么要用Qt自家的测试框架
很多Qt开发者一上来就选Google Test,理由是社区大、资料多、断言丰富。这个选择本身没错,但如果你测试的对象是和QObject、信号槽、事件循环深度绑定的代码,QTestLib其实更合适。原因很直接:它不需要额外引入第三方库,Qt安装包里自带Qt6::Test模块(Qt5里叫Qt5::Test),而且它和QObject的元对象系统是打通的。
比如你要验证某个按钮点击后是否发出了一个信号,或者某个Worker在事件循环里是否在300毫秒内完成了异步任务,用QTestLib可以直接操作目标对象的消息队列,配合QSignalSpy等待信号,这些在Google Test里要么自己造轮子,要么引入额外的mock框架。QTestLib因为出身于Qt生态,对这类场景的支持是原生的。
1.2 QTestLib和Google Test怎么选
我自己的经验是:纯C++的业务逻辑、算法、数据结构测试,用Google Test或Catch2更舒服,断言更丰富,输出也更友好。但只要是涉及QObject派生类、信号槽、界面交互、事件循环异步行为的测试,我统一用QTestLib。理由是项目里不用混两套框架,测试代码和数据文件、构建脚本都统一,成员切换成本也低。
还有一个容易忽略的点:QTestLib本身就是Qt的一部分,它跟着Qt版本走。Qt升级时,Test模块基本是同步维护的,很少出现第三方库不兼容新版Qt的情况,这意味着测试代码的生命周期和业务代码是一致的。第三方测试框架当然也能做到,但多一个依赖就多一层维护成本。
1.3 QTestLib的边界在哪里
坦白说,QTestLib的文档不算丰富,很多细节要靠源码和实践摸索。它的断言宏也不算多,复杂匹配得自己写辅助函数。如果你需要行为驱动、mock对象、嵌套测试套件这些高级特性,QTestLib给不了,得靠TurtleMock、QMock等第三方库补齐,或者干脆换框架。
另外,QTestLib的测试输出格式是为Qt自家的测试环境设计的,和JUnit、CTest的集成需要写一个xml输出选项:运行时加-o result.xml,xml即可生成XML报告。CI系统如果只认JUnit格式,得再转一道。这些细节后面我会展开。
2. 环境准备:把第一个测试跑起来
2.1 CMake和qmake的工程配置
不管用CMake还是qmake,配置其实都很简单。CMake版本需要3.16以上,Qt用5.15或6.x都行:
cmake_minimum_required(VERSION 3.16) project(MyProjectTest) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 REQUIRED COMPONENTS Test Widgets) add_executable(tst_mytest tst_mytest.cpp ) target_link_libraries(tst_mytest PRIVATE Qt6::Test Qt6::Widgets ) include(CTest) if(BUILD_TESTING) add_test(NAME tst_mytest COMMAND tst_mytest) endif()qmake这边更简单,.pro文件里加一行QT += testlib就行。注意testlib是小写,Qt的模块名在qmake里统一用小写。如果是纯逻辑测试,不需要Widgets,那QT += testlib足矣,不要画蛇添足。
这里有个细节:CMake里add_test如果不加COMMAND参数,CPack或CTest执行时找不到测试二进制。测试代码本身可以通过QTEST_MAIN宏生成main函数,自动跑所有测试。
2.2 QTEST_MAIN宏和私有槽机制的原理
QTestLib最核心的一个设计是:测试函数不是普通函数,而是测试类里的私有槽函数。这个设计初看很奇怪,但它背后是Qt的元对象系统在起作用。
当你在测试类里写上Q_OBJECT宏,并且把测试方法声明在private slots:下面,moc编译器会为这个类生成元对象代码。QTestLib运行时通过QMetaObject枚举这个类的所有槽函数,自动找出名字匹配规则的函数来执行。QTEST_MAIN(TestClass)这个宏会展开成一个main函数,它创建一个QApplication(或QCoreApplication),然后调用QTest::qExec(&testObject),后者用元对象系统把测试函数一个个执行起来。
这就是为什么如果你某天把测试函数从private slots:挪到普通private:区域,编译能过,但运行时一个测试都不会执行。框架通过元对象系统的槽函数列表来发现测试,普通成员函数不在这个列表里。
#include <QtTest> #include <QObject> class TestCalc : public QObject { Q_OBJECT private slots: void add(); }; void TestCalc::add() { QCOMPARE(2 + 2, 4); } QTEST_MAIN(TestCalc) #include "tst_calc.moc"注意最后一行#include "tst_calc.moc",这个在CMake中配合AUTOMOC是必需的。moc处理头文件的同时,也会处理这个C++文件里Q_OBJECT和QTEST_MAIN所在的类,生成的moc文件包含这个类的元对象定义。如果不包含它,链接阶段会报undefined reference to vtable for TestCalc。
2.3 测试生命周期:init与cleanup的执行时机
QTestLib给每个测试类定义了四个特殊的槽函数,它们的执行时机很重要:
initTestCase():整个测试类第一个执行,只执行一次,适合创建测试所需的全局资源。cleanupTestCase():整个测试类最后一个执行,只执行一次,适合释放全局资源。init():每个测试函数执行前都会调用一次,适合重置状态、构造独立测试数据。cleanup():每个测试函数执行后都会调用一次,适合释放临时资源。
这四个函数不是必须全部实现,但只要你实现了,框架就会按上面顺序调用。我踩过一个坑:在initTestCase()里创建了数据库连接,又在每个init()里重复创建,导致测试函数间互相干扰。正确的思路是:initTestCase()只做套件级初始化,init()只做函数级初始化,两者的分工一定要清晰。
另外,如果init()里失败了,对应的测试函数不会执行,但框架会继续跑下一个测试。这一点很实用,可以用来做前置条件判断。
2.4 一个最小示例的运行结果
把上面的代码编译运行,终端会输出类似这样的内容:
********* Start testing of TestCalc ********* Config: Using QtTest library 6.5.0, Qt 6.5.0 PASS : TestCalc::add() Totals: 1 passed, 0 failed, 0 skipped, 0 blacklisted ********* Finished testing of TestCalc *********如果你用了QTEST_MAIN,测试类会在最后自动把结果汇总输出。如果某个测试失败,进程的退出码是非零,CI系统就能据此判断构建是否通过。这一点在做持续集成时非常关键。
3. 核心断言机制:QVERIFY与QCOMPARE的底层逻辑
3.1 QVERIFY和QCOMPARE的区别
QTestLib最常用的两个断言宏是QVERIFY(condition)和QCOMPARE(actual, expected)。表面看,QCOMPARE就是QVERIFY加了一个等值判断,但实际使用时有明显区别。
QVERIFY只接受一个bool表达式,如果表达式为false,会打印一条失败消息,并且当前测试函数立即返回。注意,不是终止整个测试套件,只是终止当前这个测试函数,后面的测试函数还会继续执行。所以如果你的测试函数里有三步检查,第一步走QVERIFY挂了,后面两步不会被执行。
QCOMPARE(actual, expected)则会对两个值做类型安全的比较,失败时打印两个值的具体内容。比如QCOMPARE(actual.toString(), expected.toString()),失败时你会看到两个字符串分别是什么,方便快速定位。QCOMPARE失败同样会终止当前测试函数。
这里有个重要区别:QVERIFY(actual == expected)失败时只告诉你条件不成立,但不会告诉你actual和expected各自是什么。所以我建议,只要是比较两个值的场景,一律用QCOMPARE,不要用QVERIFY包一个等号。
还有个容易误用的宏QVERIFY2(condition, message)。它比QVERIFY多了一个自定义错误消息参数,适合在条件不满足时附带说明性信息。但它和QVERIFY一样,终止当前测试函数。
3.2 QCOMPARE的自定义类型支持
QCOMPARE并不是万能的。它对Qt自带的基础类型、字符串、容器都有良好支持,但如果你拿一个自定义结构体去比较,编译器会直接报错。原因是QCOMPARE在比较失败时需要调用toString方法把值格式化输出,编译器要求你的类型支持operator==,并且提供QTest::toString的特化或operator<<实现。
我写过一个自定义坐标结构体,就遇到过这个报错:
error: static assertion failed: Type is not comparable解决方案是在结构体所在命名空间里重载operator<<用于输出,并实现QTest::toString:
QDebug operator<<(QDebug dbg, const MyPoint &p) { dbg.nospace() << "MyPoint(" << p.x << ", " << p.y << ")"; return dbg; } namespace QTest { template<> char *toString(const MyPoint &p) { return qstrdup(QString("MyPoint(%1, %2)").arg(p.x).arg(p.y).toUtf8().constData()); } }这样QCOMPARE失败时就能输出可读内容,而不是一行冷冰冰的"Compared values are not the same"。
3.3 浮点数比较:直接QCOMPARE几乎必挂
QCOMPARE(0.1 + 0.2, 0.3)在C++里几乎必然失败,因为浮点数的二进制表示有精度误差。QTestLib对浮点数的处理方式是:如果两个值都接近0,按ULP(unit in the last place)判断;否则按相对误差判断,但默认误差容限非常小,实际工程计算基本不会恰好相等。
我建议的做法是:自己写一个带容差的比较函数,然后配合QVERIFY使用:
void TestGeo::compareWithTolerance() { double actual = 0.1 + 0.2; double expected = 0.3; double tolerance = 1e-9; QVERIFY2(qAbs(actual - expected) <= tolerance, qPrintable(QString("actual=%1, expected=%2").arg(actual).arg(expected))); }Qt 5.14之后也提供了qFuzzyCompare,但它的容差是相对值,如果比较的数值接近0,效果不好。所以工程代码里我一般自己定义容差,这样可读性更强。在测试里的容差选择要结合业务:图形计算用1e-6,货币计算用1e-4甚至更大,具体看精度要求。
4. 数据驱动测试:用最少代码覆盖最多场景
4.1 _data函数与QFETCH机制
QTestLib一个非常实用的功能是数据驱动测试(data-driven testing)。它的写法是:给某个测试函数配套一个同名但带_data后缀的函数,在这个函数里定义多行测试数据。运行时,测试函数会被执行多次,每次拿一行数据。
void TestDateParser::parse_data() { QTest::addColumn<QString>("input"); QTest::addColumn<QDate>("expected"); QTest::newRow("正常日期") << "2024-01-15" << QDate(2024, 1, 15); QTest::newRow("当年第一天") << "2024-01-01" << QDate(2024, 1, 1); QTest::newRow("闰年二月") << "2024-02-29" << QDate(2024, 2, 29); QTest::newRow("非闰年二月") << "2023-02-29" << QDate(2023, 2, 28); QTest::newRow("无效输入") << "2024-13-01" << QDate(); } void TestDateParser::parse() { QFETCH(QString, input); QFETCH(QDate, expected); QDate result = parseDate(input); QCOMPARE(result, expected); }这里有个关键点:QFETCH必须写在测试函数里,而不是_data函数里。_data函数负责填充数据,QFETCH负责按当前行的列名取值,列名要和addColumn里定义的名字完全一致。如果名字不匹配,运行时直接报错。
4.2 newRow的命名和可读性
QTest::newRow("描述")里的描述会出现在测试输出里,方便你快速定位失败的是哪组数据。比如上面那个例子,如果"非闰年二月"失败了,输出会显示FAIL : TestDateParser::parse(非闰年二月),一眼就能看出是哪个场景坏了。
我习惯在每个newRow里加中文描述,虽然有些人觉得中文输出在某些终端会有乱码,但Qt6之后默认UTF-8,CI系统只要支持UTF-8就没问题。关键在于描述要准确表达这组数据的场景,而不是机械地写"case1"、"case2"。你想想,半年后测试挂了,看到一个"case1"能知道是什么吗?
4.3 数据驱动可以覆盖哪些场景
数据驱动测试最适合输入输出映射清晰的函数,比如解析器、校验器、序列化、状态机转换。我自己常用它来测:
- 网络协议解析:不同报文长度、不同字段值。
- 配置文件读取:各种合法的、非法的配置组合。
- 数值边界:最大值、最小值、0、负数、空字符串。
- 信号槽参数:不同枚举值触发的不同行为。
有些场景不适合数据驱动,比如步骤之间有顺序依赖的集成测试。数据驱动测试每个函数本身是独立的,如果内部还要依赖前一行数据产生的状态,那就违背了它的设计目的,写起来会非常痛苦。
4.4 数据驱动运行的底层逻辑
运行时,QTestLib会把你定义的每一行数据看作一次独立的测试函数调用:先调init(),再执行测试逻辑,再调cleanup()。这意味着每一行数据的测试之间状态是完全隔离的。这一点很多人没意识到,但它恰恰是数据驱动测试能保持测试独立性的关键。
如果你在测试函数里创建了临时文件,记得在cleanup()里删掉,否则下次跑同组数据时可能残留旧文件。
5. 信号测试:QSignalSpy与异步场景
5.1 QSignalSpy的基本用法
Qt的信号槽机制是它的灵魂,测试信号是否按预期发出,自然也是QTestLib的优势场景。QSignalSpy是一个专门用于监听信号的类,它连接到某个对象的某个信号上,每次信号发射,它就把参数记录下来。
QSignalSpy spy(&object, &MyClass::completed); object.start(); QCOMPARE(spy.count(), 1); QCOMPARE(spy.at(0).at(0).toString(), QString("done"));spy.at(0)是第一次发射的信号参数列表,at(0).at(0)是第一个参数。注意,QSignalSpy构造时如果信号名写错或参数类型不匹配,会在运行时打印警告,但不会编译报错。所以写完后第一件事就是跑一次确认spy确实监听到了信号。老版本的写法是QSignalSpy spy(&object, SIGNAL(completed(QString))),这种字符串连接方式编译期不做检查,我建议用新语法,至少能避免拼写错误。
5.2 用spy.wait代替qWait硬延时
测异步逻辑时,新手很容易踩这种坑:启动一个异步任务后,直接QTest::qWait(1000)等一秒钟,然后检查结果。这种硬编码延时非常不稳定:CI机器负载高时,1秒不够;本地性能好时,又白白等1秒。
QSignalSpy从Qt 5.10开始提供了wait(int timeout)方法,它会阻塞等待直到信号发射,或直到超时:
QSignalSpy spy(&worker, &Worker::finished); worker.startAsync(); QVERIFY2(spy.wait(5000), "信号在5秒内没有发出"); QCOMPARE(spy.count(), 1);wait返回bool表示是否等到了信号,spy.count()告诉你发射了几次。这样测试的等待时间完全取决于实际信号发射速度,而不是拍脑袋定一个延时。
如果异步逻辑里信号发射了多次,而你只关心最后一次的结果,可以用spy.last():
QCOMPARE(spy.last().at(0).toInt(), 42);5.3 轮询式宏:QTRY_VERIFY和QTRY_COMPARE
有些场景不适合用QSignalSpy,比如状态变化不是通过信号暴露的。Qt 5.10起提供了一组轮询宏:QTRY_VERIFY(condition)和QTRY_COMPARE(actual, expected)。它们的原理是:反复检查条件,直到条件满足或默认超时(5秒)耗尽。
QTRY_COMPARE_WITH_TIMEOUT(worker->progress(), 100, 10000);这个宏很方便,但要注意:如果在条件不满足时,worker对象正在做耗时操作,轮询检查可能会阻塞事件循环。QTestLib在内部会处理事件循环,但如果你在非GUI线程里跑测试,轮询机制可能不工作。所以这个宏主要用于主线程或GUI线程的测试。
5.4 验证警告和错误输出
QTestLib有个冷门但好用的机制:QTest::ignoreMessage()。它可以预期某条qWarning或qDebug消息会出现。
QTest::ignoreMessage(QtWarningMsg, "file not found, fallback to default"); auto result = loadFile("nonexistent.txt"); QVERIFY(result.isNull());这有两个作用:一是验证代码确实走到了错误的处理分支,二是防止qWarning的输出污染测试日志。不过要注意,ignoreMessage必须和那条警告的出现严格配对,如果代码没有发出该消息,测试会直接失败。这个特性适合用来验证错误处理路径。
6. GUI测试:模拟真实用户操作
6.1 让GUI测试在CI上跑起来
很多人觉得GUI测试很难做,其实QTestLib把鼠标键盘模拟已经封装好了。它不要求测试环境必须有显示器:Linux上设置QT_QPA_PLATFORM=offscreen,测试就会在无头模式运行。如果你用的是QWidget界面,这个环境变量基本能解决CI上没显示设备的问题。
在CI的构建脚本里,我建议这样设置:
export QT_QPA_PLATFORM=offscreen ctest --output-on-failure在Windows上,默认的windows平台插件本来就支持无头测试吗?说实话,windows下就算没有真实交互,窗口也能创建,只是不显示出来,所以问题不大。macOS上CI用的macOS runner通常也没有问题。如果你遇到弹窗导致CI卡死,加上offscreen基本都能解决。
6.2 鼠标点击和键盘输入的模拟
QTestLib提供了QTest::mouseClick、QTest::mousePress、QTest::mouseRelease、QTest::keyClick、QTest::keyPress、QTest::keyRelease等API。用法如下:
QLineEdit *lineEdit = dialog.findChild<QLineEdit *>("usernameEdit"); QTest::keyClicks(lineEdit, "zhangsan"); QPushButton *loginBtn = dialog.findChild<QPushButton *>("loginButton"); QTest::mouseClick(loginBtn, Qt::LeftButton); QVERIFY(spyLoginSucceed.wait(5000));这里有个关键细节:如果窗口没有处于显示状态,模拟鼠标事件可能不生效。即使你设了offscreen,也建议在交互前调用QTest::qWaitForWindowExposed(&dialog),确保窗口已经完成显示和布局。否则很多控件的位置还没计算出来,点击事件会落在错误的坐标上。
QTest::keyClicks是输入字符串的便捷方法,它会逐个字符触发键盘事件。如果你想测试快捷键,用QTest::keyClick(widget, Qt::Key_C, Qt::ControlModifier)。
6.3 一个对话框交互测试的完整示例
下面是一个登录对话框的简单测试,流程是:输入账号密码,点击登录,等待登录信号。
void TestLoginDialog::loginSuccess() { LoginDialog dialog; QSignalSpy loginSpy(&dialog, &LoginDialog::loginSucceeded); dialog.show(); QVERIFY(QTest::qWaitForWindowExposed(&dialog)); QLineEdit *userEdit = dialog.findChild<QLineEdit *>("userEdit"); QLineEdit *passEdit = dialog.findChild<QLineEdit *>("passEdit"); QPushButton *loginBtn = dialog.findChild<QPushButton *>("loginBtn"); QVERIFY(userEdit && passEdit && loginBtn); QTest::keyClicks(userEdit, "tester"); QTest::keyClicks(passEdit, "123456"); QTest::mouseClick(loginBtn, Qt::LeftButton); QVERIFY(loginSpy.wait(3000)); }注意我在点击按钮之前先QVERIFY了三个控件都存在,这个检查很值得做。如果设计方案改了,某个控件没了,测试会在点按钮之前就报错,定位问题更快,而不是等到点击空指针崩溃。
6.4 自定义控件的命中测试
如果你测试的是自绘控件,比如一个自定义绘图区域,QTest::mouseClick默认的坐标是控件相对坐标。你需要先计算想点击的位置,然后传给它。实际项目中经常要配合QWidget::mapFromGlobal来处理,这在测试自绘editor时很常见。
如果控件比较复杂,我建议把交互逻辑封装成公开方法,再通过调用方法触发,而不是每一步都从外部模拟鼠标。比如对一个绘图控件,测试点是否落在某个图形内,直接调一个containsPoint(p)方法就能验证,没必要真去模拟鼠标点击。
7. 性能基准测试:QBENCHMARK实战
7.1 QBENCHMARK的基本用法
QTestLib还内置了性能基准测试能力。你用QBENCHMARK包裹一段代码,框架会自动多次运行它,统计单次执行时间。测试报告里会输出最小、最大、平均、中位数等统计数据。
void TestContainer::appendBenchmark() { QVector<int> vec; QBENCHMARK { vec.append(42); } }跑起来之后你会看到类似输出:
PASS : TestContainer::appendBenchmark() RESULT : TestContainer::appendBenchmark(): 0.000077 msec per iteration (total: 77, iterations: 1000000)这里的迭代次数是框架自动调整的,目的是让总耗时达到一个稳定的测量窗口。你不用手动指定次数,框架会动态增加。
7.2 数据驱动的基准测试
QBENCHMARK和数据驱动测试是可以组合的。比如你想比较不同数据规模下的性能:
void TestSort::sortBenchmark_data() { QTest::addColumn<int>("n"); QTest::newRow("1000") << 1000; QTest::newRow("10000") << 10000; QTest::newRow("100000") << 100000; } void TestSort::sortBenchmark() { QFETCH(int, n); QVector<int> data; data.resize(n); std::iota(data.begin(), data.end(), 0); QBENCHMARK { std::sort(data.begin(), data.end()); } }但是要注意:如果QBENCHMARK里的操作会修改被测数据,比如sort会改变data的顺序,那么每次迭代的结果状态会不一样。这种情况下,要么在QBENCHMARK内部重新初始化数据,要么只测量不修改状态的只读操作。这个坑我踩过,当时的排序基准测试结果乱跳,最后发现是数据已经排好序了,后面几轮迭代测的是纯有序数据的排序时间,完全失真。
7.3 性能测试的稳定性问题
性能测试对运行环境非常敏感。CI机器上CPU频率波动、后台进程抢占,都会导致结果不稳定。QTestLib提供了重复运行和统计功能,但跨机器比较意义不大。我通常的做法是:性能测试不在普通CI里跑,单独放到一个定时任务里,在固定的专用机器上跑,记录历史趋势。
如果只是想在代码改动后快速判断有没有明显性能回退,可以用QTest::setBenchmarkResultThreshold设置阈值,超过阈值自动失败。不过这玩意要谨慎用,阈值太小容易误报,阈值太大又失去意义。我个人更习惯把基准测试结果输出到文件,然后脚本比较历史数据,而不是让单次测试直接失败。
8. 常见问题与排查技巧实录
8.1 测试函数没有被执行
现象:测试编译运行正常,但输出里只有Totals: 0 passed,你的测试函数一个都没跑。
原因几乎都是:测试函数忘了放在private slots:区域。我把这个和一个小技巧一起说:写完测试类后,第一件事就是编译运行,确认能看到Totals: N passed,N大于0,再继续加断言。完全不写测试逻辑,先跑一个空壳测试,能帮你尽早暴露配置问题。
如果函数本身放在slots里还是没执行,检查一下函数名是否拼写错误,或者函数是否被Q_SKIP了。QSKIP可以跳过测试,但如果你在init()里写了QSKIP,整个测试类的所有函数都会被跳过。
8.2 链接报错:undefined reference to vtable
这个报错在QTestLib测试代码里非常常见。核心原因就是测试类的Q_OBJECT宏触发了moc生成元对象代码,但编译单元没有包含对应的moc文件。
在CMake里,如果你用了AUTOMOC,一般会自动处理,但如果你的测试类定义在.cpp文件里,需要手动在文件末尾加#include "tst_xxx.moc"。漏了这一行,链接阶段必然报错。这个错误信息很长,但开头总有undefined reference to vtable for,看到它就直接查moc文件有没有被包含。
8.3 QCOMPARE输出乱码
Qt6默认UTF-8,但如果测试环境是Windows的控制台,或者CI配置了GBK编码,中文字符串可能乱码。这通常不影响测试结果,只影响可读性。解决办法:把Qt的日志输出重定向到UTF-8文件,或者用QT_FORCE_STDERR_LOGGING=1环境变量让日志走标准错误。
另一个选择是测试代码里的newRow描述统一用英文或拼音缩写。但我不太建议因为乱码就放弃中文描述,解决环境编码比改代码更根本。
8.4 GUI测试在offscreen模式下仍然失败
如果你的界面里有QMessageBox::exec()这种阻塞对话框,offscreen模式下它会一直等到用户点击,测试就会挂住。解决办法是:不要用exec()模式做交互测试,改成open()加信号监听;或者在测试环境里用一个自定义的消息框替代。
还有种情况:有些自定义控件在offscreen下没有触发绘制,导致依赖绘制结果的逻辑失败。这时可以先QTest::qWaitForWindowExposed确认窗口已经映射,再执行操作。
8.5 测试之间有状态残留
数据驱动测试虽然每行数据会执行一次完整的init/cleanup,但静态变量、全局变量不会自动重置。如果你测试的逻辑依赖静态缓存,可能第二次执行时结果就变了。
我遇到过一个典型的例子:测一个单例配置类,第一次测试设置了某些值,第二次测试没重置,导致后续所有测试都基于脏数据运行。解决办法:在cleanup()里显式重置单例状态,或者提供一个测试专用的重置接口。这类问题是最难排查的,因为它只在你跑完整套测试时出现,单独跑某个测试却全绿。
8.6 用表格快速定位常见问题
| 问题现象 | 常见原因 | 排查方向 |
|---|---|---|
| 测试函数没有执行 | 函数没放在private slots: | 检查代码结构 |
| vtable链接错误 | 漏包含.moc文件 | 检查文件末尾include |
| GUI测试挂起 | 弹出阻塞对话框 | 用open代替exec |
| 浮点比较失败 | 精度误差 | 加容差比较 |
| 信号监听不到 | 信号名拼写错误 | 打印spy.count()确认 |
| 数据驱动不生效 | 漏写_data后缀 | 检查函数命名 |
这份表格是我在实际项目中排查问题的快速参考,遇到这类报错,先对照表格排除,而不是从头读代码。
9. 我的一些日常使用习惯
最后说几点我自己积累的小经验。第一,写测试时不要只写成功路径,QVERIFY(!something)和QCOMPARE(result, defaultValue)这种失败路径的断言同样重要,往往能暴露设计缺陷。第二,QTestLib支持直接运行单个测试函数,比如./tst_mytest TestCalc::add,这个功能在调试时非常有用,不用每次跑完整套测试。第三,尽量在每次提交前把测试跑一遍,即使只是快速跑ctest,也能避免把坏代码合进主干。
QTestLib的学习曲线不陡,核心API就那几个宏和一个信号监听类,但真正用好它需要理解Qt的元对象系统和事件循环机制。希望这篇总结能帮你少走一些弯路。如果你在项目里用了其他更顺手的测试方案,也可以在评论区聊聊你的经验。