面向 C++ 的现代 CMake 教程(三)(4)

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: 面向 C++ 的现代 CMake 教程(三)

面向 C++ 的现代 CMake 教程(三)(3)https://developer.aliyun.com/article/1525578

过滤测试

有很多理由只运行所有测试的一部分——最常见的原因可能是需要调试一个失败的测试或你正在工作的模块。在这种情况下,等待所有其他测试是没有意义的。其他高级测试场景甚至可能将测试用例分区并在测试运行器集群上分布负载。

这些标志将根据提供的 正则表达式regex)过滤测试,如下所示:

  • -R , --tests-regex —只运行名称匹配的测试
  • -E , --exclude-regex —跳过名称匹配的测试
  • -L , --label-regex —只运行标签匹配的测试
  • -LE , --label-exclude <正则表达式>—跳过标签匹配的测试

使用--tests-information选项(或更短的形式,-I)可以实现高级场景。用这个筛选器提供一个逗号分隔的范围内的值:<开始>, <结束>, <步长>。任意字段都可以为空,再有一个逗号之后,你可以附加个别<测试 ID>值来运行它们。以下是几个例子:

  • -I 3,,将跳过 1 和 2 个测试(执行从第三个测试开始)
  • -I ,2,只运行第一和第二个测试
  • -I 2,,3将从第二行开始运行每个第三测试
  • -I ,0,,3,9,7将只运行第三、第九和第七个测试

选择性地,CTest 将接受包含规格的文件名,格式与上面相同。正如您所想象的,用户更喜欢按名称过滤测试。此选项可用于将测试分布到多台机器上,适用于非常大的测试套件。

默认情况下,与-R一起使用的-I选项将缩小执行范围(仅运行同时满足两个要求的测试)。如果您需要两个要求的并集来执行(任一要求即可),请添加-U选项。

如前所述,您可以使用-N选项来检查过滤结果。

洗牌测试

编写单元测试可能很棘手。遇到的一个更令人惊讶的问题就是测试耦合,这是一种情况,其中一个测试通过不完全设置或清除 SUT 的状态来影响另一个测试。换句话说,首先执行的测试可能会“泄漏”其状态,污染第二个测试。这种耦合之所以糟糕,是因为它引入了测试之间的未知、隐性关系。

更糟糕的是,这种错误在测试场景的复杂性中隐藏得非常好。我们可能会在它导致测试失败时检测到它,但反之亦然:错误的状态导致测试通过,而它本不该通过。这种虚假通过的测试给开发者带来了安全感,这比没有测试还要糟糕。代码正确测试的假设可能会鼓励更大胆的行动,导致意外的结果。

发现此类问题的一种方法是单独运行每个测试。通常,当我们直接从测试框架中执行测试运行器而不使用 CTest 时,并非如此。要运行单个测试,您需要向测试可执行文件传递框架特定的参数。这允许您检测在测试套件中通过但在单独执行时失败的测试。

另一方面,CTest 有效地消除了所有基于内存的测试交叉污染,通过隐式执行子 CTest 实例中的每个测试用例。您甚至可以更进一步,添加--force-new-ctest-process选项以强制使用单独的进程。

不幸的是,仅凭这一点还不足以应对测试使用的外部、争用资源,如 GPU、数据库或文件。我们可以采取的额外预防措施之一是简单地随机化测试执行顺序。这种干扰通常足以最终检测到这种虚假通过的测试。CTest 支持这种策略,通过--schedule-random选项。

处理失败

这里有一句约翰·C· Maxwell 著名的名言:“Fail early, fail often, but always fail forward.” 这正是我们在执行单元测试时(也许在生活的其他领域)想要做的事情。除非你在运行测试时附带了调试器,否则很难了解到你在哪里出了错,因为 CTest 会保持简洁,只列出失败的测试,而不实际打印它们的输出。

测试案例或 SUT 打印到stdout的信息可能对确定具体出了什么问题非常有价值。为了看到这些信息,我们可以使用--output-on-failure运行ctest。另外,设置CTEST_OUTPUT_ON_FAILURE环境变量也会有相同的效果。

根据解决方案的大小,在任何一个测试失败后停止执行可能是有意义的。这可以通过向ctest提供--stop-on-failure参数来实现。

CTest 存储了失败测试的名称。为了在漫长的测试套件中节省时间,我们可以关注这些失败的测试,并在解决问题前跳过运行通过的测试。这个特性可以通过使用--rerun-failed选项来实现(忽略其他任何过滤器)。记得在解决问题后运行所有测试,以确保在此期间没有引入回归。

