Qt 测试最佳实践

我们建议您为错误修复和新功能添加 Qt 测试。在尝试修复错误之前,添加一个失败的回归测试(最好是自动的),该测试在修复之前显示错误,并在修复后通过。在开发新功能时,添加测试以验证它们是否按预期工作。

遵守一组代码标准会使得 Qt 自动测试更有可能在所有环境中可靠地工作。例如,一些测试需要从磁盘读取数据。如果没有为此设置标准,则某些测试可能不可移植。例如,假设测试数据文件位于当前工作目录的测试只适用于源码构建。在阴影构建(源代码目录外)中,测试将无法找到其数据。

以下部分包含编写 Qt 测试的指南

一般原则

以下部分为编写单元测试提供一般指南

验证测试

在新的分支上编写和提交您的测试,与错误修复或新功能一起。一旦完成,您可以从您的基于该分支的分支检出,然后检出新的测试文件到该分支。这使您能够验证测试确实在先前的分支上失败,因此确实捕获了错误或测试了新功能。

例如,如果使用 Git 版本控制系统,修复 QDateTime 类中的错误的流程可以是这样的

  1. 为您的修复和测试创建一个分支: git checkout -b fix-branch 5.14
  2. 编写测试并修复错误。
  3. 使用修复和新测试进行构建和测试,以验证新测试带有修复笔通过。
  4. 将修复和测试添加到您的分支: git add tests/auto/corelib/time/qdatetime/tst_qdatetime.cpp src/corelib/time/qdatetime.cpp
  5. 将修复和测试提交到您的分支: git commit -m '修复 QDateTime 中的错误'
  6. 要验证测试实际上确实捕获了您需要修复的内容,检出您基于自己的分支的分支: git checkout 5.14
  7. 将测试文件检出至 5.14 分支: git checkout fix-branch -- tests/auto/corelib/time/qdatetime/tst_qdatetime.cpp

    现在,只有测试位于修复分支上。源树的其他部分仍然位于 5.14。

  8. 构建和运行测试以验证它在 5.14 上确实失败,因此确实捕获了错误。
  9. 您现在可以返回到修复分支:git checkout fix-branch
  10. 或者,您可以在5.14上恢复您的工作树到干净状态:git checkout HEAD -- tests/auto/corelib/time/qdatetime/tst_qdatetime.cpp

在您审查变更时,可以将此工作流适应,以检查确实有针对其修复的问题的测试。

为测试函数命名

给测试用例命名很重要。测试名称会在测试运行的失败报告中出现。对于数据驱动测试,数据行的名称也会在失败报告中出现。这些名称给予了阅读报告的人初步了解出了什么问题的线索。

测试函数的名称应该清晰地表明函数要测试的内容。不要简单地使用bug追踪标识符,因为如果bug追踪器被替换,标识符就会过时。此外,某些bug追踪器可能不是所有用户都能访问。如果bug报告可能对后面的测试代码读者有参考价值,您可以在测试的相关部分旁边加注解提及其。

同样,在编写数据驱动测试时,给测试用例起描述性名称,以表明每个测试用例关注的哪个功能方面。不要简单地编号测试用例,或使用bug追踪标识符。阅读测试输出的人将无法了解数字或标识符的含义。当相关时,您可以在测试行上添加注释,提及bug追踪标识符。最好避免添加空格字符或可能在运行测试的命令行shell上有意义的字符。这使其更容易在命令行上指定测试和标记 - 例如,将测试运行限制为仅一个测试用例。

编写独立的测试函数

在测试程序内部,测试函数应该是相互独立的,它们不应该依赖于之前运行过的测试函数。您可以通过单独运行测试函数使用tst_foo testname来检查这一点。

不要在多个测试中重用被测类的实例。测试实例(例如小部件)不应该是测试的成员变量,而最好是使用栈实例化以确保即使在测试失败时也能进行适当的清理,这样测试就不会相互干扰。

测试整个栈

如果API实施了可插入或平台特定的后端来实现繁重的工作,请确保编写涵盖从头到后端的代码路径的测试。使用模拟后端测试API的上层API部分是一种很好的方法来隔离API层错误与后端,但这与使用真实数据运行实际实现的测试是补充的。

快速完成测试

测试不应该在不必要地重复、使用不适当地大的测试数据量或引入不必要的空闲时间上浪费时间。

这对于单元测试尤为重要,因为每个额外的单元测试执行时间都会使分支跨多个目标的CI测试更长。记住单元测试是独立于负载和可靠性测试的,后者期望更大的测试数据量和更长的测试运行。

基准测试,通常要多次执行同一测试,应该位于单独的tests/benchmarks目录中,并且不应与功能单元测试混用。

使用数据驱动测试

