Qt 测试最佳实践#

创建 Qt 测试的指导原则。

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

遵守一系列编码标准将使 Qt 自动测试更有可能在所有环境下可靠地运行。例如,一些测试需要从磁盘读取数据。如果没有设定如何进行此操作的标准,有些测试将无法移植。例如,一个假设其测试数据文件位于当前工作目录的测试仅适用于源码内的构建。在阴影构建(源码目录之外)时,测试将找不到其数据。

以下章节包含编写 Qt 测试的指南。

基本原理#

以下章节提供编写单元测试的通用指南。

验证测试#

在新分支上编写和提交测试,以及您的修复或新特性。完成之后,您可以检查您的工作基础分支,然后检查到该分支的新测试测试文件。这使您能够验证测试是否在先前分支上失败,从而实际捕捉到一个错误或测试一个新特性。

例如,如果您使用 Git 版本控制系统,修复 QDateTime 类中的错误的 workflow 可能如下所示:

  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 '修复同年号中存在的问题'

  6. 为了验证测试实际上捕获了您需要修复的问题,请检出您基于的分枝: git checkout 5.14

  7. 仅将测试文件检出至5.14分支: git checkout fix-branch -- tests/auto/corelib/time/qdatetime/tst_qdatetime.cpp

    现在仅在fix-branch上的测试。其余源树仍处于5.14。

  8. 构建并运行测试以验证它在5.14上失败,因而确实捕捉到了一个错误。

  9. 你现在可以回到fix分支: git checkout fix-branch

  10. 或者,你可以将你的工作树恢复到5.14上的干净状态: git checkout HEAD -- tests/auto/corelib/time/qdatetime/tst_qdatetime.cpp

当你在审查更改时,你可以调整这个工作流程来检查更改确实附带了对问题的测试。

为测试函数赋予描述性名称#

为测试用例命名很重要。测试用例名称会出现在测试运行的失败报告中。对于数据驱动测试,数据行名称也会出现在失败报告中。这些名称提供了第一个错误提示。

测试函数的名称应该清晰地表明该函数正在尝试测试什么。不要仅仅使用错误跟踪标识符,因为如果错误跟踪器被替换,标识符就过时了。此外,一些错误跟踪器可能对所有用户不可用。如果错误报告可能对测试代码的后续读者有参考价值,你可以在测试的相关部分旁边添加注释来提及错误跟踪标识符。

同样,在编写数据驱动测试时,对测试用例给出描述性名称,指明每个测试用例所关注的每个功能方面。不要简单地编号测试用例,或使用错误跟踪标识符。阅读测试输出的任何人都不会知道这些数字或标识符的含义。如果相关,你可以在测试行上添加注释,提到错误跟踪标识符。最好避免添加空格字符和在可能想要运行测试的命令行shell中可能有特殊意义的字符。这使得在命令行上指定测试和标签更容易,例如,仅将测试运行限制在一个测试用例。

编写自包含的测试函数#

在测试程序中,测试函数应相互独立,并且不应依赖于先前已运行的测试函数。你可以通过独立的运行测试函数本身来检查这一情况:tst_foo testname

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

测试整个堆栈#

如果一个API是通过可插入或平台特定的后端实现的,这些后端执行繁重的工作,确保编写覆盖到后端的所有代码路径的测试。使用模拟后端对上层API部分进行测试是隔离API层与后端错误的好方法,但是它与使用真实数据运行实际实现的测试是互补的。

使测试快速完成#

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

这对于单元测试尤其正确,每增加一秒的单元测试执行时间,都会使跨越多个目标的分支的持续集成测试时间更长。请记住,单元测试与负载和可靠性测试是分开的,后者预期会有更大的测试数据量和更长的测试运行时间。

基准测试,典型地多次执行相同的测试,应位于一个单独的tests/benchmarks目录中,并且它们不应与功能单元测试混合。

使用数据驱动测试

通过数据驱动测试,可以更容易地为后续错误报告中发现的边界条件添加新的测试。

使用数据驱动测试而不是在测试中依次测试多个项目,节省了非常相似代码的重复,并确保即使在早期案例失败的情况下也能测试后续案例。它还鼓励系统化和统一的测试,因为相同的测试应用于每个数据样本。

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

使用覆盖率工具

使用Coco或gcov等覆盖率工具来帮助编写测试,尽可能覆盖被测试的函数或类中的尽可能多的语句、分支和条件。在新的功能开发周期早期这样做,将更容易在代码重构后捕捉到回归。

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

选择适当的排除不适用的测试机制很重要。

使用QSKIP()来处理在运行时发现整个测试函数不适用的当前测试环境的情况。如果只是要跳过测试函数的一部分,可以使用条件语句,并可选地使用qDebug()调用来报告跳过不适用的部分的原因。

当存在应最终修复的已知测试失败时,建议使用QEXPECT_FAIL,因为它支持尽可能运行测试的其余部分。它还验证问题仍然存在,并让代码的维护者知道如果他们无意中解决了它,即使使用了Abort标志。