当 CTest 没有检测到任何测试时,这可能意味着两件事:要么是测试不存在,要么是项目有问题。默认情况下,ctest会打印一条警告信息并返回一个0退出码,以避免混淆。大多数用户会有足够的上下文来理解他们遇到了哪种情况以及接下来应该做什么。然而,在某些环境中,ctest总是会执行,作为自动化流水线的一部分。那么,我们可能需要明确表示,测试的缺失应该被解释为错误(并返回非零退出码)。我们可以通过提供--no-tests=error参数来配置这种行为。要实现相反的行为(不警告),请使用--no-tests=ignore选项。

重复执行测试

迟早在你的职业生涯中,你将会遇到那些大部分时间都能正确工作的测试。我想强调一下most这个词。偶尔,这些测试会因为环境原因而失败:由于错误地模拟了时间、事件循环问题、异步执行处理不当、并发性、散列冲突,以及其他在每次运行时都不会发生的非常复杂的情况。这些不可靠的测试被称为“flaky tests”。

这种不一致性看起来并不是一个很重要的问题。我们可能会说测试并不等同于真正的生产环境,这也是它们有时候会失败的根本原因。这种说法有一定的道理:测试不可能模拟每一个细节,因为这并不可行。测试是一种模拟,是对可能发生的事情的一种近似,这通常已经足够好了。如果测试在下次执行时会通过,重新运行测试有什么害处呢?

实际上,这是有关系的。主要有三个担忧,如下所述:

  • 如果你在你的代码库中收集了足够的不稳定测试,它们将成为代码变更顺利交付的一个严重障碍。尤其是当你急于回家(比如周五下午)或交付一个严重影响客户问题的紧急修复时,这种情况尤其令人沮丧。
  • 你无法真正确信你的不稳定测试之所以失败是因为测试环境的不足。可能正好相反:它们失败是因为它们复现了一个在生产环境中已经发生的罕见场景。只是还没有足够明显地发出警报… 而已。
  • 不是测试本身具有不稳定性——是你的代码有问题!环境有时确实会出问题——作为程序员,我们以确定性的方式处理这些问题。如果 SUT 以这种方式运行,这是一个严重错误的迹象——例如,代码可能正在读取未初始化的内存。

没有一种完美的方式来解决所有上述情况——可能的原因太多。然而,我们可以通过使用–repeat :<#>选项来重复运行测试,从而增加我们识别不稳定测试的机会。以下是三种可供选择的模式:

  • until-fail—运行测试<#>次;所有运行都必须通过。
  • until-pass—运行测试至<#>次;至少要通过一次。当处理已知具有不稳定性的测试时,这个方法很有用,但这些测试太难且重要,无法进行调试或禁用。
  • after-timeout—运行测试至<#>次,但只有在测试超时的情况下才重试。在繁忙的测试环境中使用它。

一般建议尽快调试不稳定测试或如果它们不能被信任以产生一致的结果,就摆脱它们。

控制输出

每次都将所有信息打印到屏幕上会立即变得非常繁忙。Ctest 减少了噪音,并将它执行的测试的输出收集到日志文件中,在常规运行中只提供最有用的信息。当事情变坏,测试失败时,如果你启用了--output-on-failure(如前面所述),你可以期待一个摘要,可能还有一些日志。

我从经验中知道,“足够的信息”是足够的,直到它不再足够。有时,我们可能希望查看通过测试的输出,也许是为了检查它们是否真的在正常工作(而不是默默地停止,没有错误)。为了获取更详细的输出,可以添加-V选项(或者如果你想在自动化管道中明确表示,可以使用--verbose)。如果这还不够,你可能想要-VV--extra-verbose。对于非常深入的调试,可以使用--debug(但要做好准备,因为会有很多文本细节)。

如果你在寻找相反的,CTest 还提供了通过-Q启用的“禅模式”,或--quiet。那时将不会打印任何输出(你可以停止担心,学会平静)。似乎这个选项除了让人困惑之外没有其他用途,但请注意,输出仍然会存储在测试文件中(默认在./Testing/Temporary中)。自动化管道可以通过检查退出代码是否非零值,并在不向开发者输出可能混淆的详细信息的情况下,收集日志文件进行进一步处理。

要将在特定路径存储日志,请使用-O <文件>--output-log <文件>选项。如果您苦于输出过长,有两个限制选项可以将它们限制为每个测试给定的字节数:--test-output-size-passed <大小>--test-output-size-failed <大小>

杂项