数据驱动测试可以更轻松地添加在后续错误报告中发现的边界条件的新测试。

使用数据驱动的测试而不是连续测试多个项目,可以避免重复编写相似代码,并确保即使在早期测试失败的情况下,后续案例也能得到测试。它还鼓励进行系统和统一的测试,因为相同的测试被应用于每个数据样本。

当测试是数据驱动的时,您可以在命令行指定测试函数的名称和数据标签,如function:tag,来仅对特定的测试案例运行测试,而不是运行该函数的所有测试案例。这可以用于全局数据标签或局部标签,标识函数自身数据的行;甚至可以将它们组合为function:global:local

使用覆盖工具

使用类似Cocogcov的覆盖工具来帮助编写尽可能涵盖待测试函数或类中更多语句、分支和条件的测试。在为新功能开发周期早期完成此操作将更容易在代码重构后捕获回归。

选择适当的机制来排除测试

选择合适的机制来排除不适用的测试非常重要。

使用>()处理在运行时发现整个测试函数不适用的案例。当测试函数的部分应被跳过时,可以使用条件语句,可以选择性地使用qDebug()调用来报告跳出不适用部分的原因。

当存在已知应最终修复的测试失败时,建议使用,因为它支持在可能的情况下运行剩余的测试。它还验证问题是否仍然存在,并让代码的维护者知道如果他们无意中修复了它,即使使用Abort标志,也能得到这种好处。

数据驱动的测试函数或数据行可以使用#if限制在特定平台或启用特定功能上。但是,在跳过测试函数时,要留意moc的限制。预处理程序不访问编译器用于特性检测的所有编译器builtin宏。因此,预处理程序的条件可能对预处理器条件和代码的其他部分产生不同的结果。这可能导致预处理器为测试槽生成元数据,而实际的编译器将其跳过,或者省略实际编译入类的测试槽的元数据。在前一种情况下,测试将尝试运行没有实现的槽。在后一种情况下,虽然应该运行测试槽,但测试将不会尝试运行它。

如果整个测试程序对于特定平台不适配或未启用特定功能,则最佳方法是在父目录的构建配置中避免生成测试。例如,如果tests/auto/gui/someclass测试对macOS无效,请将其包裹在包含在tests/auto/gui/CMakeLists.txt中的子目录,进行平台检查。

if(NOT APPLE)
    add_subdirectory(someclass)
endif

如果使用qmake,请在tests/auto/gui.pro中添加以下行

mac*: SUBDIRS -= someclass

另请参阅使用QSKIP跳过测试

避免使用Q_ASSERT

Q_ASSERT宏在断言条件为false时使程序终止,但仅当软件以调试模式构建时。在发布和调试-发布构建中,Q_ASSERT不起任何作用。

应避免使用Q_ASSERT,因为它会导致测试根据是否在进行调试构建而表现不同,并且会导致测试立即中止,跳过所有剩余的测试函数,并返回不完整或不正确的测试结果。

它还会跳过测试结束时应该执行的任何拆卸或清理,因此可能会使工作区处于杂乱状态,这可能会给后续测试带来问题。

而不是使用Q_ASSERT,应使用QCOMPARE()或QVERIFY()宏变体。它们会导致当前测试报告失败并终止,但允许剩余的测试函数执行,整个测试程序正常终止。QVERIFY2()甚至允许在测试日志中记录描述性的错误消息。

编写可靠的测试

以下各节提供了编写可靠测试的指南

避免验证步骤中的副作用

在自动测试中使用QCOMPARE()、QVERIFY()等执行验证步骤时,应避免副作用。验证步骤中的副作用会使测试难以理解。此外,它们可以在将测试更改以使用QTRY_VERIFY()、QTRY_COMPARE()或QBENCHMARK时轻易地以难以诊断的方式破坏测试。这些可以多次执行传递的表达式,从而重复任何副作用。

当无法避免副作用时,请在测试函数结束时确保恢复先前的状态,即使测试失败也是如此。这通常需要使用在函数返回时恢复状态的RAII(资源获取是初始化)类,或者使用cleanup()方法。不要简单地将恢复代码放在测试的末尾。如果测试的一部分失败,则将跳过此类代码,并且不会恢复先前的状态。

避免固定超时

避免使用硬编码的超时,例如使用QTest::qWait()等待某些条件变为真。考虑使用QSignalSpy类、QTRY_VERIFY()或QTRY_COMPARE()宏,或与QTRY_宏变体结合使用QBENCHMARK类。

