使用 CMake 和 CTest 进行测试

测试是制作和维护鲁棒、有效的软件的关键工具。本章将研究 CMake 中用于支持软件测试的工具。我们首先简要讨论测试方法,然后讨论如何使用 CMake 将测试添加到软件项目。

软件包的测试可以采用多种形式。最基本的级别是冒烟测试,例如简单地验证软件是否编译。虽然这看起来像一个简单的测试,但具有广泛的可用平台和配置,冒烟测试捕捉到的问题多于任何其他类型的测试。另一种形式的冒烟测试是验证测试可以在不崩溃的情况下运行。这对于开发人员不想花时间创建更复杂的测试但愿意运行一些简单测试的情况很方便。大多数时候,这些简单的测试都可以是小型的示例程序。运行它们不仅可以验证构建是否成功,还可以验证是否可以加载任何必需的共享库(对于使用它们的项目),以及至少可以执行部分代码而不崩溃。

超越基本的冒烟测试,就会进行更具体的测试,例如回归、黑盒和白盒测试。每种方法都有其优势。回归测试验证了测试的结果不会随着时间或平台而改变。当频繁执行此操作时非常有用,因为它可以快速检查软件的行为和结果是否已更改。当回归测试失败时,快速查看最近的代码更改通常可以找出罪魁祸首。不幸的是,回归测试通常比其他测试需要更多的精力来创建。

白盒和黑盒测试是指针对代码单元(在不同集成级别)编写的测试,它们分别了解或不了解这些单元的实现方式。白盒测试旨在应力代码中潜在的故障点,知道该代码如何编写,因此也了解其缺陷。与回归测试一样,这可能需要大量的精力来创建良好的测试。黑盒测试通常对软件的实现一无所知或知之甚少,除了其公共 API。黑盒测试可以在不投入过多精力开发测试的情况下提供大量的代码覆盖率。对于 API 定义完善的面向对象软件库,这一点尤其如此。可以编写一个黑盒测试来遍历并调用软件中所有类上的许多典型方法。

我们要讨论的最后一种测试类型是软件标准合规性测试。虽然我们讨论的其他测试类型专注于确定代码是否正常工作,但合规性测试尝试确定代码是否遵循软件项目的编码标准。可能是检查以验证所有类是否已实现某些关键方法,或者所有函数是否具有公共前缀。此类测试的选项是无限的,并且有许多方法可以执行此类测试。可以使用软件分析工具或可能编写专门的测试程序(可能是 Python 脚本等)。需要认识到的重点是测试不一定要涉及运行软件的某些部分。测试可能在源代码本身上运行其他一些工具。

将测试支持集成到构建过程中有很多原因。首先,复杂的软件项目可能有多项配置或平台相关选项。构建系统知道哪些选项可以启用,然后可以为这些选项启用相应的测试。例如,可视化工具包 (VTK) 包括对一个称为 MPI 的并行处理库的支持。如果 VTK 是在启用 MPI 支持的情况下构建的,则会启用其他测试,这些测试使用 MPI 并验证 VTK 中的 MPI 特定代码按预期运行。其次,构建系统知道可执行文件将放置的位置,并且它具有用于查找其他必需的可执行文件(如 perl、python 等)的工具。第三个原因是因为在 UNIX Makefiles 中,Makefiles 中有一个测试目标,以便开发人员可以键入 make test 并运行测试。为此,构建系统必须对测试过程具有一些了解。

CMake 如何简化测试?

CMake 通过专门的测试命令和 CTest 可执行文件简化了软件的测试。首先,我们将讨论 CMake 中的关键测试命令。要将测试添加到基于 CMake 的项目,只需 include(CTest) 并使用 add_test 命令。 add_test 命令具有简单的语法,如下所示

add_test(NAME TestName COMMAND ExecutableToRun arg1 arg2 ...)

第一个参数只是测试的字符串名称。这是将由测试程序显示的名称。第二个参数是要运行的可执行文件。可执行文件可以作为项目的一部分构建,或者它可以是独立的可执行文件,如 python、perl 等。其余的参数将传递给正在运行的可执行文件。使用 add_test 命令的典型测试示例如下所示

add_executable(TestInstantiator TestInstantiator.cxx)
target_link_libraries(TestInstantiator vtkCommon)
add_test(NAME TestInstantiator
         COMMAND TestInstantiator)

