现代C++ CMake 指南 – 11 测试框架

资深开发者深知测试必须自动化。多年前就有人向他们解释过这个道理,或者他们是通过惨痛教训学到的。但对经验不足的程序员来说,这种做法并不显而易见——它看起来像是带来很少价值的额外负担。这种想法可以理解:当人们刚开始编写代码时,他们尚未创建真正复杂的解决方案,也没有接触过大型代码库。很可能他们只是个人项目的唯一开发者。这些早期项目通常几个月就能完成,因此很少有机会看到代码在长期维护中是如何劣化的。

所有这些因素都让人认为编写测试是浪费时间和精力。编程新手可能会告诉自己,每次执行构建并运行流程时,实际上已经测试了代码。毕竟,他们已经手动确认代码能够正常工作并实现预期功能。那么,是时候继续下一个任务了,对吧?

自动化测试能确保新变更不会意外破坏程序功能。本章我们将学习测试的重要性,以及如何使用 CMake 捆绑的工具 CTest 来协调测试执行。CTest 可以查询可用测试、筛选执行顺序、随机排序、重复测试及设置时间限制。我们将探索如何使用这些功能、控制 CTest 输出以及处理测试失败情况。

接下来,我们将调整项目结构以适应测试需求,并创建自己的测试运行器。在掌握基本原理后,我们将继续添加主流测试框架:Catch2 和 GoogleTest(又称 GTest)及其模拟库。最后,我们将使用 LCOV 工具实现详细的测试覆盖率报告。

本章将涵盖以下主要内容:

仪表盘模式允许您将测试结果发送到一个名为 CDash 的独立工具(同样由 Kitware 开发)。CDash 以易于导航的仪表盘形式收集并展示软件质量测试结果。该模式对超大型项目非常有用,但已超出本书讨论范围。

测试模式的命令行如下:

为什么自动化测试值得投入精力?
使用 CTest 在 CMake 中标准化测试
为 CTest 创建最基本的单元测试
单元测试框架
生成测试覆盖率报告

为何自动化测试值得投入?

想象一条工厂生产线,机器在钢板上打孔。这些孔需要有特定的大小和形状来容纳成品螺栓。生产线设计者会设置机器、测试孔位,然后继续工作。最终某些因素会发生变化:钢板可能变厚、工人可能调整孔径,或者由于设计变更需要冲压更多孔位。聪明的设计者会在关键节点设置质量控制检查,确保产品符合规格。无论孔是如何加工的——钻孔、冲压还是激光切割——都必须满足特定要求。

同样的原则也适用于软件开发。很难预测哪些代码会保持多年稳定,哪些会经历多次修改。随着软件功能的扩展,我们必须确保不会无意中破坏原有功能。而且我们一定会犯错。即使是最好的程序员也无法预见每次变更的全部影响。开发者经常需要处理并非他们最初编写的代码,可能并不完全理解这些代码背后的所有假设。他们会阅读代码,形成思维模型,进行修改,然后希望一切顺利。当这种方法不奏效时,修复错误可能需要数小时甚至数天,并对产品及其用户产生负面影响。

有时,你会遇到难以理解的代码。你甚至可能开始责怪别人造成了这个烂摊子,结果却发现罪魁祸首就是自己。这种情况通常发生在快速编写代码时,却没有完全理解问题本质。

作为开发者,我们不仅面临项目截止日期或预算有限的压力;有时还会在深夜被叫醒修复关键问题。令人惊讶的是,一些不太明显的错误竟然能逃过代码审查。

自动化测试可以预防大部分这类问题。它们是通过代码片段来验证另一段代码是否按预期运行的机制。顾名思义,这些测试会在每次代码变更时自动执行,通常作为构建流程的一部分。它们常被设置为代码合并至代码库前的质量保障环节。

你可能会为了节省时间而跳过创建自动化测试,但这是个代价高昂的错误。正如史蒂文·赖特所说:” 经验总是在你需要它之后才会到来 “。除非你正在编写一次性脚本或进行实验性开发,否则不要跳过测试环节。最初你可能会因精心编写的代码不断测试失败而感到沮丧,但要记住:测试失败意味着你成功阻止了重大问题进入生产环境。现在投入测试的时间,将来会为你节省大量故障修复时间——还能让你睡得更安稳。测试的添加和维护难度其实远低于你的想象。

使用 CTest 在 CMake 中标准化测试

归根结底,自动化测试本质上就是运行一个可执行文件,该文件将你的被测系统 SUT)置于特定状态,执行你想要测试的操作,并检查结果是否符合预期。你可以将其视为一种结构化方式来完成句子 GIVEN_<CONDITION>_WHEN_<SCENARIO>_THEN_<EXPECTED-OUTCOME> ,并验证它对于 SUT 是否成立。一些资料建议直接用这种格式命名测试函数:例如 GIVEN_4_and_2_WHEN_Sum_THEN_returns_6 。

根据所选框架、与被测系统的连接方式及其具体设置,实现和执行这些测试有多种方法。对于首次接触项目的用户来说,即使是测试二进制文件的文件名这样的小细节也会影响他们的体验。由于没有标准的命名规范,一位开发者可能将测试可执行文件命名为 test_my_app ,另一位可能选择 unit_tests ,而第三位可能采用不太直观的命名或完全跳过测试。弄清楚该运行哪个文件、使用什么框架、传递哪些参数以及如何收集结果,这些都是用户希望避免的麻烦。

CMake 通过独立的 ctest 命令行工具解决这一问题。项目作者通过列表文件进行配置,它提供了运行测试的标准化方法。这一统一接口适用于所有使用 CMake 构建的项目。遵循此标准,您还将获得其他优势:将项目集成到持续集成/持续部署 (CI/CD)流水线中变得更加容易,测试结果在 Visual Studio 或 CLion 等 IDE 中的显示也更加便捷。最重要的是,您只需付出最小努力即可获得强大的测试运行工具。

那么,如何在已配置的项目中使用 CTest 运行测试呢?您需要选择以下三种操作模式之一:

仪表板
测试
构建与测试

ctest [<options>]

 

在此模式下,使用 CMake 构建项目后,应在构建目录中运行 CTest。虽然有许多可用选项,但在深入探讨前需要解决一个小问题: ctest 二进制文件必须在构建目录中运行,且仅能在项目构建完成后执行。这在开发周期中可能略显不便,因为您需要运行多个命令并在不同目录间切换。

为简化操作,CTest 提供了构建并测试模式 。我们将首先探讨此模式,以便稍后能全神贯注于测试模式 