还有一些其他的有用选项,可以满足你日常测试需求,如下所述:

  • -C <配置>, --build-config <配置>(仅限多配置生成器)—使用此选项指定要测试的配置。Debug配置通常包含调试符号,使事情更容易理解,但Release也应该测试,因为强烈的优化选项可能会潜在地影响 SUT 的行为。
  • -j <作业数>, --parallel <作业数>—这设置了并行执行的测试数量。在开发过程中,它非常有用,可以加快长测试的执行。请注意,在一个繁忙的环境中(在共享的测试运行器上),它可能会因调度而产生不利影响。这可以通过下一个选项稍微缓解。
  • --test-load <级别>—使用此选项以一种方式安排并行测试,使 CPU 负载不超过<级别>值(尽最大努力)。
  • --timeout <秒>—使用此选项指定单个测试的默认时间限制。

既然我们已经了解了如何在许多不同场景下执行ctest,那么让我们学习如何添加一个简单的测试。

为 CTest 创建最基本的单元测试

技术上讲,编写单元测试可以在没有任何框架的情况下进行。我们只需要做的是创建一个我们想要测试的类的实例,执行其一种方法,并检查返回的新状态或值是否符合我们的期望。然后,我们报告结果并删除被测试对象。让我们试一试。

我们将使用以下结构:

- CMakeLists.txt
- src
  |- CMakeLists.txt
  |- calc.cpp
  |- calc.h
  |- main.cpp
- test
  |- CMakeLists.txt
  |- calc_test.cpp

main.cpp开始,我们可以看到它将使用一个Calc类,如下面的代码片段所示:

chapter08/01-no-framework/src/main.cpp

#include <iostream>
#include "calc.h"
using namespace std;
int main() {
  Calc c;
  cout << "2 + 2 = " << c.Sum(2, 2) << endl;
  cout << "3 * 3 = " << c.Multiply(3, 3) << endl;
}

并不太复杂—main.cpp简单地包含了calc.h头文件,并调用了Calc对象的两种方法。让我们快速看一下Calc的接口,我们的 SUT 如下:

chapter08/01-no-framework/src/calc.h

#pragma once
class Calc {
 public:
   int Sum(int a, int b);
   int Multiply(int a, int b);
};

界面尽可能简单。我们在这里使用了#pragma once——它的工作方式与常见的预处理器包含保护符完全一样,尽管它不是官方标准的一部分,但几乎所有现代编译器都能理解。让我们看看类的实现,如下所示:

chapter08/01-no-framework/src/calc.cpp

#include "calc.h"
int Calc::Sum(int a, int b) {
  return a + b;
}
int Calc::Multiply(int a, int b) {
  return a * a; // a mistake!
}

哎呀!我们引入了一个错误!Multiply忽略了b参数,而是返回a的平方。这应该被正确编写的单元测试检测到。所以,让我们写一些!开始吧:

chapter08/01-no-framework/test/calc_test.cpp

#include "calc.h"
#include <cstdlib>
void SumAddsTwoIntegers() {
  Calc sut;
  if (4 != sut.Sum(2, 2))
    std::exit(1);
}
void MultiplyMultipliesTwoIntegers() {
  Calc sut;
  if(3 != sut.Multiply(1, 3))
    std::exit(1);
}

我们开始编写calc_test.cpp文件,其中包含两个测试方法,分别针对 SUT 的每个测试方法。如果从调用方法返回的值与期望不符,每个函数都将调用std::exit(1)。我们本可以使用assert()abort()terminate(),但那样的话,在ctest的输出中,我们将得到一个更难读的Subprocess aborted消息,而不是更易读的Failed消息。

是时候创建一个测试运行器了。我们的将会尽可能简单,因为正确地做这将需要大量的工作。 just look at the main() function we had to write in order to run just two tests:

chapter08/01-no-framework/test/unit_tests.cpp

#include <string>
void SumAddsTwoIntegers();
void MultiplyMultipliesTwoIntegers();
int main(int argc, char *argv[]) {
  if (argc < 2 || argv[1] == std::string("1"))
    SumAddsTwoIntegers();
  if (argc < 2 || argv[1] == std::string("2"))
    MultiplyMultipliesTwoIntegers();
}

下面是这里发生的事情的分解:

  • 我们声明了两个外部函数,它们将从另一个翻译单元链接过来。
  • 如果没有提供任何参数,执行两个测试(argv[]中的零元素总是程序名)。
  • 如果第一个参数是测试的标识符,执行它。
  • 如果有任何测试失败,它内部调用exit()并返回1退出码。
  • 如果没有执行任何测试或所有测试都通过,它隐式地返回0退出码。