可以使用qWait()函数设置在执行某些操作和等待由该操作触发的异步行为完成之间的固定延迟。例如,改变小部件的状态然后等待小部件重绘。然而,这种超时经常导致在工作站上编写的测试在设备上执行时失败,因为预期的行为可能需要更长的时间才能完成。将固定超时增加到快慢测试平台所需值的几倍并不是一个好的解决方案,因为它会降低所有平台上的测试运行速度,尤其是对于表格驱动测试。

如果正在测试的代码在异步行为完成时发出Qt信号,则更好的方法是用QSignalSpy类通知测试函数可以进行验证步骤。

如果没有Qt信号,请使用QTRY_COMPARE()QTRY_VERIFY()宏,这些宏会定期测试指定条件,直到其变为真或达到最大超时时间。这些宏可以防止测试比必要的时长更长,同时避免在编写测试时在工作站上运行和在嵌入式平台上执行时出现中断。

如果没有Qt信号,并且您正在将测试作为开发新API的一部分,请考虑是否该API能从添加报告异步行为完成的信号中受益。

注意时间依赖性行为

某些测试策略容易受到某些类的时间依赖性行为的影响,这可能导致仅在特定平台上失败或在多次测试中不一致的结果。

这的一个例子是文本输入小部件,通常有一个闪烁的光标,这可能会使捕获的位图比较成功或失败,具体取决于捕获位图时光标的状态。这反过来,可能取决于执行测试的机器速度。

当测试基于计时器事件的类时,需要在执行验证步骤时考虑计时器行为。由于有各种时间依赖性行为,没有针对这种测试问题的单一通用解决方案。

对于文本输入小部件,可能的解决方案包括禁用光标闪烁行为(如果API提供了该功能),在捕获位图前等待光标达到一个已知状态(例如,如果API提供适当的信号,可以订阅该信号),或者排除包含光标的区域的位图比较。

避免位图捕获和比较

虽然通过捕获和比较位图来验证测试结果有时是必要的,但它可能相当脆弱且费时。

例如,某个特定的小部件可能在不同的平台或不同的窗口小部件样式中具有不同的外观,因此需要创建多个参考位图,并且随着Qt支持的平台集的演变而维护它们。对位图产生影响的变化意味着需要在每个支持的平台上的每个平台上重新创建预期的位图,这将需要访问每个平台。

位图比较也可能受到测试机器的屏幕分辨率、位深、活动主题、颜色方案、窗口小部件样式、活动区域(货币符号、文本方向等)、字体大小、透明效果以及窗口管理器选择等因素的影响。

在可能的情况下,使用程序性方法,例如验证对象和变量的属性,而不是捕获和比较位图。

改进测试输出

以下部分提供了生成可读且有用的测试输出的指南

检查警告

就像在构建你的软件时一样,如果测试输出中充满了警告,你会更难注意到一个真正是出现错误的线索。因此,定期检查你的测试日志中的警告,并调查其他无关输出的原因是明智的。当他们是错误的迹象时,你可以让警告触发测试失败。

在测试代码应该产生消息(例如关于误用的警告)时,也很重要的是测试它确实在相应情况下产生了这些消息。您可以使用QTest::ignoreMessage()测试测试代码产生的预期消息,这些消息由qWarning()、qDebug()、qInfo()及其友元产生。这将验证消息是否被产生,并将它在测试运行输出中过滤出去。如果消息未被产生,测试将失败。

如果预期的消息只有在Qt以调试模式构建时才会输出,请使用QLibraryInfo::isDebugBuild()确定Qt库是否在调试模式下构建。使用#ifdef QT_DEBUG不足够,因为它只会告诉您测试是否在调试模式下构建,而这并不能保证Qt库也在调试模式下构建。

(自Qt 6.3开始)您可以通过调用QTest::failOnWarning()来验证它们没有触发qWarning()的调用。这接受要测试的警告消息或匹配警告的QRegularExpression;如果产生了匹配的警告,它将报告并导致测试失败。例如,一个不应产生任何警告的测试可以使用QTest::failOnWarning(QRegularExpression(u".*"_s)),这将匹配任何警告。

您还可以设置环境变量QT_FATAL_WARNINGS,使警告被视为致命错误。详细信息请参阅qWarning();这不仅仅适用于自测试。如果警告可能会丢失在大量的测试日志中,那么偶尔设置此环境变量运行可以帮助您找到并消除任何已发生的警告。

避免在自测试中打印调试消息

自测试不应产生任何未处理的警告或调试消息。这将允许CI门控制台将新的警告或调试消息视为测试失败。

在开发过程中添加调试消息是可以的,但这些应该在测试检查之前禁用或删除。

编写良好的诊断代码

如果测试失败,将有助于任何有用的诊断输出成为常规测试输出的一部分,而不是将其注释掉、通过预处理器指令禁用或在仅调试构建中启用。如果在持续集成期间失败,与手动启用诊断代码并重新测试相比,在CI日志中拥有所有相关诊断输出可以节省您大量时间。特别是,如果失败是在您桌面上没有的平台上的。