构建并测试模式

使用此模式需执行 ctest ,后接 --build-and-test :

ctest --build-and-test <source-tree> <build-tree>
      --build-generator <generator> [<options>...]
      [--build-options <opts>...]
      [--test-command <command> [<args>...]]

本质上,这是对测试模式的简单封装。它在 --test-command 参数后接受构建配置选项和测试命令。需特别注意:除非像这样在 --test-command 后包含 ctest 关键字,否则不会运行任何测试:

ctest --build-and-test project/source-tree /tmp/build-tree --build-generator "Unix Makefiles" --test-command ctest

在此命令中,我们指定了源路径和构建路径,并选择了构建生成器。这三项都是必需的,并遵循 cmake 命令的规则,具体细节详见第 1 章 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 —指定要构建的目标。
--build-noclean —构建时不先构建 clean 目标。
--build-project —命名正在构建的项目。

测试阶段的参数如下:

--test-timeout —设置测试的时间限制,单位为秒。

现在我们可以通过在 --test-command cmake 后添加参数或直接运行测试模式来配置测试模式。

测试模式

构建项目后,您可以在构建目录中使用 ctest 命令来运行测试。如果使用的是构建并测试模式,系统会自动完成此操作。在大多数情况下,直接运行 ctest 而不添加额外标志通常就足够了。如果所有测试都成功, ctest 将返回退出代码 0 (在类 Unix 系统上),您可以在 CI/CD 流水线中验证这一点,以防止将有问题的更改合并到生产分支中。

编写优秀的测试代码与编写生产代码本身一样具有挑战性。我们需要将测试对象(SUT)设置为特定状态,运行单个测试,然后拆除该测试对象实例。这个过程相当复杂,可能引发各种问题:测试间污染、时序和并发干扰、资源争用、因死锁导致的执行冻结,以及其他诸多问题。

幸运的是,CTest 提供了多种选项来缓解这些问题。您可以控制测试的运行范围、执行顺序、输出内容、时间限制以及重复频率等诸多方面。接下来的章节将提供必要的背景信息,并简要概述最有用的配置选项。

查询测试

我们首先需要了解项目中实际编写了哪些测试。CTest 提供了`–show-only`选项,该选项会禁用执行仅打印测试列表,如下所示:

# ctest -N
Test project /tmp/b
  Test #1: SumAddsTwoInts
  Test #2: MultiplyMultipliesTwoInts
Total Tests: 2

您可能希望结合下一节描述的过滤器使用`–show-only`来检查应用过滤器时将执行哪些测试。

如果需要自动化工具可处理的 JSON 格式,请使用`–show-only`配合`–output-json`执行。

CTest 还提供了通过`LABELS`关键字对测试进行分组的机制。要列出所有可用标签(不实际执行任何测试),请使用`–print-labels`。当您在列表文件中使用`ctest_add_test`命令手动定义测试时,此选项特别有用,因为这样您就可以通过测试属性指定单个标签,如下所示:

set_tests_properties(<name> PROPERTIES LABELS "<label>")

然而请注意,不同框架的自动化测试发现方法可能不支持这种级别的标签详细程度。

筛选测试

有时你可能只想运行特定的测试,而不是整个测试套件。例如,在调试单个失败的测试时,无需运行其他所有测试。对于大型项目,你还可以利用这一机制将测试分配到多台机器上运行。

这些标志将根据提供的 <r> 正则表达式 regex)来筛选测试,具体如下:

-R <r> 、 --tests-regex <r> – 仅运行名称匹配 <r> 的测试
-E <r> 、 --exclude-regex <r> – 跳过名称匹配 <r> 的测试
-L <r> , --label-regex <r> – 仅运行标签匹配 <r> 的测试
-LE <r> , --label-exclude <regex> – 跳过标签匹配 <r> 的测试

高级场景可通过 --tests-information 选项(或其简写形式 -I )实现。该选项接受以逗号分隔的 <start>,<end>,<step>,<test-IDs> 格式范围。可省略任意字段但需保留逗号。 <Test IDs> 选项是以逗号分隔的要运行的测试序号列表。例如:

-I 3,, 将跳过测试1和2(从第三个测试开始执行)
-I ,2, 将仅运行第一个和第二个测试
-I 2,,3 将从第二项测试开始,每隔两项运行一次测试(即运行第2、5、8…项测试)
-I ,0,,3,9,7 将仅运行第三、第九和第七项测试

对于超大规模测试集,您还可以将这些范围指定在文件中,以便在多台机器上以分布式方式执行测试。当同时使用 -I 和 -R 时,只有同时满足两个条件的测试才会运行。若想运行满足任一条件的测试,请使用 -U 选项。如前所述,您可以使用 -N 选项来检查筛选结果。

测试随机化执行

编写单元测试可能会很棘手。其中一个令人意外的问题是测试耦合,这种情况指的是某个测试因未完全设置或清理被测系统(SUT)的状态而影响另一个测试。换句话说,先执行的测试可能会”泄漏”其状态并污染后续测试。这种耦合非常不利,因为它会在测试之间引入未知的隐性关联。

更糟糕的是,这类错误往往能很好地隐藏在复杂的测试场景中。我们可能会在它导致某个测试随机失败时发现它,但同样可能出现相反情况:错误的状态使本应失败的测试通过了。这种虚假通过的测试会给开发人员一种安全假象,这比完全没有测试还要糟糕。认为代码已被正确测试的假设可能会促使开发者采取更冒险的行动,最终导致意外结果。

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

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

遗憾的是,仅此一项措施在测试涉及 GPU、数据库或文件等外部竞争资源时仍无法奏效。我们可以采取的额外预防措施是简单地随机化测试执行顺序。引入这种变化通常足以最终检测出偶发性通过的测试。CTest 通过` --schedule-random `选项支持这一策略。

处理故障

约翰·C·麦克斯韦有一句名言:“ 及早失败,经常失败,但永远要向前失败。”所谓向前失败,就是从错误中学习。这正是我们运行单元测试时(或许也是生活中其他方面)想要做到的。除非你附加调试器运行测试,否则很难发现哪里出错,因为 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 选项。

重复测试

在你的职业生涯中,迟早会遇到那些大多数时候运行正常的测试。我想强调”大多数”这个词。极少数情况下,这些测试会因环境原因失败:比如时间模拟错误、事件循环问题、异步执行处理不当、并行性、哈希碰撞以及其他不会在每次运行时都出现的复杂场景。这类不可靠的测试被称为不稳定测试 