要运行第一个测试,我们将执行./unit_tests 1;要运行第二个,我们将执行./unit_tests 2。我们尽可能简化代码,但它仍然变得相当难以阅读。任何可能需要维护这一部分的人在添加更多测试后都不会有很好的时光,更不用说这个功能相当原始——调试这样一个测试套件将是一项艰巨的工作。尽管如此,让我们看看我们如何使用它与 CTest,如下所示:

chapter08/01-no-framework/CMakeLists.txt

cmake_minimum_required(VERSION 3.20.0)
project(NoFrameworkTests CXX)
enable_testing()
add_subdirectory(src bin)
add_subdirectory(test)

我们从常用的标题和enable_testing()开始。这是为了告诉 CTest 我们想在当前目录及其子目录中启用测试。接下来,我们在每个子目录中包含两个嵌套的列表文件:srctest。高亮的bin值表示我们希望src子目录的二进制输出放在/bin中。否则,二进制文件将出现在/src中,这可能会引起混淆。毕竟,构建工件不再是源文件。

src目录的列表文件非常直接,包含一个简单的main目标定义,如下所示:

chapter08/01-no-framework/src/CMakeLists.txt

add_executable(main main.cpp calc.cpp)

我们还需要为test目录编写一个列表文件,如下所示:

chapter08/01-no-framework/test/CMakeLists.txt

add_executable(unit_tests
               unit_tests.cpp
               calc_test.cpp
               ../src/calc.cpp)
target_include_directories(unit_tests PRIVATE ../src)
add_test(NAME SumAddsTwoInts COMMAND unit_tests 1)
add_test(NAME MultiplyMultipliesTwoInts COMMAND unit_tests 2)

我们现在定义了第二个unit_tests目标,它也使用src/calc.cpp实现文件和相应的头文件。最后,我们明确添加了两个测试:SumAddsTwoIntsMultiplyMultipliesTwoInts。每个都将其 ID 作为add_test()命令的参数。CTest将简单地取COMMAND关键字之后提供的一切,并在子壳中执行它,收集输出和退出代码。不要对add_test()过于依赖——在单元测试框架部分,我们将发现处理测试用例的更好方法,所以我们在这里不详细描述它。

这是在构建树中执行时ctest实际的工作方式:

# ctest
Test project /tmp/b
    Start 1: SumAddsTwoInts
1/2 Test #1: SumAddsTwoInts ...................   Passed    0.00 sec
    Start 2: MultiplyMultipliesTwoInts
2/2 Test #2: MultiplyMultipliesTwoInts ........***Failed    0.00 sec
50% tests passed, 1 tests failed out of 2
Total Test time (real) =   0.00 sec
The following tests FAILED:
          2 - MultiplyMultipliesTwoInts (Failed)
Errors while running CTest
Output from these tests are in: /tmp/b/Testing/Temporary/LastTest.log
Use "--rerun-failed --output-on-failure" to re-run the failed cases verbosely.

ctest执行了这两个测试,并报告说其中一个失败——Calc::Multiply返回的值没有达到预期。非常好。我们现在知道我们的代码有一个错误,有人应该修复它。

注意

你可能注意到,在迄今为止的大多数例子中,我们并没有一定使用在第第三章设置你的第一个 CMake 项目中描述的项目结构。这是为了保持事情的简洁。本章讨论更多高级概念,因此使用完整的结构是合适的。在你的项目中(无论多么小),最好从一开始就遵循这个结构。正如一个智者曾经说过:“你踏上道路,如果你不保持你的脚步,你不知道会被冲到哪里.

众所周知,你应避免在项目中构建测试框架。即使是最基础的例子也会让人眼睛疲劳,开销很大,并且没有增加任何价值。然而,在我们采用单元测试框架之前,我们需要重新思考项目的结构。

为测试搭建项目结构

C++具有一些有限的内省能力,但无法提供像 Java 那样的强大回顾功能。这可能正是编写 C++代码的测试和单元测试框架比在其他更丰富的环境中困难的原因。这种经济方法的含义之一是程序员必须更参与构造可测试代码。我们不仅要更仔细地设计我们的接口,还要回答关于实践问题,例如:我们如何避免编译双重,并在测试和生产之间重用工件?

编译时间对于小型项目可能不是一个问题,但随着时间推移,项目会增长。对于短编译循环的需求并不会消失。在之前的例子中,我们将所有sut源文件附加到单元测试可执行文件中,除了main.cpp文件。如果你仔细阅读,你会发现我们在这个文件中有些代码是没有被测试的(main()本身的内容)。通过编译代码两次,产生的工件可能不会完全相同。这些事物可能会随着时间的推移而逐渐偏离(由于添加了编译标志和预处理器指令)。当工程师匆忙、缺乏经验或不熟悉项目时,这可能尤其危险。