测试中的诊断消息应使用Qt的输出机制,如qDebug()和qWarning(),而不是stdio.h或iostream.h输出机制。后者绕过Qt的消息处理,阻止-silent命令行选项抑制诊断消息。这可能导致重要的失败消息被隐藏在一个大量的调试输出中。

编写可测试的代码

以下部分提供了编写易于测试的代码的指南

打破依赖关系

单元测试的思路是使用独立的每一个类。由于许多类实例化了其他类,所以无法单独实例化一个类。因此,你应该使用一种称为依赖注入的技术,将对象创建与对象使用分离。工厂负责构建对象树。其他对象则通过抽象接口来操作这些对象。

这种技术在数据驱动应用程序中效果很好。对于GUI应用程序,这种方法可能比较困难,因为对象会被频繁创建和销毁。为了验证依赖抽象接口的类的正确行为,可以使用模拟技术。例如,请参阅Googletest 模拟(gMock)框架

将所有类编译成库

在小型到中型项目中,构建脚本通常会列出所有源文件,然后一次性编译可执行文件。这意味着测试的构建脚本必须再次列出所需的源文件。

在构建静态库的脚本中只列出一次源文件和头文件会更加方便。然后,将主函数链接到静态库以构建可执行文件,测试将链接到静态库。

对于在某些程序中重复使用相同源文件的项目,可能更合适地将共享类编译成动态链接(或共享对象)库,这样每个程序,包括测试程序,都可以在运行时加载。同样,将编译后的代码放入库中可以帮助避免在描述要组合哪些组件以制作各种程序时的重复。

设置测试机器

以下部分讨论了由测试机器设置引起的常见问题

所有这些问题通常可以通过使用虚拟化方法来解决。

屏幕保护程序

屏幕保护程序可能会干扰GUI类的某些测试,导致测试结果不可靠。为了确保测试结果的一致性和可靠性,应禁用屏幕保护程序。

系统对话框

操作系统或其他正在运行的应用程序意外显示的对话框可能会从自动测试涉及的窗口小部件中窃取输入焦点,导致无法复现的失败。

典型问题的例子包括macOS上的在线更新通知对话框、病毒扫描器的误报、病毒签名更新等计划任务、推送给工作站的软件更新以及聊天程序在堆栈顶部弹出窗口。

显示使用

一些测试使用了测试机的显示、鼠标和键盘,因此如果机器同时被用于其他目的或者并行运行多个测试,可能会失败。

CI系统使用专用的测试机来避免这个问题,但如果你没有专用的测试机,你可能可以通过在第二个显示器上运行测试来解决这个问题。

在Unix系统中,也可以在嵌套或虚拟X服务器上运行测试,例如Xephyr。例如,要在Xephyr上运行整个测试集,请执行以下命令

Xephyr :1 -ac -screen 1920x1200 >/dev/null 2>&1 &
sleep 5
DISPLAY=:1 icewm >/dev/null 2>&1 &
cd tests/auto
make
DISPLAY=:1 make -k -j1 check

NVIDIA二进制驱动程序的用户请注意,Xephyr可能无法提供GLX扩展。强制使用Mesa libGL可能会有所帮助

export LD_PRELOAD=/usr/lib/mesa-diverted/x86_64-linux-gnu/libGL.so.1

然而,当在Xephyr和不同版本libGL的真实X服务器上运行测试时,QML磁盘缓存可能会使测试崩溃。为了避免这种情况,请使用QML_DISABLE_DISK_CACHE=1

或者,使用离屏插件

TESTARGS="-platform offscreen" make check -k -j1

窗口管理器

在 Unix 上,至少需要运行两个自动测试(tst_examplestst_gestures)需要有一个窗口管理器正在运行。因此,如果在嵌套 X 服务器下运行这些测试,也必须在那个 X 服务器上运行一个窗口管理器。

您的窗口管理器必须配置为自动在显示上放置所有窗口。某些窗口管理器,如 Tab Window Manager(twm),具有手动定位新窗口的模式,这会阻止测试套件在没有用户交互的情况下运行。

注意:Tab Window Manager 不适合运行完整的 Qt 自动测试套件,因为 tst_gestures 自动测试会导致它忘记其配置并回退到手动窗口放置。

© 2024 Qt 公司。此处包含的文档贡献是各自所有者的版权。此处提供的文档是在 GNU 自由文档许可证版本 1.3 的条款下提供的,由自由软件开发基金会发布。Qt 及相关标志是芬兰和/或其他国家的 Qt 公司的商标。所有其他商标均为各自所有者的财产。