数据驱动的测试中的测试函数或数据行可以被限制在特定的平台上,或者使用#if启用特定功能。然而,在使用#if跳过测试函数时要小心moc的限制。moc预处理程序无法访问编译器中常用的所有builtin宏,这些宏通常用于编译器的特性检测。因此,moc可能会得到与代码其余部分不同的预处理器条件结果。这可能会导致moc为测试槽生成元数据,而实际编译器跳过了该槽,或者省略了已编译到类中的测试槽的元数据。在前一种情况下,测试将尝试运行未实现的槽。在第二种情况下,即使应该运行测试槽,测试也不会尝试运行。

如果整个测试程序对特定平台不适用或除非启用特定功能,最佳做法是使用父目录的构建配置来避免构建测试。例如,如果tests/auto/gui/someclass测试对macOS不适用,则可以在tests/auto/gui/CMakeLists.txt中将它作为子目录的包含 Wrap,在其平台检查中

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() 等 verification 步骤的自动测试中,应避免副作用。副作用可能会使测试变得难以理解。此外,在测试改为使用 QTRY_VERIFY()QTRY_COMPARE()QBENCHMARK() 时,它们可多次执行传递的表达式,从而重复任何副作用。

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

避免使用固定超时

避免使用硬编码的超时,如使用 qWait() 等待某些条件成立。考虑使用 QSignalSpy 类,QTRY_VERIFY()QTRY_COMPARE() 宏,或与 QTRY_ 宏变体一起使用的 QSignalSpy 类。

函数 qWait() 可用于在执行某些操作和等待由该操作触发的某些异步行为完成之间设置固定时间段内的延迟。例如,更改小部件的状态然后等待小部件被重新绘制。然而,这种超时有时会导致在工作站上编写的测试在设备上执行时失败,因为在设备上预期的行为可能需要更长的时间才能完成。将固定超时增加到所需的最慢测试平台大小的几倍不是好办法,因为这会减慢所有平台上的测试运行,特别是对于表驱动测试。

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

如果没有 Qt 信号,使用 QTRY_COMPARE()QTRY_VERIFY() 宏,这些宏会定期检查指定的条件,直到该条件成立或达到某个最大超时。这些宏可以防止测试超过必要的时间,同时避免在编写工作站测试然后在嵌入式平台上执行时发生破坏。

如果没有 Qt 信号,并且你正在开发新 API 的测试,考虑该 API 是否可以受益于添加报告异步行为完成的信号。

当心时间依赖的行为

某些测试策略容易受到某些类的时间相关行为的影响,这可能会导致仅在特定平台上失败的测试,或者无法返回一致的结果。

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

当测试基于计时器事件改变状态的类时,在进行验证步骤时需要考虑计时器行为。由于计时器相关的行为有很多种,所以没有单一的通用解决方案来解决这个问题。

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

避免位图捕获和比较#

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

例如,特定的部件在不同的平台或不同的部件样式中可能看起来不同,因此可能需要多次创建参考位图,并在未来维护Qt支持的平台的演变。这意味着对这些位图进行更改就意味着必须在每个受支持的平台上重新创建预期的位图,这需要访问每个平台。

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

尽可能使用程序性手段,例如验证对象和变量的属性,而不是捕获和比较位图。

改进测试输出#

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

测试警告#

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

当测试代码应该产生消息时,例如有关误用的警告,也很重要的是要测试它确实在这样使用时产生这些消息。您可以使用ignoreMessage() 测试来自被测试代码的预期消息,这由 qWarning()、qDebug()、qInfo() 以及相关功能产生。这将验证消息产生,并将其从测试运行输出中过滤出来。如果消息没有产生,测试将失败。

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

您的测试可以从 Qt 6.3 开始通过调用 failOnWarning() 来验证它们是否不会触发对 qWarning() 的调用。这需要测试的警告信息或者用于匹配警告的 QRegularExpression;如果生成了匹配的警告,将会报告并且导致测试失败。例如,应该完全不产生任何警告的测试可以这样做:QTest::failOnWarning(QRegularExpression(u".*"_s)),这将匹配任何警告。

您还可以设置环境变量 QT_FATAL_WARNINGS,以将警告视为致命错误。有关详情,请参阅 qWarning();这不是特定于自测试的。如果警告会在庞大的测试日志中丢失,偶尔使用此环境变量运行测试可以帮助您找到并消除任何出现的警告。

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

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

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

编写结构良好的诊断代码

如果测试失败,任何有用的诊断输出都应是正常测试输出的部分,而不是被注释掉,通过预处理器指令禁用,或者只在调试构建中启用。如果在持续集成期间测试失败,将所有相关的诊断输出放入 CI 日志中可以节省您大量时间,相比于启用诊断代码重新测试来说。特别是,如果失败是在您桌面没有的平台上的话。

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

编写可测试的代码

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

中断依赖关系

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

这项技术对数据驱动应用程序很有用。对于 GUI 应用程序,由于对象经常被创建和销毁,因此这种方法可能难以实现。为了验证依赖于抽象接口的类的正确行为,可以使用 mocking。例如,请参见 Googletest Mocking (gMock) 框架

将所有类编译到库中

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

在脚本中只一次列出源文件和头文件来构建静态库会更加容易。然后,main() 函数将与静态库链接来构建可执行文件,而测试将与静态库链接。

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

设置测试机器#

以下几节讨论了由测试机器设置引起的一些常见问题

这些问题通常可以通过合理使用虚拟化来解决。

屏幕保护程序#

屏幕保护程序可能会干扰一些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自动测试会导致它忘记其配置并返回到手动窗口定位。