处理这个问题有多种方法,但最优雅的方法是将整个解决方案构建为一个库,并与单元测试链接。你可能会问:“我们怎么运行它呢?”我们需要一个引导可执行文件,它将链接库并运行其代码。

首先,将您当前的main()函数重命名为run()start_program()或类似名称。然后,创建另一个实现文件(bootstrap.cpp),其中包含一个新的main()函数,仅此而已。这将成为我们的适配器(或者说是包装器):它的唯一作用是提供一个入口点并调用run()转发命令行参数(如果有)。剩下的就是将所有内容链接在一起,这样我们就有了一个可测试的项目。

通过重命名main(),我们现在可以链接被测试的系统(SUT)和测试,并且还能测试它的主要功能。否则,我们就违反了main()函数。正如第六章“为测试分离 main()”部分所承诺的,我们将详细解释这个主题。

测试框架可能提供自己的main()函数实现,所以我们不需要编写。通常,它会检测我们链接的所有测试,并根据所需配置执行它们。

这种方法产生的工件可以分为以下目标:

  • 带有生产代码的sut
  • bootstrap带有main()包装器,调用sut中的run()
  • 带有main()包装器,运行所有sut测试的单元测试

以下图表展示了目标之间的符号关系:

图 8.2 ‒ 在测试和生产可执行文件之间共享工件

我们最终会得到六个实现文件,它们将生成各自的(.o目标文件,如下所示:

  • calc.cpp—要进行单元测试的Calc类。这被称为被测试单元UUT),因为 UUT 是 SUT 的一个特化。
  • run.cpp—原始入口点重命名为run(),现在可以进行测试。
  • bootstrap.cpp—新的main()入口点调用run()
  • calc_test.cpp—测试Calc类。
  • run_test.cpp—新增run()的测试可以放在这里。
  • unit_tests.o—单元测试的入口点,扩展为调用run()的测试。

我们即将构建的库实际上并不需要是一个实际的库:静态的或共享的。通过创建一个对象库,我们可以避免不必要的归档或链接。从技术上讲,通过为 SUT 依赖动态链接来节省几秒钟是可能的,但往往我们同时在两个目标上进行更改:testssut,抵消了任何潜在的收益。

让我们看看我们的文件有哪些变化,首先是从先前命名为main.cpp的文件开始,如下所示:

chapter08/02-structured/src/run.cpp

#include <iostream>
#include "calc.h"
using namespace std;
int run() {
  Calc c;
  cout << "2 + 2 = " << c.Sum(2, 2) << endl;
  cout << "3 * 3 = " << c.Multiply(3, 3) << endl;
  return 0;
}

变化并不大:重命名文件和函数。我们还添加了一个return语句,因为编译器不会隐式地为非main()函数这样做。

新的main()函数看起来像这样:

chapter08/02-structured/src/bootstrap.cpp

int run(); // declaration
int main() {
  run();
}

尽可能简单——我们声明链接器将从另一个翻译单元提供run()函数,并且我们调用它。接下来需要更改的是src列表文件,您可以看到这里:

chapter08/02-structured/src/CMakeLists.txt

add_library(sut STATIC calc.cpp run.cpp)
target_include_directories(sut PUBLIC .)
add_executable(bootstrap bootstrap.cpp)
target_link_libraries(bootstrap PRIVATE sut)

首先,我们创建了一个sut库,并将.标记为PUBLIC 包含目录,以便将其传播到所有将链接sut的目标(即bootstrapunit_tests)。请注意,包含目录是相对于列表文件的,因此我们可以使用点(.)来引用当前的/src目录。

是时候更新我们的unit_tests目标了。在这里,我们将移除对../src/calc.cpp文件的直接引用,改为sut的链接引用作为unit_tests目标。我们还将为run_test.cpp文件中的主函数添加一个新测试。为了简洁起见,我们将跳过讨论那个部分,但如果您感兴趣,可以查看在线示例。同时,这是整个test列表文件:

chapter08/02-structured/test/CMakeLists.txt

add_executable(unit_tests
               unit_tests.cpp
               calc_test.cpp
               run_test.cpp)
target_link_libraries(unit_tests PRIVATE sut)

我们还应该注册新的测试,如下所示:

add_test(NAME SumAddsTwoInts COMMAND unit_tests 1)
add_test(NAME MultiplyMultipliesTwoInts COMMAND unit_tests 2)
add_test(NAME RunOutputsCorrectEquations COMMAND unit_tests 3)