这种不一致性看似不是什么大问题。我们可能会说测试环境并非真实生产环境,这正是一些测试偶尔会失败的终极原因。这种说法有一定道理:测试本就不需要复制每个微小细节,因为这不现实。测试是一种模拟,是对可能发生情况的近似,通常这就足够了。如果重跑测试下次就能通过,这有什么问题呢?

实际上,问题确实存在。主要有以下三个方面的顾虑:

如果你的代码库中积累了足够多的不稳定测试,它们将成为代码变更顺利交付的严重障碍。当你赶时间时尤其令人沮丧:无论是周五下午准备下班回家,还是需要紧急修复影响客户的关键问题。
你无法完全确定那些不稳定的测试失败是由于测试环境不足造成的。情况可能恰恰相反:它们失败是因为重现了生产环境中已经存在的罕见场景。只是这些场景还不够明显,尚未触发警报……至少目前如此。
问题不在于测试不稳定——而是你的代码有问题!环境偶尔会出状况——作为程序员,我们以确定性的方式处理这种情况。如果被测系统(SUT)出现这种行为,就表明存在严重错误——例如,代码可能在读取未初始化的内存。

没有一种完美的方法能解决前面提到的所有情况——可能的原因实在太多。不过,我们可以通过使用 –repeat <mode>:<#> 选项重复运行测试来提高识别不稳定测试的几率。有三种模式可供选择,具体如下:

until-fail ——运行测试 <#> 次;所有运行都必须通过。
until-pass ——最多运行测试 <#> 次;至少要通过一次。这在处理已知不稳定但又难以调试或禁用且非常重要的测试时很有用。
after-timeout ——最多运行测试 <#> 次,但仅在测试超时时重试。适用于繁忙的测试环境。

一个普遍建议是尽快调试不稳定的测试,或者如果它们无法产生一致结果,就将其移除。

控制输出

每次都将所有信息打印到屏幕上会显得极其杂乱。CTest 减少了噪音,并将测试执行的输出收集到日志文件中,在常规运行中仅提供最有用的信息。当出现问题导致测试失败时,您会看到一个总结,如果之前启用了 --output-on-failure ,可能还会看到一些日志。

根据经验,“足够的信息”在大多数情况下是够用的,但有时并非如此。有时,我们可能还想查看通过测试的输出,以确认它们确实在正常工作(而不仅仅是静默停止且未报错)。要获取更详细的输出,可以添加 -V 选项(或在自动化流水线中明确使用 --verbose )。如果这还不够,您可能需要 -VV 或 --extra-verbose 。对于极其深入的调试,请使用 --debug (但要做好面对大量详细文本的准备)。

若您需要相反的效果,CTest 也提供了”禅模式”,可通过 -Q 或 --quiet 启用。此模式下不会输出任何内容(您大可停止焦虑,学会爱上程序缺陷)。该选项似乎除了迷惑用户外别无他用,但请注意输出仍会保存在测试文件中(默认存储在 ./Testing/Temporary )。自动化流水线可通过检查非零退出码来收集日志文件进行后续处理,避免在主输出中混杂可能让不熟悉产品的开发人员困惑的细节。

要将日志存储到特定路径,请使用 -O <file> 、 --output-log <file> 选项。若输出内容过长,可通过 --test-output-size-passed <size> 和 --test-output-size-failed <size> 两个限制选项,将每个测试的日志大小控制在指定字节数内。

杂项

以下是一些在日常测试中可能有用的其他选项:

-C <cfg>, --build-config <cfg> —指定要测试的配置。 Debug 配置通常包含调试符号,便于理解,但也应测试 Release ,因为高度优化的选项可能会影响被测系统(SUT)的行为。此选项仅适用于多配置生成器。
-j <jobs>, --parallel <jobs> —设置并行执行的测试数量。这对加速开发过程中长时间运行的测试非常有用。需注意,在繁忙环境(共享测试运行器上)可能会因调度问题产生负面影响。通过下一个选项可稍作缓解。
--test-load <level> —以 CPU 负载不超过 <level> 值为原则调度并行测试(尽力而为模式)。
--timeout <seconds> —指定单个测试的默认时间限制。

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

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

从技术上讲,即使没有任何框架也可以编写单元测试。我们只需创建要测试类的实例,执行其某个方法,然后检查返回的新状态或值是否符合预期。接着,我们报告结果并删除被测对象。让我们来试试看。

我们将采用以下结构:

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

从 main.cpp 开始,我们可以看到它使用了 Calc 类:

#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 的接口:

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

接口尽可能简洁。我们在此使用 #pragma once ——它的功能与常见的预处理器包含保护完全相同,且能被几乎所有现代编译器识别,尽管它并非官方标准的一部分。

包含保护是头文件中的简短代码,用于防止同一父文件中的多次包含。

让我们看看这个类的实现:

#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 的平方值。这个错误本应通过正确编写的单元测试检测出来。那么,让我们来写一些测试吧!开始:

#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 消息。

是时候创建一个测试运行器了。我们的实现将尽可能简单,以避免引入过多工作量。看看我们为了运行仅两个测试就不得不编写的 main() 函数吧:

#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 来使用它:

cmake_minimum_required(VERSION 3.26.0)
project(NoFrameworkTests CXX)
include(CTest)
add_subdirectory(src bin)
add_subdirectory(test)

我们首先从常规的头部和 include(CTest) 开始。这会启用 CTest,并且始终应在顶层 CMakeLists.txt 中完成。接着,我们在每个子目录中包含两个嵌套的列表文件: src 和 test 。指定的 bin 值表明我们希望将 src 子目录中的二进制输出文件放置在 <build_tree>/bin 中。否则,二进制文件将生成在 <build_tree>/src 中,这可能会让用户感到困惑,因为构建产物并非源文件。

对于 src 目录,清单文件结构简单,仅包含一个基础的 main 目标定义:

add_executable(main main.cpp calc.cpp)

我们还需要为 test 目录准备一个清单文件:

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 实现文件及其对应的头文件。最后,我们明确添加了两个测试:

SumAddsTwoInts
MultiplyMultipliesTwoInts

每个测试都将其 ID 作为参数传递给 add_test() 命令。CTest 会简单地获取 COMMAND 关键字后提供的任何内容,并在子 shell 中执行,收集输出和退出代码。不要过于依赖 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 的返回值未达预期。很好,我们现在知道代码中存在一个 bug,需要有人来修复它。