通常,add_test 命令放置在包含测试的目录的 CMakeLists 文件中。对于大型项目,可能有多个包含 add_test 命令的 CMakeLists 文件。项目中出现 add_test 命令后,用户可以通过调用 Makefile 的“test”目标或 Visual Studio 或 Xcode 的 RUN_TESTS 目标来运行测试。以下是在 Linux 上使用 Makefile 生成器在 CMake 测试上运行测试的一个示例

$ make test
Running tests...
Test project
     Start 2: kwsys.testEncode
 1/20 Test  #2: kwsys.testEncode ..........   Passed    0.02 sec
     Start 3: kwsys.testTerminal
 2/20 Test  #3: kwsys.testTerminal ........   Passed    0.02 sec
     Start 4: kwsys.testAutoPtr
 3/20 Test  #4: kwsys.testAutoPtr .........   Passed    0.02 sec

其他测试属性

默认情况下,如果满足以下所有条件,则测试通过

  • 找到了测试可执行文件

  • 测试运行时未出现异常

  • 测试以返回代码 0 结束

也就是说,这些行为可以通过 set_property 命令进行修改

set_property(TEST test_name
             PROPERTY prop1 value1 value2 ...)

此命令将为指定的测试设置其他属性。示例属性有

ENVIRONMENT

指定在运行测试时应定义的环境变量。如果设置为形式为 MYVAR=value 的环境变量和值列表,则在测试运行时将定义这些环境变量。测试完成后,将还原环境到前一状态。

LABELS

指定与测试关联的文本标签列表。这些标签可用于根据测试内容将测试分组。例如,可以向所有执行 MPI 代码的测试中添加 MPI 标签。

WILL_FAIL

如果该选项设置为真,则当返回代码不是 0 时,测试将通过;如果返回代码是 0,则测试失败。这将颠倒通过要求的第三个条件。

PASS_REGULAR_EXPRESSION

如果指定此选项,则测试的输出会与提供的正则表达式进行检查(也可以传入一个正则表达式列表)。如果没有一个正则表达式匹配,则测试将失败。如果至少有一个匹配,则测试通过。

FAIL_REGULAR_EXPRESSION

如果指定此选项,则测试的输出会与提供的正则表达式进行检查(也可以传入一个正则表达式列表)。如果没有一个正则表达式匹配,则测试通过。如果至少有一个匹配,则测试失败。

如果同时指定了 PASS_REGULAR_EXPRESSIONFAIL_REGULAR_EXPRESSION,则 FAIL_REGULAR_EXPRESSION 优先级更高。以下示例说明了如何使用 PASS_REGULAR_EXPRESSIONFAIL_REGULAR_EXPRESSION

add_test (NAME outputTest COMMAND outputTest)

set (passRegex "^Test passed" "^All ok")
set (failRegex "Error" "Fail")

set_property (TEST outputTest
              PROPERTY PASS_REGULAR_EXPRESSION "${passRegex}")
set_property (TEST outputTest
              PROPERTY FAIL_REGULAR_EXPRESSION "${failRegex}")

使用 CTest 进行测试

当你从构建环境中运行测试时,实际发生的是构建环境将运行 CTestCTest 是一个随 CMake 附带的可执行文件;它处理运行该项目的测试。虽然 CTest 适用于 CMake,但你无需使用 CMake 即可使用 CTest。CTest 的主输入文件称为 CTestTestfile.cmake。此文件将在 CMake 处理的每个目录中创建(通常是具有 CMakeLists 文件的每个目录)。CTestTestfile.cmake 的语法类似于普通 CMake 语法,其中包含一组可用的命令。如果使用 CMake 生成测试文件,它们将列出需要处理的任何子目录以及任何 add_test 调用。子目录是由 add_subdirectory 命令添加的。然后,CTest 可以解析这些文件以确定要运行哪些测试。下面显示了此类文件的示例

# CMake generated Testfile for
# Source directory: C:/CMake
# Build directory: C:/CMakeBin
#
# This file includes the relevant testing commands required
# for testing this directory and lists subdirectories to
# be tested as well.

add_test (SystemInformationNew ...)

add_subdirectory (Source/kwsys)
add_subdirectory (Utilities/cmzlib)
...

当 CTest 解析 CTestTestfile.cmake 文件时,它将从它们中提取测试列表。将运行这些测试,对于每个测试,CTest 将显示测试的名称及其状态。考虑以下示例输出