完成!通过遵循这种做法,您可以确信您的测试是在将用于生产的实际机器代码上执行的。

注意

我们在这里使用的目标名称sutbootstrap,是为了让从测试的角度来看它们非常清晰。在实际项目中,您应该选择与生产代码上下文相匹配的名称(而不是测试)。例如,对于一个 FooApp,将您的目标命名为foo,而不是bootstrap,将lib_foo命名为sut

既然我们已经知道如何在一个适当的目标中结构一个可测试的项目,那么让我们将重点转移到测试框架本身。我们不想手动将每个测试用例添加到我们的列表文件中,对吧?

单元测试框架

上一节证明了编写一个微小的单元测试驱动并不非常复杂。它可能不够美观,但信不信由你,专业开发者实际上确实喜欢重新发明轮子(他们的轮子会更漂亮、更圆、更快)。不要陷入这个陷阱:你会创建出如此多的模板代码,它可能成为一个独立的项目。将一个流行的单元测试框架引入你的解决方案中,可以使它符合超越项目和公司的标准,并为你带来免费的更新和扩展。你没有损失。

我们如何将单元测试框架添加到我们的项目中呢?嗯,根据所选框架的规则在实现文件中编写测试,并将这些测试与框架提供的测试运行器链接起来。测试运行器是您的入口点,将启动所选测试的执行。与我们在本章早些时候看到的基本的unit_tests.cpp文件不同,许多它们将自动检测所有测试。太美了。

本章我决定介绍两个单元测试框架。我选择它们的原因如下:

  • Catch2 是一个相对容易学习、得到良好支持和文档的项目。它提供了简单的测试用例,但同时也提供了用于行为驱动开发BDD)的优雅宏。它缺少一些功能,但在需要时可以与外部工具配合使用。您可以在这里访问其主页:github.com/catchorg/Catch2
  • GTest 也非常方便,但功能更加强大。它的关键特性是一组丰富的断言、用户定义的断言、死亡测试、致命和非致命失败、值和类型参数化测试、XML 测试报告生成以及模拟。最后一个是通过从同一存储库中可用的 GMock 模块提供的: github.com/google/googletest

您应该选择哪个框架取决于您的学习方法和项目大小。如果您喜欢缓慢、逐步的过程,并且不需要所有的花哨功能,那么选择 Catch2。那些喜欢从深层次开始并需要大量火力支持的开发人员将受益于选择 GTest。

Catch2

这个由 Martin Hořeňovský维护的框架,对于初学者和小型项目来说非常棒。这并不是说它不能处理更大的应用程序,只要你记住,只要记得在需要额外工具的区域会有所需要。如果我详细介绍这个框架,我就会偏离本书的主题太远,但我仍然想给你一个概述。首先,让我们简要地看看我们可以为我们的Calc类编写单元测试的实现,如下所示:

chapter08/03-catch2/test/calc_test.cpp

#include <catch2/catch_test_macros.hpp>
#include "calc.h"
TEST_CASE("SumAddsTwoInts", "[calc]") {
  Calc sut;
  CHECK(4 == sut.Sum(2, 2));
}
TEST_CASE("MultiplyMultipliesTwoInts", "[calc]") {
  Calc sut;
  CHECK(12 == sut.Multiply(3, 4));
}

就这样。这几行比我们之前写的例子要强大得多。CHECK()宏不仅验证期望是否满足——它们还会收集所有失败的断言,并在单个输出中呈现它们,这样你就可以进行一次修复,避免重复编译。

现在,最好的一部分:我们不需要在任何地方添加这些测试,甚至不需要通知 CMake 它们存在;你可以忘记add_test(),因为你再也用不到了。如果允许的话,Catch2 会自动将你的测试注册到 CTest。在上一节中描述的配置项目后,添加框架非常简单。我们需要使用FetchContent()将其引入项目。