你可能已经注意到,在目前的大多数示例中,我们并未严格采用第 4 章  搭建首个 CMake 项目 》所描述的项目结构。这是为了保持内容简洁。本章将探讨更高级的概念,因此有必要采用完整结构。在您的项目中(无论规模多小),最好从一开始就遵循这种结构。正如一位智者所言:” 踏上道路后若不站稳脚跟,就不知会被冲往何方 “。

现在应该很清楚,为自己的项目从头开始构建测试框架并不可取。即使是最基础的示例也显得杂乱无章,存在大量冗余,且无法带来任何价值。不过,在采用单元测试框架之前,我们需要重新思考项目的结构。

为测试构建项目结构

C++具备有限的反射能力,但无法提供像 Java 那样强大的内省特性。这可能是为 C++代码编写测试和单元测试框架比其他功能更丰富的环境更具挑战性的原因之一。这种局限性的一个结果是程序员需要更多地参与编写可测试代码。我们需要精心设计接口并考虑实际因素。例如,如何避免重复编译代码并在测试与生产环境之间复用构件?

对于小型项目而言,编译时间或许不是大问题,但随着项目规模扩大,保持短编译周期的需求依然存在。在前述示例中,除了 main.cpp 文件外,我们将所有被测系统源码都纳入了单元测试可执行文件。细心的读者可能已注意到,该文件中部分代码(即 main() 自身内容)未被测试覆盖。双重编译会略微增加生成产物不一致的可能性,这种差异会随时间推移逐渐累积——特别是在添加编译标志和预处理器指令时,当贡献者时间紧迫、经验不足或不熟悉项目时,可能带来风险。

针对此问题存在多种解决方案,但最直接的方法是将整个解决方案构建为库文件,并与单元测试进行链接。您可能会疑惑如何运行它。答案是创建一个引导可执行文件,该文件与库链接并执行其代码。

首先将当前的 main() 函数重命名为类似 run() 或 start_program() 的名称。然后创建另一个实现文件( bootstrap.cpp ),其中仅包含一个新的 main() 函数。该函数作为适配器:其唯一作用是提供入口点并调用 run() ,同时传递所有命令行参数。将所有内容链接在一起后,最终会得到一个可测试的项目。

通过重命名 main() ,你现在可以将被测系统(SUT)与测试链接起来,并测试其主要功能。否则,你将违反单一定义规则 ODR),该规则在第 8 章  链接可执行文件与库 》中讨论过,因为测试运行器也需要自己的 main() 函数。正如我们在第 8 章  为测试分离 main()》一节中承诺的,我们将在此详细探讨这个话题。

还需注意的是,测试框架可能默认提供自己的 main() 函数,因此可能无需自行编写。通常,它会自动检测所有链接的测试,并根据你的配置运行它们。

采用此方法生成的产物可分为以下目标:

一个包含生产代码的 sut 库
bootstrap 通过 main() 包装器从 sut 调用 run()
unit tests 通过 main() 包装器在 sut 上运行所有测试

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

最终我们得到了六个实现文件,它们将生成各自对应的( .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 可以节省一些时间,但我们经常需要同时修改测试目标和 SUT 目标,这反而会抵消节省的时间。

让我们从先前名为 main.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() 函数如下所示:

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

简单起见,我们声明链接器将从另一个翻译单元提供 run() 函数,并调用它。

接下来是 src 列表文件:

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 链接的目标(即 bootstrap 和 unit_tests )。注意, 包含目录是相对于列表文件的,这让我们可以用点号( . )来表示当前 <source_tree>/src 目录。

现在更新我们的 unit_tests 目标。我们将把直接引用 ../src/calc.cpp 文件改为为 unit_tests 目标添加对 sut 的链接引用。我们还会为 run_test.cpp 文件中的主函数添加一个新测试。为简洁起见,这部分就不详细讨论了,但如果你感兴趣,可以查看本书代码库中的示例。

与此同时,以下是完整的 test 列表文件:

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

ch11/02-structured/test/CMakeLists.txt(续)

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

完成!我们已按要求注册了新测试。遵循这一做法,您可以确保测试运行在将与生产环境完全相同的机器代码上。

我们在此使用的目标名称 sut 和 bootstrap ,是为了从测试角度清晰表达其用途而选择的。在实际项目中,您应选择与生产代码上下文相符的名称(而非测试用途)。例如,对于 FooApp,应将目标命名为 foo 而非 bootstrap ,命名为 lib_foo 而非 sut 。

既然我们已经了解了如何将项目结构化为可测试的目标,现在让我们将注意力转向测试框架本身。我们肯定不想手动将每个测试用例都添加到列表文件中吧?

单元测试框架

上一节表明,编写一个小型单元测试驱动程序的难度并不高。它可能不够美观,但信不信由你,一些专业开发者确实喜欢重复造轮子,认为自己的版本在各方面都会更优。请避免这个陷阱:最终你会编写大量样板代码,以至于可能演变成独立项目。使用流行的单元测试框架能让你的解决方案符合跨项目、跨公司认可的标准,通常还能获得免费更新和扩展。稳赚不赔。

如何将单元测试框架集成到项目中?当然是通过按照所选框架规则实现测试用例,再将这些测试与框架提供的测试运行器链接。测试运行器会启动选定测试的执行并收集结果。与我们之前查看的基础 unit_tests.cpp 文件不同,多数框架能自动检测所有测试用例并使其对 CTest 可见。整个过程流畅得多。

在本章中,我特意选择介绍两种单元测试框架,原因如下:

Catch2 相对容易学习,并且提供了良好的支持和文档。它不仅支持基础的测试用例,还包含优雅的宏用于行为驱动开发 BDD)。虽然可能缺少某些功能,但在需要时可以通过外部工具进行补充。访问其主页:https://github.com/catchorg/Catch2。
GoogleTest (GTest) 既便捷又功能强大。它提供了丰富的特性,包括多种断言、死亡测试,以及值和类型参数化测试。甚至还能通过其 GMock 模块支持 XML 测试报告生成和模拟测试。访问这里获取:https://github.com/google/googletest。

框架的选择取决于你的学习偏好和项目规模。如果你喜欢循序渐进且不需要完整功能集,Catch2 是个不错的选择。那些喜欢直接深入且需要全面工具集的开发者会发现 GoogleTest 更合适。

Catch2

这个由 Martin Hořeňovský维护的框架非常适合初学者和小型项目。这并不是说它不能适应大型应用,但要注意在某些领域可能需要额外工具(深入探讨这点会使我们偏离主题太远)。首先,让我们来看一个针对 Calc 类的简单单元测试实现:

#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() 即可将其引入项目。

你可以选择两个主要版本:Catch2 v2 和 Catch2 v3。版本 2 是遗留选项,提供为 C++11 的单头文件库。版本 3 需编译为静态库且要求 C++14。建议选择最新发布版本。

使用 Catch2 时,请确保选择 Git 标签并在列表文件中固定版本。通过 main 分支升级无法保证无缝衔接。

在商业环境中,您很可能需要在持续集成(CI)流水线中运行测试。这种情况下,请确保预先配置好环境,使系统已安装所有依赖项,这样每次构建时就不需要重复获取这些依赖。如《第 9 章》中使用 CMake 管理依赖项一节尽可能使用已安装的依赖项所述,您应该扩展 FetchContent_Declare() 命令,添加 FIND_PACKAGE_ARGS 关键字来使用系统中的软件包。

我们将这样在列表文件中包含3.4.0版本:

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

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

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 中,具体如下:

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

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

# ./test/unit_tests
unit_tests is a Catch2 v3.4.0 host application.
Run with -? for options
---------------------------------------------------------------------
MultiplyMultipliesTwoInts
---------------------------------------------------------------------
/root/examples/ch11/03-catch2/test/calc_test.cpp:9
.....................................................................
/root/examples/ch11/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

Catch2 能够将 sut.Multiply(3, 4) 表达式展开为 9 ,为我们提供了更多上下文信息,这对调试非常有帮助。

请注意,直接运行 runner 二进制文件(编译后的 unit_test 可执行文件)可能比使用 ctest 稍快一些,但 CTest 提供的额外优势值得这一性能取舍。

至此已完成 Catch2 的配置。若将来需要添加更多测试,只需创建新的实现文件并将其路径加入 unit_tests 目标的源文件列表即可。

Catch2 提供多种功能如事件监听器、数据生成器和微基准测试,但缺少内置的模拟功能。如果您不熟悉模拟测试,我们将在下一节详述。您可以通过以下模拟框架之一为 Catch2 添加模拟功能:

FakeIt (https://github.com/eranpeer/FakeIt)
Hippomocks (https://github.com/dascandy/hippomocks)
Trompeloeil (https://github.com/rollbear/trompeloeil)

话虽如此,若追求更精简、高级的体验,还有另一个值得已关注的框架——GoogleTest。

GoogleTest

使用 GoogleTest 有几个重要优势:它历史悠久且在 C++社区中广受认可,因此多个 IDE 都原生支持它。由全球最大搜索引擎公司维护并广泛使用,使其不太可能过时或被弃用。它能测试 C++11 及以上版本,这对于在较旧环境中工作的开发者是个好消息。

GoogleTest 代码库包含两个项目:GTest(主测试框架)和 GMock(提供模拟功能的库)。这意味着只需一次 FetchContent() 调用即可下载两者。

使用 GTest

要使用 GTest,我们的项目需要遵循为测试构建项目结构章节的指导。以下是该框架中编写单元测试的方式:

#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 还提供了许多其他类型的断言、辅助工具和宏值得探索。有关 GTest 的详细信息,请参阅官方参考文档(GoogleTest User’s Guide | GoogleTest)。

要将此依赖项添加到我们的项目中,我们需要决定使用哪个版本。与 Catch2 不同,GoogleTest 倾向于采用”live at head”理念(源自 GTest 依赖的 Abseil 项目)。其声明表示:” 如果你从源代码构建我们的依赖项并遵循我们的 API,应该不会遇到任何问题。“(更多详情请参阅延伸阅读部分)。如果你愿意遵循这一规则(且从源代码构建不是问题),请将 Git 标签设置为 master 分支。否则,请从 GoogleTest 仓库中选择一个发布版本。

在商业环境中,您很可能需要在持续集成(CI)流水线中运行测试。这种情况下,请确保预先配置好环境,使系统已安装所有依赖项,这样每次构建时就不需要重复获取这些依赖。如《第 9 章》中使用 CMake 管理依赖项一节尽可能使用已安装的依赖项所述,您应该扩展 FetchContent_Declare() 命令,添加 FIND_PACKAGE_ARGS 关键字来使用系统中的软件包。

无论哪种情况,添加 GTest 依赖项的方式如下:

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

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

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

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/ch11/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 命令行参数启用它)。

既然框架已经搭建好了,现在我们来讨论模拟测试。毕竟,当测试与其他元素紧密耦合时,就不能算是真正的”单元测试”了。

GMock

编写纯粹的单元测试关键在于让一段代码与其他代码隔离执行。被测单元必须是一个自包含的元素,可以是一个类或组件。当然,用 C++编写的程序中,几乎没有任何单元能完全与其他部分隔离。

很可能,你的代码会大量依赖类之间的某种关联关系。但这样做只有一个问题:该类的对象需要另一个类的对象,而后者又需要其他对象。不知不觉中,整个解决方案都参与了”单元测试”。更糟的是,你的代码可能与外部系统紧密耦合,并依赖于其状态。例如,它可能严重依赖数据库中的特定记录、传入的网络数据包或磁盘上存储的特定文件。

为了解耦单元以便测试,开发者会使用测试替身或被测单元专用的特殊类版本。例如仿制品、桩件和模拟对象等。以下是这些术语的粗略定义:

一个伪对象(fake) 是对更复杂机制的有限实现。例如用内存映射替代实际的数据库客户端。
一个桩对象(stub) 为方法调用提供预设的固定响应,仅限于测试所需的返回值。它还能记录哪些方法被调用及调用次数。
模拟对象(mock) 是桩对象的扩展版本,它还会验证测试过程中方法是否按预期被调用。

这类测试替身(test double)在测试开始时创建,并通过构造函数注入被测类以替代真实对象,这种机制称为依赖注入(dependency injection)

简单测试替身的问题在于它们过于简单 。为了模拟不同测试场景下的行为,我们不得不提供许多不同的替身,每个替身对应被耦合对象可能处于的每一种状态。这种做法既不实用,也会使测试代码分散在过多文件中。这时 GMock 就派上用场了:它允许开发者为特定类创建通用测试替身,并针对每个测试内联定义其行为。GMock 称这些替身为”mocks”,但实际上它们是根据场合混合了所有前述测试替身的综合体。

考虑以下示例:我们为 Calc 类添加一个功能,该功能会为提供的参数添加一个随机数。这将通过一个返回该和作为 int 的 AddRandomNumber() 方法来实现。我们如何确认返回值确实是某个随机数与提供给类的值的精确和?众所周知,随机生成的数字是许多重要流程的关键,如果使用不当,可能会遭受各种后果。检查所有随机数直到穷尽所有可能性并不实际。

为了测试它,我们需要将一个随机数生成器封装在一个可被模拟(或者说,用模拟对象替换)的类中。模拟对象能让我们强制返回特定响应,用于”伪造”随机数的生成。 Calc 将在 AddRandomNumber() 中使用该值,使我们能够检查该方法返回的值是否符合预期。将随机数生成与其他单元清晰分离还具有额外价值(因为我们可以随时更换不同类型的生成器)。

让我们从抽象生成器的公共接口开始。这个头文件将允许我们在实际生成器和模拟器中实现它,从而能够互换使用它们:

#pragma once
class RandomNumberGenerator {
public:
  virtual int Get() = 0;
  virtual ~RandomNumberGenerator() = default;
};

实现此接口的类将通过 Get() 方法为我们提供一个随机数。注意 virtual 关键字——除非我们想处理更复杂的基于模板的模拟,否则所有需要模拟的方法都必须加上该关键字。此外,我们还需要记得添加一个虚析构函数。

接下来,我们需要扩展我们的 Calc 类,使其能够接收并存储生成器,这样我们既可以为发布版本提供真实的生成器,也可以为测试提供模拟器:

#pragma once
#include "rng.h"
class Calc {
  RandomNumberGenerator* rng_;
public:
   Calc(RandomNumberGenerator* rng);
   int Sum(int a, int b);
   int Multiply(int a, int b);
   int AddRandomNumber(int a);
};

我们包含了头文件,并添加了一个提供随机加法的方法。此外,还创建了一个用于存储生成器指针的字段,以及一个带参数的构造函数。这就是依赖注入在实际中的应用。现在,我们按如下方式实现这些方法:

#include "calc.h"
Calc::Calc(RandomNumberGenerator* rng) {
  rng_ = rng;
}
int Calc::Sum(int a, int b) {
  return a + b;
}
int Calc::Multiply(int a, int b) {
  return a * b; // now corrected
}
int Calc::AddRandomNumber(int a) {
  return a + rng_->Get();
}

构造函数中,我们将传入的指针赋值给类的字段。随后,在 AddRandomNumber() 处使用该字段获取生成的值。生产代码将使用真实的数值生成器;而测试代码则会使用模拟对象。请注意,我们需要对指针进行解引用以实现多态。额外提示:我们可以针对不同实现创建不同的生成器类。我只需要一个:具备均匀分布的梅森旋转伪随机数生成器,如下列代码片段所示:

#include <random>
#include "rng_mt19937.h"
int RandomNumberGeneratorMt19937::Get() {
  std::random_device rd;
  std::mt19937 gen(rd());
  std::uniform_int_distribution<> distrib(1, 6);
  return distrib(gen);
}

每次调用都创建新实例效率不高,但对于这个简单示例已足够。目的是生成从 1 到 6 的数字并返回给调用方。

该类的头文件仅提供了一个方法的签名:

#include "rng.h"
class RandomNumberGeneratorMt19937
      : public RandomNumberGenerator {
public:
  int Get() override;
};

以下是我们如何在生产代码中使用它的:

#include <iostream>
#include "calc.h"
#include "rng_mt19937.h"
using namespace std;
int run() {
  auto rng = new RandomNumberGeneratorMt19937();
  Calc c(rng);
  cout << "Random dice throw + 1 = "
       << c.AddRandomNumber(1) << endl;
  delete rng;
  return 0;
}

我们已创建生成器并将其指针传递给 Calc 的构造函数。一切准备就绪,可以开始编写模拟对象了。为保持代码整洁,开发者通常会将模拟对象存放在单独的 test/mocks 目录中。为避免歧义,头文件名带有 _mock 后缀。

代码如下:

#pragma once
#include "gmock/gmock.h"
class RandomNumberGeneratorMock : public
RandomNumberGenerator {
public:
  MOCK_METHOD(int, Get, (), (override));
};

添加 gmock.h 头文件后,我们可以声明模拟对象。按照计划,这是一个实现 RandomNumberGenerator 接口的类。我们无需自行编写方法,而是需要使用 GMock 提供的 MOCK_METHOD 宏指令。这些宏会告知框架需要模拟接口中的哪些方法。请使用以下格式(必须保留这些括号):

MOCK_METHOD(<return type>, <method name>,
           (<argument list>), (<keywords>))

我们已准备好在测试套件中使用模拟对象(为简洁起见省略了之前的测试用例),如下所示:

#include <gtest/gtest.h>
#include "calc.h"
#include "mocks/rng_mock.h"
using namespace ::testing;
class CalcTestSuite : public Test {
protected:
  RandomNumberGeneratorMock rng_mock_;
  Calc sut_{&rng_mock_};
};
TEST_F(CalcTestSuite, AddRandomNumberAddsThree) {
  EXPECT_CALL(rng_mock_, Get()).Times(1).WillOnce(Return(3));
  EXPECT_EQ(4, sut_.AddRandomNumber(1));
}

让我们分解这些变更:我们添加了新标头并在测试套件中为 rng_mock_ 创建了新字段。接着,将模拟地址传递给 sut_ 的构造函数。之所以能这样做,是因为字段是按照声明顺序初始化的( rng_mock_ 先于 sut_ )。

在我们的测试用例中,我们对 rng_mock_ 的 Get() 方法调用了 GMock 的 EXPECT_CALL 宏。这指示测试框架:若执行过程中未调用 Get() 方法,则测试失败。链式调用的 Times 明确声明了测试通过所需的方法调用次数。 WillOnce 决定了模拟框架在方法调用后的行为(此处返回 3 )。

通过使用 GMock,我们能够将模拟行为与预期结果同步表达。这显著提升了测试代码的可读性,并简化了维护工作。最重要的是,它为每个测试用例提供了灵活性——仅需一条富有表现力的语句就能区分不同场景。

最后,为了构建项目,我们需要确保 gmock 库已与测试运行器链接。为此,我们将其添加到 target_link_libraries() 列表中:

include(FetchContent)
FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG release-1.14.0
)
# For Windows: Prevent overriding the parent project's
  compiler/linker settings
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
add_executable(unit_tests
               calc_test.cpp
               run_test.cpp)
target_link_libraries(unit_tests PRIVATE sut gtest_main gmock)
include(GoogleTest)
gtest_discover_tests(unit_tests)

现在,我们可以充分享受 GoogleTest 框架带来的所有优势。GTest 和 GMock 都是功能强大的工具,包含适用于各种场景的丰富概念、实用工具和辅助功能。这个示例(尽管略显冗长)仅仅触及了其可能性的表面。我强烈建议您将其融入项目,它们将显著提升工作质量。对于 GMock 入门,官方文档中的”Mocking for Dummies”页面是个不错的起点(您可以在延伸阅读部分找到相关链接)。

有了测试之后,我们需要设法衡量哪些代码被测试覆盖、哪些没有,并努力改善现状。最好使用自动化工具来收集和报告这些信息。

生成测试覆盖率报告

为如此小规模的解决方案添加测试并不特别困难。真正的挑战出现在稍微复杂些、代码量更大的程序中。多年来我发现,当代码量超过 1000 行时,就逐渐难以追踪测试执行了哪些行和分支;超过 3000 行后几乎不可能手动跟踪。大多数专业应用程序的代码量远不止于此。更重要的是,许多管理者用来协商解决技术债务的关键指标之一就是代码覆盖率百分比,因此掌握如何生成有用的报告有助于为这些讨论提供实际数据。为解决这个问题,我们可以使用工具来了解哪些代码行被测试用例”覆盖”。这类代码覆盖率工具会连接到被测系统(SUT),收集测试期间每行代码的执行信息,并以如下所示的直观报告形式呈现:

这些报告将展示哪些文件被测试覆盖,哪些未被覆盖。不仅如此,您还可以深入查看每个文件的细节,确切了解哪些代码行被执行以及执行了多少次。在下面的截图中, 行数据列显示 Calc 构造函数运行了 4 次,每次测试各执行一次: 

生成相似报告有多种方法,这些方法因平台和编译器的不同而有所差异,但通常遵循相同的流程:准备待测系统(SUT)并获取基准数据、进行测量、生成报告。

这项任务最简单的工具名为 LCOV。它并非首字母缩写词,而是 gcov 的图形化前端界面,后者是 GNU 编译器套件 GCC)中的覆盖率工具。下面我们来看看如何实际使用它。