$ ctest
Test project C:/CMake-build26
        Start 1: SystemInformationNew
 1/21 Test  #1: SystemInformationNew ......   Passed    5.78 sec
        Start 2: kwsys.testEncode
 2/21 Test  #2: kwsys.testEncode ..........   Passed    0.02 sec
        Start 3: kwsys.testTerminal
 3/21 Test  #3: kwsys.testTerminal ........   Passed    0.00 sec
        Start 4: kwsys.testAutoPtr
 4/21 Test  #4: kwsys.testAutoPtr .........   Passed    0.02 sec
        Start 5: kwsys.testHashSTL
 5/21 Test  #5: kwsys.testHashSTL .........   Passed    0.02 sec
...
100% tests passed, 0 tests failed out of 21
Total Test time (real) =  59.22 sec

CTest 在构建树内运行。它将运行当前目录中找到的所有测试,以及 CTestTestfile.cmake 中列出的任何子目录。对于运行的每个测试,CTest 将报告测试是否通过以及运行测试需要多长时间。

CTest 可执行文件包含一些便捷的命令行选项,以便于测试。我们先从命令行中通常会使用的选项开始。

-R <regex>            Run tests matching regular expression
-E <regex>            Exclude tests matching regular expression
-L <regex>            Run tests with labels matching the regex
-LE <regex>           Run tests with labels not matching regexp
-C <config>           Choose the configuration to test
-V,--verbose          Enable verbose output from tests.
-N,--show-only        Disable actual execution of tests.
-I [Start,End,Stride,test#,test#|Test file]
                      Run specific tests by range and number.
-H                                        Display a help message

可能用得最多的选项是 -R 选项。你可以用它指定一个正则表达式,只有名字符合该正则表达式的测试才会被执行。将 -R 选项与测试的名字(或名字的一部分)一起使用是执行单个测试的快捷方式。-E 选项很类似,不同之处在于它会排除所有符合该正则表达式的测试。-L-LE 选项类似于 -R-E,不同之处在于它们应用于使用 set_property 命令(如上文所述)设置的测试标签。-C 选项主要用于 IDE 编译,在同一个树中可能有多个配置,如版本和调试。跟随 -C 的参数决定要测试哪个配置。-V 参数在你试图确定某个测试失败的原因时很有用。使用 -V,CTest 会打印出用来执行测试的命令行,以及测试本身的任何输出。-V 选项可以与 CTest 的任何调用一起使用,以提供更加详细的输出。-N 选项在你想要查看 CTest 会在不实际执行的情况下执行哪些测试时很有用。

在对软件进行任何更改之前执行测试并确保所有测试都通过,是提高软件质量和开发过程的一种可靠方式。不幸的是,对于大型项目,测试数量和执行它们所需的时间可能是难以承受的。在这种情况下,可以使用 CTest 的 -I 选项。-I 选项允许你灵活地指定要执行的测试子集。例如,以下 CTest 调用将执行每 7 个测试中的一个。

ctest -I ,,7

虽然这不如执行每个测试那么好,但比不执行任何测试要好,对于很多开发人员来说,这可能是一个更实用的解决方案。注意,如果未指定开始和结束参数,如本例所示,则它们会默认为第一个和最后一个测试。在另一个示例中,假设你总是希望执行一些测试以及其他测试的子集。在这种情况下,你可以将这些测试显式添加到 -I 参数的末尾。例如

ctest -I ,,5,1,2,3,10

将执行测试 1、2、3 和 10,以及每 5 个测试中的一个。你可以在步长参数之后传递任意数量的测试编号。

使用 CTest 驱动复杂测试

有时,为了正确测试项目,需要在测试过程中实际编译代码。有几个原因导致这种情况。首先,如果测试程序作为主要项目的组成部分编译,则最终可能会占用大量构建时间。此外,如果测试构建失败,则主要构建也不应该失败。最后,IDE 项目很快就会变得过大,无法加载和使用。CTest 命令支持一组命令行选项,允许将其用作要运行的测试可执行文件。当作为测试可执行文件使用时,CTest 可以运行 CMake、运行编译步骤,最后运行已编译的测试。现在,我们将查看支持构建和运行测试的 CTest 命令行选项。

--build-and-test  src_directory build_directory
Run cmake on the given source directory using the specified build directory.
--test-command        Name of the program to run.
--build-target        Specify a specific target to build.
--build-nocmake       Run the build without running cmake first.
--build-run-dir       Specify directory to run programs from.
--build-two-config    Run cmake twice before the build.
--build-exe-dir       Specify the directory for the executable.
--build-generator     Specify the generator to use.
--build-project       Specify the name of the project to build.
--build-makeprogram   Specify the make program to use.
--build-noclean       Skip the make clean step.
--build-options       Add extra options to the build step.

例如,考虑以下 add_test 命令,该命令取自 CMake 本身的 CMakeLists.txt 文件。它展示了如何使用 CTest 编译和运行测试。

add_test(simple ${CMAKE_CTEST_COMMAND}
   --build-and-test "${CMAKE_SOURCE_DIR}/Tests/Simple"
                    "${CMAKE_BINARY_DIR}/Tests/Simple"
   --build-generator ${CMAKE_GENERATOR}
   --build-makeprogram ${CMAKE_MAKE_PROGRAM}
   --build-project Simple
   --test-command simple)

在这个示例中,add_test 命令首先传递测试的名称“simple”。在测试的名称后,指定要运行的命令。在本例中,要运行的测试命令是 CTest。CTest 命令通过 CMAKE_CTEST_COMMAND 变量进行引用。此变量始终由 CMake 设置为来自用于构建项目的 CMake 安装的 CTest 命令。接下来,指定源目录和二进制目录。传递给 CTest 的下一个选项是 --build-generator--build-makeprogram 选项。它们使用 CMake 变量 CMAKE_MAKE_PROGRAMCMAKE_GENERATOR 来指定。CMake 定义 CMAKE_MAKE_PROGRAMCMAKE_GENERATOR。这是一个重要的步骤,因为它可以确保用于构建测试的生成器与用于构建项目本身的生成器相同。将 --build-project 选项传递给 Simple,它对应于 Simple 测试中使用的 project 命令。最后一个参数是 --test-command,它告诉 CTest 一旦构建成功就运行的命令,并且应该是测试将编译的可执行文件的名称。

处理大量的测试

当一个单一项目中存在大量测试时,针对每项测试准备独立的可执行文件会很繁琐。也就是说,项目开发人员不必使用复杂的参数解析创建测试。这就是 CMake 为创建测试驱动程序提供便捷命令的原因。此命令称为 create_test_sourcelist。测试驱动程序是一个程序,它链接多个小型测试到一个可执行文件中。在使用大库构建静态可执行文件以缩小所需总大小时,这很有用。create_test_sourcelist 的签名如下

create_test_sourcelist (SourceListName
                        DriverName
                        test1 test2 test3
                        EXTRA_INCLUDE include.h
                        FUNCTION function
                        )

第一个参数是变量,它包含必须编译以制作测试可执行文件的源文件列表。DriverName 是测试驱动程序程序的名称(例如,生成的可执行文件的名称)。其余参数包含测试源文件列表。每个测试源文件中应当包含一个函数,该函数的名称与没有扩展名的文件相同 (foo.cxx 应当包含 int foo(argc, argv);)。生成的可执行文件应当能够通过在命令行中按名称调用每个测试。EXTRA_INCLUDEFUNCTION 参数支持对测试驱动程序程序进行其他自定义。考虑以下 CMakeLists 文件片段,了解如何使用此命令

# create the testing file and list of tests
set (TestToRun
  ObjectFactory.cxx
  otherArrays.cxx
  otherEmptyCell.cxx
  TestSmartPointer.cxx
  SystemInformation.cxx
  ...
)
create_test_sourcelist (Tests CommonCxxTests.cxx ${TestToRun})

# add the executable
add_executable (CommonCxxTests ${Tests})

# Add all the ADD_TEST for each test
foreach (test ${TestsToRun})
  get_filename_component (TName ${test} NAME_WE)
  add_test (NAME ${TName} COMMAND CommonCxxTests ${TName})
endforeach ()

调用create_test_sourcelist命令以创建测试驱动程序。在这种情况下,它使用其余参数确定其内容,创建并写入CommonCxxTests.cxx到项目的二进制树中。接下来,add_executable命令用于将该可执行文件添加到版本中。然后创建名为TestsToRun的新变量,其初始值为测试驱动程序所需源。然后,使用foreach命令循环处理剩余的源。对于每个源,其不带文件扩展名的名称将被提取并放入变量TName中,然后为TName添加新的测试。最终结果是,对于create_test_sourcelist中的每个源文件,都会使用测试的名称调用add_test命令。随着越来越多的测试添加到create_test_sourcelist命令中,foreach循环将自动针对每个循环调用add_test

管理测试数据

除了处理大量的测试,CMake 还包含一个用于管理测试数据的系统。它封装在ExternalDataCMake 模块中,按需下载大数据、保留版本信息,并允许分布式存储。

ExternalData 的设计遵循基于散列的文件标识符和对象存储的分布式版本控制系统,但它也利用了基于依赖项的构建系统的存在。下图说明了这种方法。源代码树包含轻量级“内容链接”,通过内容的散列值来引用远程存储中的数据。ExternalData 模块生成构建规则,以将数据下载到本地存储,并通过符号链接(在 Windows 系统中为副本)在构建树中引用它们。

../_images/ExternalDatamoduleflowchart.png

图 1:ExternalData 模块流程图

内容链接是一个小型纯文本文件,其中包含真实数据的散列值。它的名称与其数据文件名称相同,并带有附加的扩展名以标识散列算法,例如 img.png.md5。无论真实数据的规模大小,内容链接在源代码树中始终占用相同的(小)空间。CMakeLists.txt CMake 配置文件使用 ExternalData 模块 API 中 DATA{} 语法引用数据。例如,DATA{img.png} 告诉 ExternalData 模块在构建树中提供 img.png,即使在源代码树中只出现 img.png.md5 内容链接也是如此。

ExternalData 模块实现了一个灵活的系统,以防止重复获取和存储内容。对象从一系列(可能是冗余的)本地和远程位置中检索,这些位置以一系列“URL 模板”的形式在 ExternalData CMake 配置中指定。远程存储系统的唯一要求是能够从通过指定散列算法和散列值定位内容的 URL 获取数据。例如,本地或网络文件系统、Apache FTP 服务器或 Midas 服务器都具有此功能。每个 URL 模板都有 %(algo) 和 %(hash) 占位符, 供 ExternalData 用内容链接中的值替换。

可以通过设置 CMake 构建配置变量 ExternalData_OBJECT_STORES 将下载的内容缓存到持久性本地对象存储中,以便在构建树之间共享。这有助于对多个构建树中的内容进行去重。它还解决了一个在回归测试上下文中重要的实际问题;当很多计算机同时启动一个夜间仪表板构建时,它们可以使用它们本地的对象存储,而不是超载数据服务器并使网络流量泛滥。

检索已与基于依赖性的构建系统集成,这样资源只会在需要时才获取。例如,如果系统用于检索测试数据且 BUILD_TESTING 关闭,则数据不会被不必要地检索。当源代码树更新且内容链接更改时,构建系统会根据需要获取新的数据。

由于离开源代码树的所有引用都经过哈希处理,因而它们不依赖于任何外部状态。远程对象存储和本地对象存储可以重新定位,而不会使旧版源代码中的内容链接失效。源代码树中的内容链接可以在不修改对象存储的情况下重新定位或重命名。源代码树中可以存在重复的内容链接,但是下载只发生一次。具有项目历史记录中相同源代码树文件名的多版本数据在对象存储中是惟一标识的。

基于哈希的系统允许使用不受信任的连接来访问远程资源,因为下载的内容在检索后会得到验证。URL 模板列表的配置通过允许多个冗余远程存储资源来提高稳健性。随着需要,存储资源也可以随着时间推移而改变。如果项目远程存储随着时间推移而移动,则始终可以通过调整为构建树配置的 URL 模板或手动填充本地对象存储来构建旧版源代码。

ExternalData 模块的一个简单应用如下所示

include(ExternalData)
set(midas "http://midas.kitware.com/MyProject")


# Add standard remote object stores to user's
# configuration.
list(APPEND ExternalData_URL_TEMPLATES
 "${midas}?algorithm=%(algo)&hash=%(hash)"
 "ftp://myproject.org/files/%(algo)/%(hash)"
 )
# Add a test referencing data.
ExternalData_Add_Test(MyProjectData
 NAME SmoothingTest
 COMMAND SmoothingExe DATA{Input/Image.png}
                    SmoothedImage.png
 )
# Add a build target to populate the real data.
ExternalData_Add_Target(MyProjectData)

ExternalData_Add_Test 函数是对 CMake 的 add_test 命令的一个包装器。探测源代码树是否存在包含数据 MD5 哈希的 Input/Image.png.md5 内容链接。在检查了本地对象存储后,将按顺序使用数据的哈希向 ExternalData_URL_TEMPLATES 列表中的每个 URL 发起请求。一旦找到,将在构建树中创建符号链接。DATA{Input/Image.png} 路径将扩展到测试命令行中的构建树路径。在构建 MyProjectData 目标时会检索数据。