有两个主要版本可供选择:v2v3。版本 2 作为一个单头库(只需#include )提供给 C++11,最终将被版本 3 所取代。这个版本由多个头文件组成,被编译为静态库,并要求 C++14。当然,如果你能使用现代 C++(是的,C++11 不再被认为是“现代”的),那么推荐使用更新的版本。在与 Catch2 合作时,你应该选择一个 Git 标签并在你的列表文件中固定它。换句话说,不能保证升级不会破坏你的代码(升级很可能不会破坏代码,但如果你不需要,不要使用devel分支)。要获取 Catch2,我们需要提供一个仓库的 URL,如下所示:

chapter08/03-catch2/test/CMakeLists.txt

include(FetchContent)
FetchContent_Declare(
  Catch2
  GIT_REPOSITORY https://github.com/catchorg/Catch2.git
  GIT_TAG        v3.0.0
)
FetchContent_MakeAvailable(Catch2)

然后,我们需要定义我们的unit_tests目标,并将其与sut以及一个框架提供的入口点和Catch2::Catch2WithMain库链接。由于 Catch2 提供了自己的main()函数,我们不再使用unit_tests.cpp文件(这个文件可以删除)。代码如下所示:

chapter08/03-catch2/test/CMakeLists.txt(续)

add_executable(unit_tests 
               calc_test.cpp 
               run_test.cpp)
target_link_libraries(unit_tests PRIVATE 
                      sut Catch2::Catch2WithMain)

最后,我们使用由 Catch2 提供的模块中定义的catch_discover_tests()命令,该命令将检测unit_tests中的所有测试用例并将它们注册到 CTest,如下所示:

chapter08/03-catch2/test/CMakeLists.txt(续)

list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
include(Catch)
catch_discover_tests(unit_tests)

完成了。我们刚刚为我们的解决方案添加了一个单元测试框架。现在让我们看看它的实际应用。测试运行器的输出如下所示:

# ./test/unit_tests
unit_tests is a Catch v3.0.0 host application.
Run with -? for options
--------------------------------------------------------------
MultiplyMultipliesTwoInts
--------------------------------------------------------------
examples/chapter08/03-catch2/test/calc_test.cpp:9
..............................................................
examples/chapter08/03-catch2/test/calc_test.cpp:11: FAILED:
  CHECK( 12 == sut.Multiply(3, 4) )
with expansion:
  12 == 9
==============================================================
test cases: 3 | 2 passed | 1 failed
assertions: 3 | 2 passed | 1 failed

直接执行运行器(编译的unit_test可执行文件)稍微快一点,但通常,你希望使用ctest --output-on-failure命令,而不是直接执行测试运行器,以获得前面提到的所有 CTest 好处。注意 Catch2 能够方便地将sut.Multiply(3, 4)表达式扩展为9,为我们提供更多上下文。

这就结束了 Catch2 的设置。如果你还需要添加更多测试,只需创建实现文件并将它们的路径添加到unit_tests目标的源列表中。

这个框架包含了一些有趣的小技巧:事件监听器、数据生成器和微基准测试,但它并不提供模拟功能。如果你不知道什么是模拟,继续阅读——我们马上就会涉及到这一点。然而,如果你发现自己需要模拟,你总是可以在这里列出的一些模拟框架旁边添加 Catch2:

话说回来,对于一个更简洁、更先进的体验,还有另一个框架值得一看。

GTest

使用 GTest 有几个重要的优点:它已经存在很长时间,并且在 C++社区中高度认可(因此,多个 IDE 支持它)。背后最大的搜索引擎公司的维护和广泛使用,所以它很可能在不久的将来变得过时或被遗弃。它可以测试 C++11 及以上版本,所以如果你被困在一个稍微老一点的环境中,你很幸运。

GTest 仓库包括两个项目:GTest(主测试框架)和 GMock(一个添加模拟功能的库)。这意味着你可以用一个FetchContent()调用来下载它们。

使用 GTest

要使用 GTest,我们的项目需要遵循为测试结构化项目部分的方向。这就是我们在这个框架中编写单元测试的方法:

chapter08/04-gtest/test/calc_test.cpp

#include <gtest/gtest.h>
#include "calc.h"
class CalcTestSuite : public ::testing::Test {
 protected:
  Calc sut_;
};
TEST_F(CalcTestSuite, SumAddsTwoInts) {
  EXPECT_EQ(4, sut_.Sum(2, 2));
}
TEST_F(CalcTestSuite, MultiplyMultipliesTwoInts) {
  EXPECT_EQ(12, sut_.Multiply(3, 4));
}

由于这个例子也将用于 GMock,我决定将测试放在一个CalcTestSuite类中。测试套件是相关测试的组,因此它们可以重用相同的字段、方法和设置(初始化)以及清理步骤。要创建一个测试套件,我们需要声明一个新的类,从::testing::Test继承,并将重用元素(字段、方法)放在其protected部分。

测试套件中的每个测试用例都是用TEST_F()预处理器宏声明的,该宏将测试套件和测试用例提供的名称字符串化(还有一个简单的TEST()宏,定义不相关的测试)。因为我们已经在类中定义了Calc sut_,每个测试用例可以像CalcTestSuite的一个方法一样访问它。实际上,每个测试用例在其自己的类中隐式继承自CalcTestSuite运行(这就是我们需要protected关键字的原因)。请注意,重用字段不是为了在连续测试之间共享任何数据——它们的目的是保持代码DRY

GTest 没有提供像 Catch2 那样的自然断言语法。相反,我们需要使用一个显式的比较,比如EXPECT_EQ()。按照惯例,我们将期望值作为第一个参数,实际值作为第二个参数。还有许多其他断言、助手和宏值得学习。

注意

关于 GTest 的详细信息,请参阅官方参考资料(google.github.io/googletest/).

要将此依赖项添加到我们的项目中,我们需要决定使用哪个版本。与 Catch2 不同,GTest 倾向于采用“现场开发”的理念(起源于 GTest 所依赖的 Abseil 项目)。它指出:“如果你从源代码构建我们的依赖项并遵循我们的 API,你不会遇到任何问题。”(更多详情请参阅进阶阅读部分。)

如果你习惯于遵循这个规则(并且从源代码构建没有问题),将你的 Git 标签设置为master分支。否则,从 GTest 仓库中选择一个版本。我们还可以选择首先在宿主机器上搜索已安装的副本,因为 CMake 提供了一个捆绑的FindGTest模块来查找本地安装。自 v3.20 起,CMake 将使用上游的GTestConfig.cmake配置文件(如果存在),而不是依赖于可能过时的查找模块。

无论如何,添加对 GTest 的依赖项看起来是这样的:

chapter08/04-gtest/test/CMakeLists.txt

include(FetchContent)
FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG master
)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)

