面向 C++ 的现代 CMake 教程(三)(2)https://developer.aliyun.com/article/1525577
自动初始化 Git 子模块
为用户提供整洁的体验并不总是对开发者来说是痛苦的。如果一个库提供了一个包配置文件,我们只需让find_package()
在安装的库中搜索它。正如承诺的那样,CMake 首先检查是否有合适的 find 模块,如果没有,它将寻找配置文件。
我们已经知道,如果_FOUND
变量被设置为1
,则库被找到,我们可以直接使用它。我们也可以在库未找到时采取行动,并提供方便的解决方法来默默改善用户的体验:退回到获取子模块并从源代码构建库。突然之间,一个新克隆的仓库不自动下载和初始化嵌套子模块的事实看起来并没有那么糟糕,不是吗?
让我们将上一个示例中的代码进行扩展:
chapter07/06-git-submodule-auto/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0) project(GitSubmoduleAuto CXX) add_executable(welcome main.cpp) configure_file(config.yaml config.yaml COPYONLY) find_package(yaml-cpp QUIET) if (NOT yaml-cpp_FOUND) message("yaml-cpp not found, initializing git submodule") execute_process( COMMAND git submodule update --init -- extern/yaml-cpp WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} ) add_subdirectory(extern/yaml-cpp) endif() target_link_libraries(welcome PRIVATE yaml-cpp)
我们添加了高亮显示的行:
- 我们将尝试悄悄地查找 yaml-cpp 并使用它。
- 如果它不存在,我们将打印一个简短的诊断信息,并使用
execute_process()
命令来初始化子模块。这实际上是从引用仓库中克隆文件。 - 最后,我们将
add_subdirectory()
用于从源代码构建依赖项。
简短而精炼。这也适用于未使用 CMake 构建的库——我们可以遵循 git submodule
的示例,再次调用 execute_process()
以同样的方式启动任何外部构建工具。
可悲的是,如果您的公司使用 Concurrent Versions System (CVS)、Subversion (SVN)、Mercurial 或任何其他方法向用户提供代码,这种方法就会崩溃。如果您不能依赖 Git submodules,替代方案是什么?
为不使用 Git 的项目克隆依赖项
如果您使用另一个 VCS 或者提供源代码的存档,您可能会在依赖 Git submodules 将外部依赖项引入您的仓库时遇到困难。很有可能是构建您代码的环境安装了 Git 并能执行 git clone
命令。
让我们看看我们应该如何进行:
chapter07/07-git-clone/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0) project(GitClone CXX) add_executable(welcome main.cpp) configure_file(config.yaml config.yaml COPYONLY) find_package(yaml-cpp QUIET) if (NOT yaml-cpp_FOUND) message("yaml-cpp not found, cloning git repository") find_package(Git) if (NOT Git_FOUND) message(FATAL_ERROR "Git not found, can't initialize!") endif () execute_process( COMMAND ${GIT_EXECUTABLE} clone https://github.com/jbeder/yaml-cpp.git WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/extern ) add_subdirectory(extern/yaml-cpp) endif() target_link_libraries(welcome PRIVATE yaml-cpp)
再次,加粗的行是我们 YAML 项目中的新部分。发生了以下情况:
- 首先,我们通过
FindGit
查找模块检查 Git 是否可用。 - 如果不可以使用,我们就束手无策了。我们将发出
FATAL_ERROR
,并希望用户知道接下来该做什么。 - 否则,我们将使用
FindGit
查找模块设置的GIT_EXECUTABLE
变量调用execute_process()
并克隆我们感兴趣的仓库。
Git 对于有一定经验的开发者来说尤其有吸引力。它可能适合一个不包含对相同仓库的嵌套引用的小项目。然而,如果确实如此,您可能会发现您可能需要多次克隆和构建同一个项目。如果依赖项目根本不使用 Git,您将需要另一个解决方案。
使用 ExternalProject 和 FetchContent 模块
在线 CMake 参考书籍将建议使用 ExternalProject
和 FetchContent
模块来处理更复杂项目中依赖项的管理。这实际上是个好建议,但它通常在没有适当上下文的情况下给出。突然之间,我们面临了许多问题。这些模块是做什么的?何时选择一个而不是另一个?它们究竟是如何工作的,以及它们是如何相互作用的?一些答案比其他的更难找到,令人惊讶的是,CMake 的文档没有为该主题提供一个平滑的介绍。不用担心——我们在这里会处理。
外部项目
CMake 3.0.0 引入了一个名为 ExternalProject
的模块。正如您所猜测的,它的目的是为了添加对在线仓库中可用的外部项目的支持。多年来,该模块逐渐扩展以满足不同的需求,最终变得相当复杂的命令——ExternalProject_Add()
。我是说复杂——它接受超过 85 个不同的选项。不足为奇,因为它提供了一组令人印象深刻的特性:
- 为外部项目管理目录结构
- 从 URL 下载源代码(如有需要,从归档中提取)
- 支持 Git、Subversion、Mercurial 和 CVS 仓库
- 如有需要,获取更新
- 使用 CMake、Make 配置和构建项目,或使用用户指定的工具
- 执行安装和运行测试
- 记录到文件
- 从终端请求用户输入
- 依赖于其他目标
- 向构建过程中添加自定义命令/步骤
ExternalProject
模块在构建阶段填充依赖项。对于通过 ExternalProject_Add()
添加的每个外部项目,CMake 将执行以下步骤:
mkdir
– 为外部项目创建子目录download
– 从仓库或 URL 获取项目文件update
– 在支持差量更新的下载方法中重新运行时更新文件patch
– 可选执行一个补丁命令,用于修改下载文件以满足项目需求configure
– 为 CMake 项目执行配置阶段,或为非 CMake 依赖手动指定命令build
– 为 CMake 项目执行构建阶段,对于其他依赖项,执行make
命令install
– 安装 CMake 项目,对于其他依赖项,执行make install
命令test
– 如果定义了任何TEST_...
选项,则执行依赖项的测试
步骤按照前面的确切顺序进行,除了 test
步骤,该步骤可以通过 TEST_BEFORE_INSTALL
或 TEST_AFTER_INSTALL
选项在 install
步骤之前或之后可选地启用。
下载步骤选项
我们主要关注控制 download
步骤或 CMake 如何获取依赖项的选项。首先,我们可能选择不使用 CMake 内置的此方法,而是提供一个自定义命令(在此处支持生成器表达式):
DOWNLOAD_COMMAND <cmd>...
这样做后,我们告诉 CMake 忽略此步骤的所有其他选项,只需执行一个特定于系统的命令。空字符串也被接受,用于禁用此步骤。
从 URL 下载依赖项
我们可以提供一系列 URL,按顺序扫描直到下载成功。CMake 将识别下载文件是否为归档文件,并默认进行解压:
URL <url1> [<url2>...]
其他选项允许我们进一步自定义此方法的行为:
URL_HASH =
– 检查通过生成的下载文件的校验和是否与提供的
匹配。建议确保下载的完整性。支持的算法包括
MD5
、SHA1
、SHA224
、SHA256
、SHA384
、SHA512
、SHA3_224
、SHA3_256
、SHA3_384
和SHA3_512
,这些算法由string()
命令定义。对于MD5
,我们可以使用简写选项URL_MD5
。DOWNLOAD_NO_EXTRACT
– 显式禁用下载后的提取。我们可以通过访问变量,在后续步骤中使用下载文件的文件名。
DOWNLOAD_NO_PROGRESS
– 不记录下载进度。TIMEOUT
和INACTIVITY_TIMEOUT
– 在固定总时间或无活动期后终止下载的超时时间。HTTP_USERNAME
和HTTP_PASSWORD
– 提供 HTTP 认证值的选项。确保在项目中避免硬编码任何凭据。HTTP_HEADER […]
– 发送额外的 HTTP 头。用这个来访问 AWS 中的内容或传递一些自定义令牌。TLS_VERIFY
– 验证 SSL 证书。如果没有设置,CMake 将从CMAKE_TLS_VERIFY
变量中读取这个设置,默认为false
。跳过 TLS 验证是一种不安全、糟糕的做法,应该避免,尤其是在生产环境中。TLS_CAINFO
– 如果你的公司发行自签名 SSL 证书,这个选项很有用。这个选项提供了一个权威文件的路径;如果没有指定,CMake 将从CMAKE_TLS_CAINFO
变量中读取这个设置。
从 Git 下载依赖项
要从 Git 下载依赖项,你需要确保主机安装了 Git 1.6.5 或更高版本。以下选项是克隆 Git 的必要条件:
GIT_REPOSITORY <url> GIT_TAG <tag>
和
都应该符合
git
命令能理解的格式。此外,建议使用特定的 git 哈希,以确保生成的二进制文件可以追溯到特定的提交,并且不会执行不必要的git fetch
。如果你坚持使用分支,使用如origin/main
的远程名称。这保证了本地克隆的正确同步。
其他选项如下:
GIT_REMOTE_NAME
– 远程名称,默认为origin
。GIT_SUBMODULES ...
– 指定应该更新的子模块。从 3.16 起,这个值默认为无(之前,所有子模块都被更新)。GIT_SUBMODULES_RECURSE 1
– 启用子模块的递归更新。GIT_SHALLOW 1
– 执行浅克隆(不下载历史提交)。这个选项推荐用于性能。TLS_VERIFY
– 这个选项在从 URL 下载依赖项部分解释过。它也适用于 Git,并且为了安全起见应该启用。
从 Subversion 下载依赖项
要从 Subversion 下载,我们应该指定以下选项:
SVN_REPOSITORY <url> SVN_REVISION -r<rev>
此外,我们还可以提供以下内容:
SVN_USERNAME
和SVN_PASSWORD
– 用于检出和更新的凭据。像往常一样,避免在项目中硬编码它们。SVN_TRUST_CERT
– 跳过对 Subversion 服务器证书的验证。只有在你信任网络路径到服务器及其完整性时才使用这个选项。默认是禁用的。
从 Mercurial 下载依赖项
这种模式非常直接。我们需要提供两个选项,就完成了:
HG_REPOSITORY <url> HG_TAG <tag>
从 CVS 下载依赖项
要从 CVS 检出模块,我们需要提供这三个选项:
CVS_REPOSITORY <cvsroot> CVS_MODULE <module> CVS_TAG <tag>
更新步骤选项
默认情况下,update
步骤如果支持更新,将会重新下载外部项目的文件。我们可以用两种方式覆盖这个行为:
- 提供一个自定义命令,在更新期间执行
UPDATE_COMMAND
。 - 完全禁用
update
步骤(允许在断开网络的情况下构建)–UPDATE_DISCONNECTED
。请注意,第一次构建期间的download
步骤仍然会发生。
修补步骤选项
Patch
是一个可选步骤,在源代码获取后执行。要启用它,我们需要指定我们要执行的确切命令:
PATCH_COMMAND <cmd>...
CMake 文档警告说,一些修补程序可能比其他修补程序“更粘”。例如,在 Git 中,更改的文件在更新期间不会恢复到原始状态,我们需要小心避免错误地再次修补文件。理想情况下,patch
命令应该是真正健壮且幂等的。
重要提示
前面提到的选项列表只包含最常用的条目。确保参考官方文档以获取更多详细信息和描述其他步骤的选项:cmake.org/cmake/help/latest/module/ExternalProject.html
。
在实际中使用 ExternalProject
依赖项在构建阶段被填充非常重要,它有两个效果——项目的命名空间完全分离,任何外部项目定义的目标在主项目中不可见。后者尤其痛苦,因为我们在使用find_package()
命令后不能以同样的方式使用target_link_libraries()
。这是因为两个配置阶段的分离。主项目必须完成配置阶段并开始构建阶段,然后依赖项才能下载并配置。这是一个问题,但我们将学习如何处理第二个。现在,让我们看看ExternalProject_Add()
如何与我们在 previous examples 中使用的 yaml-cpp 库工作:
chapter07/08-external-project-git/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0) project(ExternalProjectGit CXX) add_executable(welcome main.cpp) configure_file(config.yaml config.yaml COPYONLY) include(ExternalProject) ExternalProject_Add(external-yaml-cpp GIT_REPOSITORY https://github.com/jbeder/yaml-cpp.git GIT_TAG yaml-cpp-0.6.3 ) target_link_libraries(welcome PRIVATE yaml-cpp)
构建该项目采取以下步骤:
- 我们包含了
ExternalProject
模块以访问其功能。 - 我们调用了
FindExternalProject_Add()
命令,该命令将构建阶段任务为下载必要文件,并在我们的系统中配置、构建和安装依赖项。
我们需要小心这里,并理解这个例子之所以能工作,是因为 yaml-cpp 库在其CMakeLists.txt
中定义了一个安装阶段。这个阶段将库文件复制到系统中的标准位置。target_link_libraries()
命令中的yaml-cpp
参数被 CMake 解释为直接传递给链接器的参数——-lyaml-cpp
。这个行为与之前的例子不同,在那里我们明确定义了yaml-cpp
目标。如果库不提供安装阶段(或者二进制版本的名字不匹配),链接器将抛出错误。
在此之际,我们应该更深入地探讨每个阶段的配置,并解释如何使用不同的下载方法。我们将在FetchContent部分讨论这些问题,但首先,让我们回到讨论ExternalProject
导致的依赖项晚获取问题。我们不能在外部项目被获取的时候使用它们的目标,因为编译阶段已经结束了。CMake 将通过将其标记为特殊的UTILITY
类型来显式保护使用FindExternalProject_Add()
创建的目标。当你错误地尝试在主项目中使用这样一个目标(也许是为了链接它)时,CMake 将抛出一个错误:
Target "external-yaml-cpp-build" of type UTILITY may not be linked into another target.
为了绕过这个限制,技术上我们可以创建另一个目标,一个IMPORTED
库,然后使用它(就像我们在这个章节前面用FindPQXX.cmake
做的那样)。但这实在太麻烦了。更糟糕的是,CMake 实际上理解外部 CMake 项目创建的目标(因为它在构建它们)。在主项目中重复这些声明不会是一个非常 DRY 的做法。
另一个可能的解决方案是将整个依赖项的获取和构建提取到一个独立的子项目中,并在配置阶段构建该子项目。要实现这一点,我们需要用execute_process()
启动 CMake 的另一个实例。通过一些技巧和add_subdirectory()
命令,我们随后可以将这个子项目的列表文件和二进制文件合并到主项目中。这种方法(有时被称为超级构建)过时且不必要的复杂。在这里我不详细说明,因为对初学者来说没有太大用处。如果你好奇,可以阅读 Craig Scott 这篇很好的文章:crascit.com/2015/07/25/cmake-gtest/
。
总之,当项目间存在命名空间冲突时,ExternalProject
可以帮我们摆脱困境,但在其他所有情况下,FetchContent
都远远优于它。让我们来找出为什么。
FetchContent
现在,建议使用FetchContent
模块来导入外部项目。这个模块自 CMake 3.11 版本以来一直可用,但我们建议至少使用 3.14 版本才能有效地与之工作。
本质上,它是一个高级别的ExternalProject
包装器,提供类似的功能和更多功能。关键区别在于执行阶段——与ExternalProject
不同,FetchContent
在配置阶段填充依赖项,将外部项目声明的所有目标带到主项目的范围内。这样,我们可以像定义自己的目标一样精确地使用它们。
使用FetchContent
模块需要三个步骤:
- 将模块包含在你的项目中,使用
include(FetchModule)
。 - 使用
FetchContent_Declare()
命令配置依赖项。 - 使用
FetchContent_MakeAvailable()
命令填充依赖项——下载、构建、安装,并将其列表文件添加到主项目中并解析。
你可能会问自己为什么Declare
和MakeAvailable
命令被分开。这是为了在层次化项目中启用配置覆盖。这是一个场景——一个父项目依赖于A和B外部库。A库也依赖于B,但A库的作者仍在使用与父项目不同的旧版本(图 7.1):
图 7.1 —— 层次化项目
而且,对MakeAvailable
的依赖既不能配置也不能填充依赖,因为要覆盖A库中的版本,父项目将被迫无论在A库中最终是否需要,都要填充依赖。
由于有了单独的配置步骤,我们能够为父项目指定一个版本,并在所有子项目和依赖项中使用它:
FetchContent_Declare( googletest GIT_REPOSITORY https://github.com/google/googletest.git # release-1.11.0 GIT_TAG e2239ee6043f73722e7aa812a459f54a28552929 )
任何后续调用FetchContent_Declare()
,以googletest
作为第一个参数,都将被忽略,以允许层次结构最高的项目决定如何处理这个依赖。
FetchContent_Declare()
命令的签名与ExternalProject_Add()
完全相同:
FetchContent_Declare(<depName> <contentOptions>...)
这并非巧合——这些参数会被 CMake 存储,直到调用FetchContent_MakeAvailable()
并且需要填充时才会传递。然后,内部会将这些参数传递给ExternalProject_Add()
命令。然而,并非所有的选项都是允许的。我们可以指定download
、update
或patch
步骤的任何选项,但不能是configure
、build
、install
或test
步骤。
当配置就绪后,我们会像这样填充依赖项:
FetchContent_MakeAvailable(<depName>)
这将下载文件并读取目标到项目中,但在这次调用中实际发生了什么?FetchContent_MakeAvailable()
是在 CMake 3.14 中添加的,以将最常用的场景封装在一个命令中。在图 7.2中,你可以看到这个过程的详细信息:
- 调用
FetchContent_GetProperties()
,从全局变量将FetchContent_Declare()
设置的配置从全局变量传递到局部变量。 - 检查(不区分大小写)是否已经为具有此名称的依赖项进行了填充,以避免重复下载。如果是,就在这里停止。
- 调用
FetchContent_Populate()
。它会配置包装的ExternalProject
模块,通过传递我们设置的(但跳过禁用的)选项并下载依赖项。它还会设置一些变量,以防止后续调用重新下载,并将必要的路径传递给下一个命令。 - 最后,
add_subdirectory()
带着源和构建树作为参数调用,告诉父项目列表文件在哪里以及构建工件应放在哪里。
通过调用add_subdirectory()
,CMake 实际上执行了获取项目的配置阶段,并在当前作用域中检索那里定义的任何目标。多么方便!
](https://gitee.com/OpenDocCN/freelearn-c-cpp-pt2-zh/raw/master/docs/mdn-cmk-cpp/img/Figure_7.2_B17205.jpg)
图 7.2 – FetchContent_MakeAvailable()如何包装对 ExternalProject 的调用
显然,我们可能遇到两个无关项目声明具有相同名称的目标的情况。这是一个只能通过回退到ExternalProject
或其他方法来解决的问题。幸运的是,这种情况并不经常发生。
为了使这个解释完整,它必须与一个实际例子相补充。让我们看看当我们将FetchContent
更改为FetchContent
时,前一部分的列表文件是如何变化的:
chapter07/09-fetch-content/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0) project(ExternalProjectGit CXX) add_executable(welcome main.cpp) configure_file(config.yaml config.yaml COPYONLY) include(FetchContent) FetchContent_Declare(external-yaml-cpp GIT_REPOSITORY https://github.com/jbeder/yaml-cpp.git GIT_TAG yaml-cpp-0.6.3 ) FetchContent_MakeAvailable(external-yaml-cpp) target_link_libraries(welcome PRIVATE yaml-cpp)
ExternalProject_Add
直接被FetchContent_Declare
替换,我们还添加了另一个命令——FetchContent_MakeAvailable
。代码的变化微乎其微,但实际的区别却很大!我们可以明确地访问由 yaml-cpp 库创建的目标。为了证明这一点,我们将使用CMakePrintHelpers
帮助模块,并向之前的文件添加这些行:
include(CMakePrintHelpers) cmake_print_properties(TARGETS yaml-cpp PROPERTIES TYPE SOURCE_DIR)
现在,配置阶段将打印以下输出:
Properties for TARGET yaml-cpp: yaml-cpp.TYPE = "STATIC_LIBRARY" yaml-cpp.SOURCE_DIR = "/tmp/b/_deps/external-yaml-cpp-src"
目标存在;它是一个静态库,其源代码目录位于构建树内部。使用相同的助手在ExternalProject
示例中调试目标简单地返回:
No such TARGET "yaml-cpp" !
在配置阶段目标没有被识别。这就是为什么FetchContent
要好得多,并且应该尽可能地在任何地方使用。
总结
当我们使用现代的、得到良好支持的项目时,管理依赖关系并不复杂。在大多数情况下,我们只需依赖系统中有可用的库,如果没有就回退到FetchContent
。如果依赖项相对较小且易于构建,这种方法是合适的。
对于一些非常大的库(如 Qt),从源代码构建会花费大量时间。为了在这些情况下提供自动依赖解析,我们不得不求助于提供与用户环境匹配的库编译版本的包管理器。像 Apt 或 Conan 这样的外部工具超出了本书的范围,因为它们要么太依赖于系统,要么太复杂。
好消息是,大多数用户知道如何安装您的项目可能需要的依赖项,只要您为他们提供清晰的指示即可。从这一章,您已经知道如何使用 CMake 的 find-modules 检测系统中的包,以及库捆绑的配置文件。
我们还了解到,如果一个库有点旧,不支持 CMake,但 distribution 中包含.pc
文件,我们可以依靠 PkgConfig 工具和随 CMake 捆绑的FindPkgConfig
查找模块。我们可以期待,当使用上述任一方法找到库时,CMake 会自动创建构建目标,这是方便且优雅的。我们还讨论了依赖 Git 及其子模块和克隆整个仓库的方法。当其他方法不适用或实施起来不切实际时,这种方法非常有用。
最后,我们探讨了ExternalProject
模块及其功能和限制。我们研究了FetchContent
如何扩展ExternalProject
模块,它与模块有哪些共同之处,与模块有何不同,以及为什么FetchContent
更优越。
现在你已准备好在你的项目中使用常规库;然而,我们还应该覆盖另一种类型的依赖——测试框架。每个认真的项目都需要 Correctness testing,而 CMake 是一个很好的工具来自动化这一过程。我们将在下一章学习如何操作。
深入阅读
关于本章涵盖的主题的更多信息,你可以参考以下内容:
- CMake 文档 - 使用依赖关系指南:
cmake.org/cmake/help/latest/guide/using-dependencies/index.html
- 教程:使用 CMake 和 Git 进行 C++的简易依赖管理:
www.foonathan.net/2016/07/cmake-dependency-handling/
- CMake 和用于依赖项目的 git-submodule 使用:
stackoverflow.com/questions/43761594/
- 利用 PkgConfig 进行依赖共享:
gitlab.kitware.com/cmake/community/-/wikis/doc/tutorials/How-To-Find-Libraries#piggybacking-on-pkg-config
- 关于在 findmodules 中导入库的
UNKNOWN
类型的讨论:gitlab.kitware.com/cmake/cmake/-/issues/19564
- 什么是 Git 子模块:
git-scm.com/book/en/v2/Git-Tools-Submodules
- 如何使用 ExternalProject:
www.jwlawson.co.uk/interest/2020/02/23/cmake-external-project.html
- CMake FetchContent 与 ExternalProject 的比较:
www.scivision.dev/cmake-fetchcontent-vs-external-project/
- 使用 CMake 与外部项目:
www.saoe.net/blog/using-cmake-with-external-projects/
第三部分:使用 CMake 自动化
完成前面的章节后,你已经变成了一个能够使用 CMake 构建各种项目的自给自足的构建工程师。成为 CMake 专家的最后一个步骤是学习如何引入和自动化各种质量检查,并为协作工作和发布做好准备。在大型公司内部开发的高质量项目往往共享同样的理念:自动化耗竭心灵能量的重复性任务,以便重要决策得以实施。
为了实现这一点,我们利用 CMake 生态系统的力量,添加构建过程中进行的所有测试:代码风格检查、单元测试以及我们解决方案的静态和动态分析。我们还将通过使用工具来简化文档过程,生成漂亮的网页,并且我们将打包和安装我们的项目,使其消费变得轻而易举,无论是对于其他开发者还是最终用户。
作为总结,我们将把我们所学的所有内容整合成一个连贯的单元:一个能够经受住时间考验的专业项目。
本节包括以下章节:
- 第八章,测试框架
- 第九章,程序分析工具
- 第十章,生成文档
- 第十一章,安装和打包
- 第十二章,创建你的专业项目
第八章:测试框架
有经验的专家知道测试必须自动化。有人向他们解释了这一点,或者他们通过艰苦的方式学到了。这种做法对于没有经验的程序员来说并不那么明显:它似乎是不必要的,额外的工作,并不会带来太多价值。难怪:当某人刚开始编写代码时,他们会避免编写复杂的解决方案和为庞大的代码库做出贡献。他们很可能是他们宠物项目的唯一开发者。这些早期的项目通常需要不到几个月就能完成,所以几乎没有任何机会看到代码在更长时间内是如何变质的。
所有这些因素共同构成了编写测试是浪费时间和精力的观念。编程实习生可能会对自己说,每次执行“构建-运行”流程时,他们实际上确实测试了他们的代码。毕竟,他们已经手动确认了他们的代码可以工作,并且做到了预期。现在是时候转向下一个任务了,对吧?
自动化测试确保新的更改不会意外地破坏我们的程序。在本章中,我们将学习测试的重要性以及如何使用与 CMake 捆绑的 CTest 工具来协调测试执行。CTest 能够查询可用的测试、过滤执行、洗牌、重复和限制时间。我们将探讨如何使用这些特性、控制 CTest 的输出以及处理测试失败。
接下来,我们将调整我们项目的结构以支持测试,并创建我们自己的测试运行器。在讨论基本原理之后,我们将继续添加流行的测试框架:Catch2 和 GoogleTest 及其模拟库。最后,我们将介绍使用 LCOV 进行详细测试覆盖率报告。
在本章中,我们将涵盖以下主要主题:
- 自动化测试为什么值得麻烦?
- 使用 CTest 在 CMake 中标准化测试
- 为 CTest 创建最基本的单元测试
- 单元测试框架
- 生成测试覆盖率报告
技术要求
您可以在 GitHub 上的以下链接找到本章中存在的代码文件:
github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter08
为了构建本书中提供的示例,请始终使用推荐的命令:
cmake -B <build tree> -S <source tree> cmake --build <build tree>
请确保将占位符和
替换为适当的路径。作为提醒:build tree是目标/输出目录的路径,source tree是您的源代码所在的路径。
自动化测试为什么值得麻烦?
想象一个工厂生产线,有一个机器在钢板上打孔。这些孔必须具有特定的尺寸和形状,以便它们可以容纳将最终产品固定的螺栓。这样一个工厂线的设计者会设置机器,测试孔是否正确,然后继续。迟早,一些东西会改变:工厂会使用不同、更厚的钢材;工人可能会意外地改变孔的大小;或者,简单地说,需要打更多的孔,机器必须升级。一个聪明的设计师会在生产线的某些点上设置质量控制检查,以确保产品遵循规格并保持其关键特性。孔必须符合特定的要求,但它们是如何产生的并不重要:钻孔、冲孔还是激光切割。
同样的方法在软件开发中也得到了应用:很难预测哪些代码将保持多年不变,哪些代码将经历多次修订。随着软件功能的扩展,我们需要确保我们不会意外地破坏东西。但是,我们还是会犯错。即使是最优秀的程序员也会犯错,因为他们无法预见他们所做的每一处改动的全部影响。更不用说,开发者经常在别人编写的代码上工作,他们不知道之前做出了哪些微妙的假设。他们会阅读代码,建立一个粗略的心理模型,添加必要的改动,并希望他们做对了。大多数时候,这是真的——直到它不再是。在这种情况下,引入的错误可能需要花费数小时甚至数天来修复,更不用说它可能对产品和客户造成的损害。
偶尔,你可能会遇到一些非常难以理解和跟进去的代码。你不仅会质疑这段代码是如何产生的以及它做了什么,你还会开始追查谁应该为创造这样的混乱负责。如果你发现自己是作者,也别太惊讶。这曾经发生在我身上,也会发生在你身上。有时候,代码是在匆忙中编写的,没有完全理解问题。作为开发者,我们不仅受到截止日期或预算的压力。半夜被叫醒修复一个关键故障,你会对某些错误如何逃过代码审查感到震惊。
大多数这些问题都可以通过自动化测试来避免。这些测试代码用于检查另一段代码(即生产中使用的代码)是否正确运行。正如其名,自动化测试应该在每次有人做出改动时无需提示地执行。这通常作为构建过程的一部分发生,并且经常作为控制代码质量的一个步骤,在将其合并到仓库之前执行。
你可能会有避免自动化测试以节省时间的冲动。这将是一个非常昂贵的教训。史蒂文·赖特(Steven Wright)说得对:“经验是你需要的经验之后才得到的。”相信我:除非你正在为个人目的编写一次性脚本,或者为非生产性原型编写脚本,否则不要跳过编写测试。最初,你可能会因为自己精心编写的代码不断在测试中失败而感到烦恼。但如果你真的思考一下,那个失败的测试刚刚阻止了你将一个破坏性更改推送到生产环境中。现在投入的努力将在节省修复 bug(和完整的夜晚睡眠)方面得到回报。测试并不像看起来那么难以添加和维护。
使用 CTest 在 CMake 中标准化测试
最终,自动化测试涉及到的不过是运行一个可执行文件,设置你的 test_my_app
,另一个将使用 unit_tests
,第三个将使用一些不明显或者根本不提供测试的文件。找出需要运行哪个文件,使用哪个框架,向运行器传递哪些参数,以及如何收集结果是用户希望避免的问题。
CMake 通过引入一个独立的 ctest
命令行工具来解决这个问题。它由项目作者通过列表文件进行配置,并为执行测试提供了一个统一的方式:对于使用 CMake 构建的每个项目,都有一个相同的、标准化的接口。如果你遵循这个约定,你将享受其他好处:将项目添加到(CI/CD)流水线将更容易,在诸如 Visual Studio 或 CLion 等(IDE)中突出显示它们——所有这些事情都将得到简化,更加方便。更重要的是,你将用非常少的投入获得一个更强大的测试运行工具。
如何在一个已经配置的项目上使用 CTest 执行测试?我们需要选择以下三种操作模式之一:
- 测试
- 构建与测试
- 仪表板客户端
最后一种模式允许您将测试结果发送到一个名为 CDash 的单独工具(也来自 Kitware)。CDash 通过一个易于导航的仪表板收集和汇总软件质量测试结果,如下面的屏幕截图所示:
图 8.1 ‒ CDash 仪表板时间轴视图的屏幕截图
CDash 不在本书的范围内,因为它是作为共享服务器的高级解决方案,可供公司中的所有开发者访问。
注意
如果你有兴趣在线学习,请参考 CMake 的官方文档并访问 CDash 网站:
cmake.org/cmake/help/latest/manual/ctest.1.html#dashboard-client
让我们回到前两种模式。测试模式的命令行如下所示:
ctest [<options>]
在这种模式下,应在构建树中执行 CTest,在用cmake
构建项目之后。在开发周期中,这有点繁琐,因为您需要执行多个命令并来回更改工作目录。为了简化这个过程,CTest 增加了一个第二种模式:build-and-test
模式。
构建和测试模式
要使用此模式,我们需要以--build-and-test
开始执行ctest
,如下所示:
ctest --build-and-test <path-to-source> <path-to-build> --build-generator <generator> [<options>...] [--build-options <opts>...] [--test-command <command> [<args>...]]
本质上,这是一个简单的包装器,它围绕常规测试模式接受一些构建配置选项,并允许我们添加第一个模式下的命令——换句话说,所有可以传递给ctest
的选项,在传递给ctest --build-and-test
时也会生效。这里唯一的要求是在--test-command
参数之后传递完整的命令。与您可能认为的相反,除非在--test-command
后面提供ctest
关键字,否则构建和测试模式实际上不会运行任何测试,如下所示:
ctest --build-and-test project/source-tree /tmp/build-tree --build-generator "Unix Makefiles" --test-command ctest
在这个命令中,我们需要指定源和构建路径,并选择一个构建生成器。这三个都是必需的,并且遵循cmake
命令的规则,在第一章、CMake 的初步步骤中有详细描述。
您可以传递额外的参数给这个模式。它们分为三组,分别控制配置、构建过程或测试。
以下是控制配置阶段的参数:
--build-options
—任何额外的cmake
配置(不是构建工具)选项应紧接在--test-command
之前,这是最后一个参数。--build-two-config
—为 CMake 运行两次配置阶段。--build-nocmake
—跳过配置阶段。--build-generator-platform
,--build-generator-toolset
—提供生成器特定的平台和工具集。--build-makeprogram
—在使用 Make 或 Ninja 生成器时指定make
可执行文件。
以下是控制构建阶段的参数:
--build-target
—构建指定的目标(而不是all
目标)。--build-noclean
—在不首先构建clean
目标的情况下进行构建。--build-project
—提供构建项目的名称。
这是用于控制测试阶段的参数:
--test-timeout
—限制测试的执行时间(以秒为单位)。
剩下的就是在--test-command cmake
参数之后配置常规测试模式。
测试模式
假设我们已经构建了我们的项目,并且我们在构建树中执行ctest
(或者我们使用build-and-test
包装器),我们最终可以执行我们的测试。
在没有任何参数的情况下,一个简单的ctest
命令通常足以在大多数场景中获得满意的结果。如果所有测试都通过,ctest
将返回一个0
的退出码。您可以在 CI/CD 管道中使用此命令,以防止有错误的提交合并到您仓库的生产分支。
编写好的测试可能和编写生产代码本身一样具有挑战性。我们将 SUT 设置为特定的状态,运行一个测试,然后拆除 SUT 实例。这个过程相当复杂,可能会产生各种问题:跨测试污染、时间和并发干扰、资源争用、由于死锁而导致的执行冻结,以及其他许多问题。
我们可以采用一些策略来帮助检测和解决这些问题。CTest 允许你影响测试选择、它们的顺序、产生的输出、时间限制、重复等等。以下部分将提供必要的上下文和对最有用选项的简要概述。像往常一样,请参阅 CMake 文档以获取详尽的列表。
查询测试
我们可能需要做的第一件事就是理解哪些测试实际上是为本项目编写的。CTest 提供了一个-N
选项,它禁用执行,只打印列表,如下所示:
# ctest -N Test project /tmp/b Test #1: SumAddsTwoInts Test #2: MultiplyMultipliesTwoInts Total Tests: 2
你可能想用下一节中描述的筛选器与-N
一起使用,以检查当应用筛选器时会执行哪些测试。
如果你需要一个可以被自动化工具消费的 JSON 格式,请用--show-only=json-v1
执行ctest
。
CTest 还提供了一个用LABELS
关键字来分组测试的机制。要列出所有可用的标签(而不实际执行任何测试),请使用--print-labels
。这个选项在测试用手动定义时很有帮助,例如在你的列表文件中使用add_test( )
命令,因为你可以通过测试属性指定个别标签,像这样:
set_tests_properties(<name> PROPERTIES LABELS "<label>")
另一方面,我们稍后讨论的框架提供了自动测试发现,不幸的是,它还不支持如此细粒度的标签。
面向 C++ 的现代 CMake 教程(三)(4)https://developer.aliyun.com/article/1525579