使用 LCOV 生成覆盖率报告

LCOV 会生成 HTML 格式的覆盖率报告,并在内部使用 gcov 来衡量覆盖率。如果你使用 Clang,不必担心——Clang 支持生成这种格式的指标数据。你可以从 Linux Test Project(https://github.com/linux-test-project/lcov)维护的官方代码库获取 LCOV,或直接通过包管理器安装。正如其名所示,这是一个面向 Linux 的工具。

虽然可以在 macOS 上运行它,但 Windows 平台不受支持。终端用户通常不关心测试覆盖率,因此通常只需在你自己的构建环境中手动安装 LCOV,而无需将其集成到项目中。

要测量覆盖率,我们需要执行以下操作:

在 Debug 配置下编译,启用代码覆盖率的编译器标志。这将生成覆盖率记录( .gcno )文件。
将测试可执行文件与 gcov 库链接。
收集基准的覆盖率指标,无需运行任何测试。
运行测试。这将生成覆盖率数据( .gcda )文件。
将指标收集到一个聚合信息文件中。
生成一份( .html )报告。

我们首先需要解释为何代码必须在 Debug 配置下编译。最重要的原因是,通常 Debug 配置会通过 -O0 标志禁用所有优化。CMake 默认在 CMAKE_CXX_FLAGS_DEBUG 变量中实现这一点(尽管文档中并未明确说明)。除非您决定覆盖此变量,否则您的 Debug 构建应该是未经优化的。这样做的目的是为了防止任何内联和其他形式的隐式代码简化。否则,将难以追踪机器指令源自源代码的哪一行。

第一步,我们需要指示编译器为被测系统(SUT)添加必要的插桩工具。具体添加的标志取决于编译器类型;不过,两大主流编译器(GCC 和 Clang)都提供相同的 --coverage 标志来启用覆盖率插桩,并以 GCC 兼容的 gcov 格式生成数据。

以下是为上一节示例 SUT 添加覆盖率插桩的方法:

 

add_library(sut STATIC calc.cpp run.cpp rng_mt19937.cpp)
target_include_directories(sut PUBLIC .)
if (CMAKE_BUILD_TYPE STREQUAL Debug)
  target_compile_options(sut PRIVATE --coverage)
  target_link_options(sut PUBLIC --coverage)
  add_custom_command(TARGET sut PRE_BUILD COMMAND
                     find ${CMAKE_BINARY_DIR} -type f
                     -name '*.gcda' -exec rm {} +)
endif()
add_executable(bootstrap bootstrap.cpp)
target_link_libraries(bootstrap PRIVATE sut)

让我们一步步分解,具体如下:

确保我们在 Debug 配置下运行 if(STREQUAL) 命令。请注意,除非使用 -DCMAKE_BUILD_TYPE=Debug 选项运行 cmake ,否则无法获取任何覆盖率。
将 --coverage 添加到构成 sut 库的所有目标文件编译选项中。
将 --coverage 添加到 PUBLIC 链接器选项中:GCC 和 Clang 都会将此解释为请求将 gcov (或兼容)库与所有依赖 sut 的目标链接(由于属性传播)。
引入 add_custom_command() 命令用于清理所有过时的 .gcda 文件。添加此命令的原因在避免 SEGFAULT 陷阱一节中有详细讨论。

这足以生成代码覆盖率。如果您使用像 CLion 这样的 IDE,您将能够运行带有覆盖率的单元测试,并在内置报告视图中查看结果。然而,这不会在 CI/CD 中运行的任何自动化流水线中生效。要获取报告,我们需要使用 LCOV 自行生成。

为此,最好定义一个名为 coverage 的新目标。为了保持整洁,我们将在另一个文件中定义一个单独的函数 AddCoverage ,用于 test 列表文件,如下所示:

function(AddCoverage target)
  find_program(LCOV_PATH lcov REQUIRED)
  find_program(GENHTML_PATH genhtml REQUIRED)
  add_custom_target(coverage
    COMMENT "Running coverage for ${target}..."
    COMMAND ${LCOV_PATH} -d . --zerocounters
    COMMAND $<TARGET_FILE:${target}>
    COMMAND ${LCOV_PATH} -d . --capture -o coverage.info
    COMMAND ${LCOV_PATH} -r coverage.info '/usr/include/*'
                         -o filtered.info
    COMMAND ${GENHTML_PATH} -o coverage filtered.info
      --legend
    COMMAND rm -rf coverage.info filtered.info
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
  )
endfunction()

在前面的代码片段中,我们首先检测了 LCOV 包中的两个命令行工具 lcov 和 genhtml 的路径。 REQUIRED 关键字指示 CMake 在未找到这些工具时抛出错误。接着,我们通过以下步骤添加了一个自定义的 coverage 目标:

清除之前运行的计数器。
运行 target 可执行文件(使用生成器表达式获取其路径)。 $<TARGET_FILE:target> 是一个特殊的生成器表达式,在这种情况下它会隐式添加对 target 的依赖,导致在执行所有命令前先构建它。我们将 target 作为参数提供给此函数。
从当前目录( -d . )收集解决方案的指标并输出到文件( -o coverage.info )。
移除( -r )系统头文件( '/usr/include/*' )上不需要的覆盖率数据,并输出到另一个文件( -o filtered.info )。
在 coverage 目录下生成 HTML 报告,并添加 --legend 颜色。
删除临时 .info 文件。
指定 WORKING_DIRECTORY 关键字会将二叉树设置为所有命令的工作目录。

这些是 GCC 和 Clang 的通用步骤。需要注意的是, gcov 工具的版本必须与编译器版本匹配:不能将 GCC 的 gcov 工具用于 Clang 编译的代码。要让 lcov 指向 Clang 的 gcov 工具,可以使用 --gcov-tool 参数。唯一的问题是它必须是单个可执行文件。为此,我们可以提供一个简单的包装脚本(记得用 chmod +x 将其标记为可执行文件),如下所示:

# cmake/gcov-llvm-wrapper.sh
#!/bin/bash
exec llvm-cov gcov "$@"

这样做意味着我们在之前的函数中对 ${LCOV_PATH} 的所有调用都会收到以下标志:

--gcov-tool ${CMAKE_SOURCE_DIR}/cmake/gcov-llvm-wrapper.sh

确保此函数可被包含在 test 列表文件中。我们可以通过在主列表文件中扩展包含搜索路径来实现,如下所示:

cmake_minimum_required(VERSION 3.26.0)
project(Coverage CXX)
include(CTest)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
add_subdirectory(src bin)
add_subdirectory(test)

高亮显示的这行代码让我们能够将 cmake 目录下的所有 .cmake 文件包含到项目中。现在,我们可以在 test 列表文件中使用 Coverage.cmake ,如下所示:

# ... skipped unit_tests target declaration for brevity
include(Coverage)
AddCoverage(unit_tests)
include(GoogleTest)
gtest_discover_tests(unit_tests)

要构建 coverage 目标,请使用以下命令(注意第一个命令以 -DCMAKE_BUILD_TYPE=Debug 构建类型选择结束):

# cmake -B <binary_tree> -S <source_tree> -DCMAKE_BUILD_TYPE=Debug
# cmake --build <binary_tree> -t coverage

执行完上述所有步骤后,您将看到如下简短摘要:

Writing directory view page.
Overall coverage rate:
  lines......: 95.7% (22 of 23 lines)
  functions..: 75.0% (6 of 8 functions)
[100%] Built target coverage

接下来,在浏览器中打开 coverage/index.html 文件即可查看报告!不过还有一个小问题……

避免 SEGFAULT 陷阱

当我们开始在这种构建好的解决方案中编辑源代码时,可能会遇到问题。这是因为覆盖率信息被分成了两部分:

gcno 文件,或称 GNU 覆盖率注释 ,在 SUT 编译期间生成
gcda 文件,或称 GNU 覆盖率数据 ,在测试运行期间生成并更新

“更新”功能是导致段错误的潜在源头。初次运行测试后,会遗留大量未被清除的 gcda 文件。若我们对源代码进行修改并重新编译目标文件 ,系统将生成新的 gcno 文件。但由于缺少清理步骤——先前测试运行产生的 gcda 文件仍关联着过时的源代码。当我们执行 unit_tests 二进制文件时(该操作发生在 gtest_discover_tests 宏中),覆盖率信息文件将不匹配,从而引发 SEGFAULT (段错误)错误。

为避免此问题,我们应清除所有过时的 gcda 文件。由于我们的 sut 实例是 STATIC 库,可将 add_custom_command(TARGET) 命令挂接到构建事件中。清理操作将在重新构建开始前执行。

延伸阅读部分可找到更多信息的链接。

总结

表面上看,与正确测试相关的复杂性似乎大到不值得投入精力。令人惊讶的是,有大量代码在没有任何测试的情况下运行,主要理由是测试软件是一项艰巨的任务。我要补充的是:如果是手动测试,难度就更大了。遗憾的是,没有严格的自动化测试,代码中任何问题的可见性都是不完整或根本不存在的。未经测试的代码可能写起来更快(但也不总是如此);然而,阅读、重构和修复它肯定要慢得多。

在本章中,我们概述了从一开始就进行测试的一些关键原因。其中最引人注目的是心理健康和良好的睡眠。没有一个开发者躺在床上会想: 我等不及几小时后被叫醒去处理生产环境的问题和修复漏洞了 。但说真的,在将错误部署到生产环境之前发现它们,对你(和公司)来说可能是救命稻草。

在测试工具方面,CMake 真正展现了其强大实力。CTest 能够出色地检测问题测试:隔离、随机排序、重复执行及超时处理。这些技术都极为实用,且通过便捷的命令行标志即可调用。我们已掌握如何使用 CTest 列出测试、筛选测试并控制测试用例输出,但最重要的是,我们现在明白了全面采用标准化解决方案的真正价值。任何基于 CMake 构建的项目都能以完全相同的方式进行测试,无需探究其内部实现细节。

接下来,我们调整了项目结构,以简化测试流程,并在生产代码和测试运行器之间复用相同的目标文件 。编写自己的测试运行器固然有趣,但或许我们应该聚焦程序要解决的实际问题,投入时间采用流行的第三方测试框架更为明智。

说到这,我们已经掌握了 Catch2 和 GoogleTest 的基础知识。我们进一步深入研究了 GMock 库的细节,理解了测试替身如何使真正的单元测试成为可能。最后,我们使用 LCOV 搭建了一些测试报告功能。毕竟,没有什么比硬数据更能证明我们的解决方案确实经过了充分测试。

在下一章中,我们将讨论更多实用工具,这些工具有助于提升源代码质量,并发现那些我们甚至未曾察觉的问题。

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容