我们遵循与 Catch2 相同的方法——执行FetchContent()并从源代码构建框架。唯一的区别是在 GTest 作者建议的set(gtest...)命令,以防止在 Windows 上覆盖父项目的编译器和链接器设置。

最后,我们可以声明我们的测试运行器可执行文件,链接gtest_main,并借助内置的 CMake GoogleTest模块自动发现我们的测试用例,如下所示:

chapter08/04-gtest/test/CMakeLists.txt(续)

add_executable(unit_tests
               calc_test.cpp
               run_test.cpp)
target_link_libraries(unit_tests PRIVATE sut gtest_main)
include(GoogleTest)
gtest_discover_tests(unit_tests)

这完成了 GTest 的设置。测试运行器的输出比 Catch2 更详细,但我们可以传递--gtest_brief=1来限制它仅显示失败,如下所示:

# ./test/unit_tests --gtest_brief=1
~/examples/chapter08/04-gtest/test/calc_test.cpp:15: Failure
Expected equality of these values:
  12
  sut_.Multiply(3, 4)
    Which is: 9
[  FAILED  ] CalcTestSuite.MultiplyMultipliesTwoInts (0 ms)
[==========] 3 tests from 2 test suites ran. (0 ms total)
[  PASSED  ] 2 tests.

幸运的是,即使从 CTest 运行时,噪音输出也会被抑制(除非我们显式地在ctest --output-on-failure命令行上启用它)。

现在我们已经有了框架,让我们讨论一下模拟。毕竟,当它与其他元素耦合时,没有任何测试可以真正称为“单元”。

面向 C++ 的现代 CMake 教程(三)(5)https://developer.aliyun.com/article/1525580

相关实践学习
日志服务之数据清洗与入湖
本教程介绍如何使用日志服务接入NGINX模拟数据,通过数据加工对数据进行清洗并归档至OSS中进行存储。
相关文章
|
6天前
|
C++
Clion CMake C/C++程序输出乱码
Clion CMake C/C++程序输出乱码
10 0
|
8天前
|
存储 算法 编译器
C++ 函数式编程教程
C++ 函数式编程学习
|
8天前
|
存储 编译器 开发工具
C++语言教程分享
C++语言教程分享
|
8天前
|
存储 编译器 C++
|
29天前
|
C++ 存储 索引
面向 C++ 的现代 CMake 教程(一)(5)
面向 C++ 的现代 CMake 教程(一)
46 0
|
29天前
|
缓存 存储 C++
面向 C++ 的现代 CMake 教程(一)(4)
面向 C++ 的现代 CMake 教程(一)
45 0
|
29天前
|
C++ 缓存 存储
面向 C++ 的现代 CMake 教程(一)(3)
面向 C++ 的现代 CMake 教程(一)
43 0
|
29天前
|
缓存 C++ Windows
面向 C++ 的现代 CMake 教程(一)(2)
面向 C++ 的现代 CMake 教程(一)
57 0
|
29天前
|
C++ 容器 Docker
面向 C++ 的现代 CMake 教程(一)(1)
面向 C++ 的现代 CMake 教程(一)
67 0
|
1天前
|
编译器 C语言 C++