第五章:创建和运行测试
在本章中,我们将介绍以下内容:
- 创建一个简单的单元测试
- 使用 Catch2 库定义单元测试
- 定义单元测试并链接到 Google Test
- 定义单元测试并链接到 Boost 测试
- 使用动态分析检测内存缺陷
- 测试预期失败
- 为长时间测试设置超时
- 并行运行测试
- 运行测试的子集
- 使用测试夹具
引言
测试是代码开发工具箱的核心组成部分。通过使用单元和集成测试进行自动化测试,不仅可以帮助开发者在早期检测功能回归,还可以作为新加入项目的开发者的起点。它可以帮助新开发者提交代码变更,并确保预期的功能得以保留。对于代码的用户来说,自动化测试在验证安装是否保留了代码功能方面至关重要。从一开始就为单元、模块或库使用测试的一个好处是,它可以引导程序员编写更加模块化和不那么复杂的代码结构,采用纯粹的、函数式的风格,最小化并局部化全局变量和全局状态。
在本章中,我们将演示如何将测试集成到 CMake 构建结构中,使用流行的测试库和框架,并牢记以下目标:
- 让用户、开发者和持续集成服务轻松运行测试套件。在使用 Unix Makefiles 时,应该简单到只需输入
make test
。 - 通过最小化总测试时间来高效运行测试,以最大化测试经常运行的概率——理想情况下,每次代码更改后都进行测试。
创建一个简单的单元测试
本示例的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-04/recipe-01
找到,并包含一个 C++示例。该示例适用于 CMake 版本 3.5(及以上),并在 GNU/Linux、macOS 和 Windows 上进行了测试。
在本食谱中,我们将介绍使用 CTest 进行单元测试,CTest 是作为 CMake 一部分分发的测试工具。为了保持对 CMake/CTest 方面的关注并最小化认知负荷,我们希望尽可能简化要测试的代码。我们的计划是编写并测试能够求和整数的代码,仅此而已。就像在小学时,我们在学会加法后学习乘法和除法一样,此时,我们的示例代码只会加法,并且只会理解整数;它不需要处理浮点数。而且,就像年轻的卡尔·弗里德里希·高斯被他的老师测试从 1 到 100 求和所有自然数一样,我们将要求我们的代码做同样的事情——尽管没有使用高斯所用的聪明分组技巧。为了展示 CMake 对实现实际测试的语言没有任何限制,我们将不仅使用 C++可执行文件,还使用 Python 脚本和 shell 脚本来测试我们的代码。为了简单起见,我们将不使用任何测试库来完成这个任务,但我们将在本章后面的食谱中介绍 C++测试框架。
准备就绪
我们的代码示例包含三个文件。实现源文件sum_integers.cpp
负责对整数向量进行求和,并返回总和:
#include "sum_integers.hpp" #include <vector> int sum_integers(const std::vector<int> integers) { auto sum = 0; for (auto i : integers) { sum += i; } return sum; }
对于这个例子,无论这是否是最优雅的向量求和实现方式都无关紧要。接口被导出到我们的示例库中的sum_integers.hpp
,如下所示:
#pragma once #include <vector> int sum_integers(const std::vector<int> integers);
最后,main.cpp
中定义了主函数,它从argv[]
收集命令行参数,将它们转换成一个整数向量,调用sum_integers
函数,并将结果打印到输出:
#include "sum_integers.hpp" #include <iostream> #include <string> #include <vector> // we assume all arguments are integers and we sum them up // for simplicity we do not verify the type of arguments int main(int argc, char *argv[]) { std::vector<int> integers; for (auto i = 1; i < argc; i++) { integers.push_back(std::stoi(argv[i])); } auto sum = sum_integers(integers); std::cout << sum << std::endl; }
我们的目标是使用 C++可执行文件(test.cpp
)、Bash shell 脚本(test.sh
)和 Python 脚本(test.py
)来测试这段代码,以证明 CMake 并不真正关心我们偏好哪种编程或脚本语言,只要实现能够返回零或非零值,CMake 可以将其解释为成功或失败,分别。
在 C++示例(test.cpp
)中,我们通过调用sum_integers
验证 1 + 2 + 3 + 4 + 5 等于 15:
#include "sum_integers.hpp" #include <vector> int main() { auto integers = {1, 2, 3, 4, 5}; if (sum_integers(integers) == 15) { return 0; } else { return 1; } }
Bash shell 脚本测试示例调用可执行文件,该文件作为位置参数接收:
#!/usr/bin/env bash EXECUTABLE=$1 OUTPUT=$($EXECUTABLE 1 2 3 4) if [ "$OUTPUT" = "10" ] then exit 0 else exit 1 fi
此外,Python 测试脚本直接调用可执行文件(使用--executable
命令行参数传递),并允许它使用--short
命令行参数执行:
import subprocess import argparse # test script expects the executable as argument parser = argparse.ArgumentParser() parser.add_argument('--executable', help='full path to executable') parser.add_argument('--short', default=False, action='store_true', help='run a shorter test') args = parser.parse_args() def execute_cpp_code(integers): result = subprocess.check_output([args.executable] + integers) return int(result) if args.short: # we collect [1, 2, ..., 100] as a list of strings result = execute_cpp_code([str(i) for i in range(1, 101)]) assert result == 5050, 'summing up to 100 failed' else: # we collect [1, 2, ..., 1000] as a list of strings result = execute_cpp_code([str(i) for i in range(1, 1001)]) assert result == 500500, 'summing up to 1000 failed'
如何操作
现在我们将逐步描述如何为我们的项目设置测试,如下所示:
- 对于这个例子,我们需要 C++11 支持、一个可用的 Python 解释器以及 Bash shell:
cmake_minimum_required(VERSION 3.5 FATAL_ERROR) project(recipe-01 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(PythonInterp REQUIRED) find_program(BASH_EXECUTABLE NAMES bash REQUIRED)
- 然后我们定义了库、主可执行文件的依赖项以及测试可执行文件:
# example library add_library(sum_integers sum_integers.cpp) # main code add_executable(sum_up main.cpp) target_link_libraries(sum_up sum_integers)
# testing binary add_executable(cpp_test test.cpp) target_link_libraries(cpp_test sum_integers)
- 最后,我们开启测试功能并定义了四个测试。最后两个测试调用同一个 Python 脚本;首先是没有任何命令行参数,然后是使用
--short
:
enable_testing() add_test( NAME bash_test COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.sh $<TARGET_FILE:sum_up> ) add_test( NAME cpp_test COMMAND $<TARGET_FILE:cpp_test> ) add_test( NAME python_test_long COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --executable $<TARGET_FILE:sum_up> ) add_test( NAME python_test_short COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --short --executable $<TARGET_FILE:sum_up> )
- 现在,我们准备好配置和构建代码了。首先,我们手动测试它:
$ mkdir -p build $ cd build $ cmake .. $ cmake --build . $ ./sum_up 1 2 3 4 5 15
- 然后,我们可以使用
ctest
运行测试集。
$ ctest Test project /home/user/cmake-recipes/chapter-04/recipe-01/cxx-example/build Start 1: bash_test 1/4 Test #1: bash_test ........................ Passed 0.01 sec Start 2: cpp_test 2/4 Test #2: cpp_test ......................... Passed 0.00 sec Start 3: python_test_long 3/4 Test #3: python_test_long ................. Passed 0.06 sec Start 4: python_test_short 4/4 Test #4: python_test_short ................ Passed 0.05 sec 100% tests passed, 0 tests failed out of 4 Total Test time (real) = 0.12 sec
- 您还应该尝试破坏实现,以验证测试集是否捕获了更改。
它是如何工作的
这里的两个关键命令是enable_testing()
,它为这个目录及其所有子文件夹(在本例中,整个项目,因为我们将其放在主CMakeLists.txt
中)启用测试,以及add_test()
,它定义一个新测试并设置测试名称和运行命令;例如:
add_test( NAME cpp_test COMMAND $<TARGET_FILE:cpp_test> )
在前面的示例中,我们使用了一个生成器表达式:$
。生成器表达式是在构建系统生成时间评估的表达式。我们将在第五章,配置时间和构建时间操作,第 9 个配方,使用生成器表达式微调配置和编译中更详细地返回生成器表达式。目前,我们可以声明$
将被替换为cpp_test
可执行目标的完整路径。
生成器表达式在定义测试的上下文中非常方便,因为我们不必将可执行文件的位置和名称硬编码到测试定义中。以可移植的方式实现这一点将非常繁琐,因为可执行文件的位置和可执行文件后缀(例如,Windows 上的.exe
后缀)可能在操作系统、构建类型和生成器之间有所不同。使用生成器表达式,我们不必明确知道位置和名称。
还可以向测试命令传递参数以运行;例如:
add_test( NAME python_test_short COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --short --executable $<TARGET_FILE:sum_up> )
在本例中,我们按顺序运行测试(第 8 个配方,并行运行测试,将向您展示如何通过并行执行测试来缩短总测试时间),并且测试按定义的顺序执行(第 9 个配方,运行测试子集,将向您展示如何更改顺序或运行测试子集)。程序员负责定义实际的测试命令,该命令可以用操作系统环境支持的任何语言编程。CTest 唯一关心的是决定测试是否通过或失败的测试命令的返回代码。CTest 遵循标准约定,即零返回代码表示成功,非零返回代码表示失败。任何可以返回零或非零的脚本都可以用来实现测试用例。
既然我们知道如何定义和执行测试,了解如何诊断测试失败也很重要。为此,我们可以向代码中引入一个错误,并让所有测试失败:
Start 1: bash_test 1/4 Test #1: bash_test ........................***Failed 0.01 sec Start 2: cpp_test 2/4 Test #2: cpp_test .........................***Failed 0.00 sec Start 3: python_test_long 3/4 Test #3: python_test_long .................***Failed 0.06 sec Start 4: python_test_short 4/4 Test #4: python_test_short ................***Failed 0.06 sec 0% tests passed, 4 tests failed out of 4 Total Test time (real) = 0.13 sec The following tests FAILED: 1 - bash_test (Failed) 2 - cpp_test (Failed) 3 - python_test_long (Failed) 4 - python_test_short (Failed) Errors while running CTest
如果我们希望了解更多信息,可以检查文件Testing/Temporary/LastTestsFailed.log
。该文件包含测试命令的完整输出,是进行事后分析时的第一个查看地点。通过使用以下 CLI 开关,可以从 CTest 获得更详细的测试输出:
--output-on-failure
:如果测试失败,将打印测试程序产生的任何内容到屏幕上。-V
:将启用测试的详细输出。-VV
:启用更详细的测试输出。
CTest 提供了一个非常方便的快捷方式,可以仅重新运行先前失败的测试;使用的 CLI 开关是--rerun-failed
,这在调试过程中证明极其有用。
还有更多内容。
考虑以下定义:
add_test( NAME python_test_long COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py --executable $<TARGET_FILE:sum_up> )
前面的定义可以通过显式指定脚本将在其中运行的WORKING_DIRECTORY
来重新表达,如下所示:
add_test( NAME python_test_long COMMAND ${PYTHON_EXECUTABLE} test.py --executable $<TARGET_FILE:sum_up> WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} )
我们还将提到,测试名称可以包含/
字符,这在按名称组织相关测试时可能很有用;例如:
add_test( NAME python/long COMMAND ${PYTHON_EXECUTABLE} test.py --executable $<TARGET_FILE:sum_up> WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} )
有时,我们需要为测试脚本设置环境变量。这可以通过set_tests_properties
实现。
set_tests_properties(python_test PROPERTIES ENVIRONMENT ACCOUNT_MODULE_PATH=${CMAKE_CURRENT_SOURCE_DIR} ACCOUNT_HEADER_FILE=${CMAKE_CURRENT_SOURCE_DIR}/account/account.h ACCOUNT_LIBRARY_FILE=$<TARGET_FILE:account> )
这种方法可能并不总是跨不同平台都健壮,但 CMake 提供了一种绕过这种潜在健壮性不足的方法。以下代码片段等同于上述代码片段,并通过CMAKE_COMMAND
预先添加环境变量,然后执行实际的 Python 测试脚本:
add_test( NAME python_test COMMAND ${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=${CMAKE_CURRENT_SOURCE_DIR} ACCOUNT_HEADER_FILE=${CMAKE_CURRENT_SOURCE_DIR}/account/account.h ACCOUNT_LIBRARY_FILE=$<TARGET_FILE:account> ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/account/test.py )
再次注意,使用生成器表达式$
来传递库文件的位置,而无需显式硬编码路径。
我们使用ctest
命令执行了测试集,但 CMake 还将为生成器创建目标(对于 Unix Makefile 生成器使用make test
,对于 Ninja 工具使用ninja test
,或对于 Visual Studio 使用RUN_TESTS
)。这意味着还有另一种(几乎)便携的方式来运行测试步骤:
$ cmake --build . --target test
不幸的是,在使用 Visual Studio 生成器时这会失败,我们必须使用RUN_TESTS
代替:
$ cmake --build . --target RUN_TESTS
ctest
命令提供了丰富的命令行参数。其中一些将在后面的食谱中探讨。要获取完整列表,请尝试ctest --help
。命令cmake --help-manual ctest
将输出完整的 CTest 手册到屏幕上。
使用 Catch2 库定义单元测试
本食谱的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-04/recipe-02
获取,并包含一个 C++示例。该食谱适用于 CMake 版本 3.5(及更高版本),并在 GNU/Linux、macOS 和 Windows 上进行了测试。
在前一个配方中,我们在test.cpp
中使用整数返回码来表示成功或失败。这对于简单的测试来说是可以的,但通常我们希望使用一个提供基础设施的测试框架,以便运行更复杂的测试,包括固定装置、与数值容差的比较,以及如果测试失败时更好的错误报告。一个现代且流行的测试库是 Catch2(github.com/catchorg/Catch2
)。这个测试框架的一个很好的特点是它可以作为单个头文件库包含在你的项目中,这使得编译和更新框架特别容易。在本配方中,我们将使用 CMake 与 Catch2 结合,测试在前一个配方中介绍的求和代码。
准备就绪
我们将保持main.cpp
、sum_integers.cpp
和sum_integers.hpp
与之前的配方不变,但将更新test.cpp
:
#include "sum_integers.hpp" // this tells catch to provide a main() // only do this in one cpp file #define CATCH_CONFIG_MAIN #include "catch.hpp" #include <vector> TEST_CASE("Sum of integers for a short vector", "[short]") { auto integers = {1, 2, 3, 4, 5}; REQUIRE(sum_integers(integers) == 15); } TEST_CASE("Sum of integers for a longer vector", "[long]") { std::vector<int> integers; for (int i = 1; i < 1001; ++i) { integers.push_back(i); } REQUIRE(sum_integers(integers) == 500500); }
我们还需要catch.hpp
头文件,可以从github.com/catchorg/Catch2
(我们使用了 2.0.1 版本)下载,并将其放置在项目根目录中,与test.cpp
并列。
如何做
为了使用 Catch2 库,我们将修改前一个配方的CMakeLists.txt
,执行以下步骤:
- 我们可以保持
CMakeLists.txt
的大部分内容不变:
# set minimum cmake version cmake_minimum_required(VERSION 3.5 FATAL_ERROR) # project name and language project(recipe-02 LANGUAGES CXX) # require C++11 set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_STANDARD_REQUIRED ON) # example library add_library(sum_integers sum_integers.cpp) # main code add_executable(sum_up main.cpp) target_link_libraries(sum_up sum_integers) # testing binary add_executable(cpp_test test.cpp) target_link_libraries(cpp_test sum_integers)
- 与前一个配方相比,唯一的改变是删除所有测试,只保留一个,并重命名它(以明确我们改变了什么)。请注意,我们向我们的单元测试可执行文件传递了
--success
选项。这是 Catch2 的一个选项,即使在成功时也会从测试中产生输出:
enable_testing() add_test( NAME catch_test COMMAND $<TARGET_FILE:cpp_test> --success )
- 就这样!让我们配置、构建并测试。测试将使用 CTest 中的
-VV
选项运行,以从单元测试可执行文件获取输出:
$ mkdir -p build $ cd build $ cmake .. $ cmake --build . $ ctest -V UpdateCTestConfiguration from :/home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/build/DartConfiguration.tcl UpdateCTestConfiguration from :/home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/build/DartConfiguration.tcl Test project /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/build Constructing a list of tests Done constructing a list of tests Updating test list for fixtures Added 0 tests to meet fixture requirements Checking test dependency graph... Checking test dependency graph end test 1 Start 1: catch_test 1: Test command: /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/build/cpp_test "--success" 1: Test timeout computed to be: 10000000 1: 1: ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 1: cpp_test is a Catch v2.0.1 host application. 1: Run with -? for options 1: 1: ---------------------------------------------------------------- 1: Sum of integers for a short vector 1: ---------------------------------------------------------------- 1: /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:10 1: ................................................................... 1: 1: /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:12: 1: PASSED: 1: REQUIRE( sum_integers(integers) == 15 ) 1: with expansion: 1: 15 == 15 1: 1: ---------------------------------------------------------------- 1: Sum of integers for a longer vector 1: ---------------------------------------------------------------- 1: /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:15 1: ................................................................... 1: 1: /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:20: 1: PASSED: 1: REQUIRE( sum_integers(integers) == 500500 ) 1: with expansion: 1: 500500 (0x7a314) == 500500 (0x7a314) 1: 1: =================================================================== 1: All tests passed (2 assertions in 2 test cases) 1: 1/1 Test #1: catch_test ....................... Passed 0.00 s 100% tests passed, 0 tests failed out of 1 Total Test time (real) = 0.00 sec
- 我们也可以直接尝试运行
cpp_test
二进制文件,并直接从 Catch2 看到输出:
$ ./cpp_test --success ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ cpp_test is a Catch v2.0.1 host application. Run with -? for options ------------------------------------------------------------------- Sum of integers for a short vector ------------------------------------------------------------------- /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:10 ................................................................... /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:12: PASSED: REQUIRE( sum_integers(integers) == 15 ) with expansion: 15 == 15 ------------------------------------------------------------------- Sum of integers for a longer vector ------------------------------------------------------------------- /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:15 ................................................................... /home/user/cmake-cookbook/chapter-04/recipe-02/cxx-example/test.cpp:20: PASSED: REQUIRE( sum_integers(integers) == 500500 ) with expansion: 500500 (0x7a314) == 500500 (0x7a314) =================================================================== All tests passed (2 assertions in 2 test cases)
- Catch 将生成一个具有命令行界面的可执行文件。我们邀请你也尝试执行以下命令,以探索单元测试框架提供的选项:
$ ./cpp_test --help
CMake 秘籍(三)(2)https://developer.aliyun.com/article/1524618